Transactional Email API
Programmatic transactional sends for receipts, invites, resets, and ops mail — authenticated with workspace-scoped apex_tx_* keys (managed under Settings → Workspace → Transactional API).
Adaptive Journeys remain marketing-only; this endpoint is the escape hatch for regulated transactional mail.
Scope: what Apex handles vs. what your app handles
Apex is the rendering and delivery layer for transactional emails — we don't try to be the application layer for things like multi-tenant invitations or password-reset state machines. The split keeps each side in its lane:
| Responsibility | Apex provides | Your app provides |
|---|---|---|
| Email content (subject, body, brand) | Editable templates in the composer + {{variable}} substitution at send time | Variable values resolved against your business state |
| Delivery (SES, idempotency, retries, sandbox, deliverability) | Yes | — |
| Tokens (invite tokens, reset tokens, magic-link tokens) | — | Generation, persistence, expiration, single-use enforcement |
Action handlers (/invite/accept, /auth/reset) | — | The route a recipient lands on after clicking |
| Identity / membership state | — | Who an invite belongs to, what org/role they get on accept |
| Audit + analytics for transactional sends | Send log, delivery timeline, idempotency replay | — |
The flow for an "invite" template, end-to-end:
- Your app generates an
invitationrecord with a unique token, stores it server-side, and decides what permissions it confers when accepted. - Your app calls Apex with
commId: "invite"and passesacceptUrl: "${APP_URL}/invite/accept?token=${token}"in thevariablesmap. - Apex renders the workspace's
invitetemplate (subject + body + brand), substitutes the variables, sends through SES, returns a send id. - Recipient clicks the button in the email → lands on your
/invite/accept?token=…route. - Your app validates the token, looks up the invitation, materializes the membership (or rejects on expired / used / forged tokens), and redirects.
If you need a hosted /accept flow, that's not what this API is for — but it's a small handler in any web framework, and we keep the rendering side of the equation editable in the dashboard so marketing / ops can tweak copy without redeploying your app.
Send email
/api/v1/transactional/sendSend one transactional email via SES
Authenticate with Authorization: Bearer apex_tx_… or X-Api-Key: apex_tx_….
Headers
| Parameter | Type | Description |
|---|---|---|
Authorization | string | `Bearer apex_tx_<secret>` — preferred for server SDKs |
X-Api-Key | string | Alternative to Bearer — same `apex_tx_` secret |
Idempotency-Key | string | Dedupes retries within ~24h per workspace + key (stores finalized JSON response) |
X-Apex-Mode | string | `sandbox` forces simulated outcomes without SES (still writes COMM rows tagged sandbox) |
Body
The endpoint accepts two mutually exclusive shapes: provide either the inline fields (subject + html/text) or a commId reference, not both.
Inline shape
Caller supplies the email body verbatim. Available since the API launched.
| Parameter | Type | Description |
|---|---|---|
torequired | string | Recipient email address |
subjectrequired | string | Subject line (UTF-8) — only valid in the inline shape |
html | string | HTML body — instrumented like other Apex sends for opens/clicks |
text | string | Plaintext fallback — required alongside html unless html alone provided |
tags | object | String-key metadata echoed into COMM record (`txn_tags` JSON blob) |
Template shape (Wave 44)
Reference a workspace TenantCommunication (must have commType === "transactional") by id and pass a flat variables map. The server resolves the template, applies your variables as slot overrides (when keys match named slots like headline, ctaUrl, body) and as {{key}} substitutions in subject + html + text, and renders the result through the same pipeline as Adaptive Journey sends. Unknown placeholders are left as-is so test sends surface bugs instead of silently dropping them.
HTML output escapes special characters in substituted values; the subject and text outputs are not escaped (so curly quotes etc. render correctly).
Always pass URLs as variables. Email clients can't resolve relative paths —
/dashboardbecomeshttp://dashboardand fails. Build the absolute URL in your code (${process.env.APP_URL}/invite/accept?token=…) and pass it as a variable; the template references it as{{acceptUrl}}etc. The catalog templates ship with{{variable}}defaults out of the box for this reason.
Catalog template variable reference
When activating a built-in catalog template, here's what its slots expect. The variables your code passes must match these names (or the template falls back to its {{variable}} placeholder rendering as literal text in the email).
| Parameter | Type | Description |
|---|---|---|
welcome / onboarding-reminder | {{onboardingUrl}}, {{name}} | Sent on signup / signup-completion-reminder. `onboardingUrl` is the resolved absolute URL to the onboarding flow. |
invite | {{acceptUrl}}, {{inviterName}}, {{orgName}}, {{role}} | `acceptUrl` is the absolute URL to your invite-accept handler with the token. |
invite-accepted | {{member_name}}, {{org_name}}, {{role}} | No CTA button (informational). Sent to the inviter when their teammate joins. |
integration-connected / integration-sync-failed | {{integrationsUrl}}, {{integrationName}} | `integrationsUrl` should resolve to your integration management surface. |
integration-sync-complete | {{performanceUrl}}, {{integrationName}}, {{rowCount}}, {{duration}} | `performanceUrl` resolves to the imported-data view. |
password-reset | {{resetUrl}} | Absolute URL to your password-reset handler with the reset token. |
verification-code | {{code}} | Auth-code email (verification / MFA / OTP / password-reset code). Drop a Code block in the composer and call this from your auth provider's webhook or Lambda with the minted code in `variables.code`. See Auth codes section below. |
experiment-started / experiment-completed | {{experimentUrl}}, {{experimentName}}, {{resultLabel?}} | `experimentUrl` resolves to your experiment dashboard. |
badge-earned / level-up | {{growthJourneyUrl}}, {{badgeName?}}, {{level?}}, {{score?}}, {{impactMessage?}} | `growthJourneyUrl` resolves to the engagement / journey page. |
weekly-digest | {{digestUrl}}, {{name}}, {{period}} | `digestUrl` resolves to the dashboard view that pairs with this digest. |
anomaly-detected | {{investigateUrl}}, {{metric}} | Transactional alert. `investigateUrl` deep-links to the affected metric. |
upgrade-nudge | {{upgradeUrl}}, {{currentTier}}, {{nextTier}}, {{name}}, {{featureCount}} | `upgradeUrl` resolves to your pricing/billing surface. |
plan-upgrade / plan-downgrade | {{dashboardUrl}}, {{name}}, {{fromPlan}}, {{toPlan}} | `dashboardUrl` should be a stable post-billing landing page. |
portfolio-access-request / -approved | {{approveUrl}} or {{portfolioUrl}}, {{requesterName}} or {{granterName}} | Use `approveUrl` for the request side, `portfolioUrl` for the approved confirmation. |
You can override any slot at send time by passing a key matching the slot id (e.g. subject, headline, body, ctaLabel, ctaUrl). Anything else flows through {{key}} substitution.
Transactional emails must not be unsubscribable. When
commType === "transactional", the brand-footer block automatically suppresses the Unsubscribe link. This is required by CAN-SPAM § 7704(a)(5)(B), preserves the SES reputation partition between transactional and marketing traffic, and prevents recipients from accidentally opting out of password-reset emails. Don't override this — change the comm's type tomarketinginstead if you actually want unsubscribe behavior.
| Parameter | Type | Description |
|---|---|---|
torequired | string | Recipient email address |
commIdrequired | string | Id of a workspace `TenantCommunication` whose `commType` is `transactional` |
variables | object | Flat string/number/boolean map. Keys matching a slot replace pre-rendered slot content; remaining keys substitute `{{key}}` in subject/html/text. Nested objects are accepted for dotted keys (`{ user: { firstName: 'Alex' } }` satisfies `{{user.firstName}}`). |
tags | object | String-key metadata echoed into COMM record (`txn_tags` JSON blob) |
strict | boolean | When `true`, the endpoint refuses to send if the template references a `{{variable}}` you didn't provide. Returns `400 missing_variables` with the list of missing keys. Recommended for production; default is permissive (missing keys substitute as empty strings). |
Errors specific to the template shape:
| HTTP | Code | Meaning |
|---|---|---|
| 400 | validation_error | commId and inline fields both provided |
| 400 | comm_type_mismatch | Referenced comm has commType other than "transactional" |
| 400 | missing_variables | Strict mode and at least one {{key}} referenced by the template was absent. Response body includes a missing array. |
| 404 | comm_not_found | No comm with that id in this workspace |
Strict mode example
// Request
{
"to": "alex@customer.com",
"commId": "invite",
"variables": { "inviterName": "Chris" },
"strict": true
}
// 400 response
{
"error": {
"code": "missing_variables",
"message": "Strict mode: template \"invite\" requires variables you didn't provide.",
"missing": ["acceptUrl", "orgName", "role"]
}
}
Pair strict: true with the Data dictionary (Settings → Workspace → Data dictionary) so the composer surfaces required variables as you author templates and your code surfaces them as 400s before they reach an inbox.
Auth codes (verification / MFA / OTP / password-reset code) [#auth-codes]
Apex ships a first-class Code block in the composer for auth-code emails. The block renders a styled monospace value (your workspace's primary brand color) with an editable caption beneath ("Verification Code", "Sign-in Code", "Recovery Code" — whatever fits the flow). Use it for any email that delivers a short numeric or alphanumeric code to the recipient — Cognito email verification, Auth0 MFA challenges, password-reset codes, WorkOS magic codes, custom OTP flows.
1. Author the template
- Open the composer for the comm (e.g.
verification-code, which ships pre-wired). - Drop a Code block from the content palette where you want the code to appear (between the intro paragraph and the brand-footer reads well).
- The block defaults to
{{code}}as the value. Rename the placeholder (e.g.{{otp}},{{recovery_code}}) if your code-generation pipeline uses a different name. Edit the caption to fit the use case. - Save. The block participates in the same
{{variable}}substitution as every other text-bearing block — you don't need a special field, hook, or webhook.
2. Call from your auth provider
The code-generation half of the flow stays on your side (your auth provider mints the code). Apex renders and delivers.
3. Validate the code on the user's response
Apex never sees or stores the code value beyond the render-and-deliver pass. Validation stays on your side — your auth provider knows the expected value and the expiration window. Pair the send with whatever short-lived store you already use for OTP state (Cognito's built-in code, Auth0's MFA cache, your own Redis row).
Anti-patterns
- Don't pass the code in
tags. Tags are metadata for the COMM record (analytics, idempotency, audit). Variables are what the renderer substitutes. Putting the code in tags will leak it to the analytics tab and won't substitute. - Don't store the code on the template. The block's
{{code}}placeholder is not the code itself — it's the binding. The actual value comes from the caller every send. - Don't reuse the same
Idempotency-Keyacross attempts. Auth flows often retry on user-typed wrong-code attempts; each attempt is a distinct send and needs a distinct key. Use<flow>-<userId>-<attemptId>. - Don't put the code in a generic Text block. The Code block gives you the monospace + brand-color treatment, owns its own
<table>wrapper for email-client compatibility, and signals intent to anyone editing the template later.
Sandbox helpers
Combine X-Apex-Mode: sandbox or send to anything@sandbox.apex.inc:
- Local-part contains
bounce→ deterministic simulated bounce (422). - Contains
suppress→ suppression outcome (422). - Otherwise → synthetic SES success (
message_idprefixedsbx_).
Rate limits
Per-key throughput inherits the workspace organization plan mapping used elsewhere (free → starter → pro → growth → scale). Responses include X-RateLimit-* headers.
Errors
| HTTP | Code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing/invalid apex_tx credential |
| 409 | idempotency_key_mismatch | Re-used Idempotency-Key with different JSON payload |
| 409 | idempotency_in_flight | Parallel duplicate requests racing on same Idempotency-Key |
| 422 | send_failed / bounce / suppressed | SES/suppression issues or sandbox simulations |
| 429 | rate_limited | Tier throughput exceeded |
Comparison notes
| Vendor | Apex transactional advantage |
|---|---|
| Postmark | Same SES bill transparency plus Adaptive Journeys + holdout sibling surfaces |
| SendGrid | Dynamo-backed idempotency slots plus /dashboard/communications/transactional audit trail |
| Resend | Workspace RBAC + Intelligence beliefs share one tenancy |
Official SDKs:
- TypeScript / Node 18+ →
@apex-inc/transactionalon npm (retries, sandbox helpers, typed errors). - Python 3.10+ → reference client in-repo (stdlib-only; install from git or vendor).
- Ruby 3.0+ → reference client in-repo (stdlib-only; install from git or vendor).
Workspace endpoints
| Method | Path | Notes |
|---|---|---|
| GET | /api/transactional-api-keys | Workspace admin — list prefixes |
| POST | /api/transactional-api-keys | Workspace admin — mint key (rawKey returned once) |
| DELETE | /api/transactional-api-keys/:keyId | Workspace admin — revoke |
| GET | /api/transactional/recent | Workspace admin — recent txn_api COMM rows |