pex

Transactional SDK · Ruby

apex_transactional is the reference Ruby client for the Transactional Email API. It targets Ruby 3.0+ and is stdlib-only — no httparty, faraday, or excon dependency.

It handles:

  • Auto-attached Idempotency-Key so transparent retries can't double-send.
  • Exponential backoff with jitter for transient failures (network, 5xx, 429).
  • Typed exceptions mirroring the public error contract.
  • A sandbox mode flag plus an ApexTransactional.sandbox_recipient(:bounce) helper.

Info

This client is shipped as a reference implementation in the Apex monorepo. It is not currently published on RubyGems — the API is small enough that most teams either install it directly from git or vendor the file. If you'd prefer a published gem, reach out and we'll graduate it.

Install

In your Gemfile:

gem "apex_transactional",
    git: "https://github.com/apex-inc/apex.git",
    glob: "packages/apex-transactional-ruby/*.gemspec"

Or vendor lib/apex_transactional/ into your project — it's ~250 lines of stdlib Ruby with no external dependencies.

Quickstart

require "apex_transactional"

apex = ApexTransactional::Client.new(
  base_url: "https://app.apex.inc",
  api_key: ENV.fetch("APEX_TX_KEY"),
)

result = apex.send({
  to: "alex@customer.com",
  subject: "You've been invited",
  html: "<p>Welcome aboard!</p>",
  text: "Welcome aboard!",
  tags: { product_area: "invite" },
})

puts result.id, result.message_id

Send shapes

send accepts two payload shapes — pick whichever fits the use case. The server enforces mutual exclusion (mixing commId with subject/html/text returns 400 validation_error).

Inline shape — when you ship the body in code

Use this for one-off sends where the content lives next to the call site (admin-tools alerts, internal notifications, prototype work).

apex.send({
  to: "alex@customer.com",
  subject: "Receipt #1812",
  html: "<p>Thanks for your order.</p>",
  text: "Thanks for your order.",
  tags: { product_area: "billing" },
})

Reference a workspace TenantCommunication (must have commType == "transactional") by id. The server renders the saved template, applies your variables map as both slot overrides (when keys match slot ids like headline, ctaUrl, subject) and {{key}} mustache substitutions in subject + html + text, and returns the same response shape as inline sends.

The catalog templates ship with {{variable}} defaults — see the API reference for the exact variable names each one expects.

apex.send(
  {
    to: user.email,
    commId: "invite",
    variables: {
      inviterName: actor.name,
      orgName: org.name,
      role: "member",
      # Always pass URLs as variables. Email clients can't resolve
      # relative paths, and our catalog templates use mustache
      # placeholders for that reason.
      acceptUrl: "#{ENV.fetch('APP_URL')}/invite/accept?token=#{invitation.token}",
    },
  },
  idempotency_key: "invite-#{invitation.token}",
)

The template shape gives you:

  • Editable copy — operators tweak subject + body in the composer; the next send picks it up immediately. No deploy.
  • Brand consistency — workspace brand panel (logo, colors, support email, address) auto-applies.
  • Unsubscribe-suppression — transactional comms don't render an Unsubscribe link, regardless of which slots the operator edits.

Configuration

ParameterTypeDescription
base_url:requiredStringApex API origin (no trailing /api/v1/...)
api_key:requiredStringWorkspace transactional secret. Must start with apex_tx_
sandbox:BooleanDefault sandbox flag for every call (overridable per call)
timeout:IntegerPer-request timeout in seconds. Default 30
max_retries:IntegerTotal attempts on transient failures. Default 3
retry_base_delay_ms:IntegerBase delay for exponential backoff. Default 250
retry_max_delay_ms:IntegerCap for individual retries. Default 5000
transport:ProcInject custom HTTP transport. Signature: ->(url, method, headers, body, timeout) -> [status, headers, raw_body]

Idempotency

The client always sends an Idempotency-Key. If you don't supply one it generates a per-call key via SecureRandom.uuid and reuses the same key across retry attempts, so the server replays a cached response instead of double-sending.

apex.send(
  { to: "alex@customer.com", subject: "Receipt #1812", text: "..." },
  idempotency_key: "receipt-1812",
)

Reusing the same key with a different payload raises ApexTransactional::IdempotencyMismatchError (the API responds 409 idempotency_key_mismatch).

Sandbox

require "apex_transactional"

sandbox = ApexTransactional::Client.new(
  base_url: "https://app.apex.inc",
  api_key: ENV.fetch("APEX_TX_KEY"),
  sandbox: true,
)

sandbox.send({
  to: ApexTransactional.sandbox_recipient(:success),
  subject: "Smoke test",
  text: "Resolves to status=sent, mode=sandbox",
})

begin
  sandbox.send({
    to: ApexTransactional.sandbox_recipient(:bounce),
    subject: "Bounce simulation",
    text: "ignored",
  })
rescue ApexTransactional::SendFailedError => e
  raise unless e.code == "bounce"
end

Error handling

begin
  apex.send(payload)
rescue ApexTransactional::AuthError
  rotate_keys
rescue ApexTransactional::RateLimitError
  enqueue_for_later
rescue ApexTransactional::IdempotencyMismatchError
  regenerate_key_and_retry
rescue ApexTransactional::SendFailedError => e
  log_outcome(e.code) # bounce | suppressed | send_failed
rescue ApexTransactional::ValidationError
  fix_payload
rescue ApexTransactional::TimeoutError
  timeout_path
end

Every exception exposes #code, #status, #request_id, and the raw decoded #body.

Retry policy

FailureRetried?
Network error / TCP / DNSYes
TimeoutNo (single attempt — budget == timeout)
429 rate-limitedYes
5xx server errorYes
409 idempotency_in_flightYes
400 / 401 / 409 mismatch / 422No

See also