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-Keyso 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" },
})
Template shape — when content lives in the dashboard (recommended)
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
| Parameter | Type | Description |
|---|---|---|
base_url:required | String | Apex API origin (no trailing /api/v1/...) |
api_key:required | String | Workspace transactional secret. Must start with apex_tx_ |
sandbox: | Boolean | Default sandbox flag for every call (overridable per call) |
timeout: | Integer | Per-request timeout in seconds. Default 30 |
max_retries: | Integer | Total attempts on transient failures. Default 3 |
retry_base_delay_ms: | Integer | Base delay for exponential backoff. Default 250 |
retry_max_delay_ms: | Integer | Cap for individual retries. Default 5000 |
transport: | Proc | Inject 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
| Failure | Retried? |
|---|---|
| Network error / TCP / DNS | Yes |
| Timeout | No (single attempt — budget == timeout) |
429 rate-limited | Yes |
5xx server error | Yes |
409 idempotency_in_flight | Yes |
400 / 401 / 409 mismatch / 422 | No |