pex

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:

ResponsibilityApex providesYour app provides
Email content (subject, body, brand)Editable templates in the composer + {{variable}} substitution at send timeVariable 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 stateWho an invite belongs to, what org/role they get on accept
Audit + analytics for transactional sendsSend log, delivery timeline, idempotency replay

The flow for an "invite" template, end-to-end:

  1. Your app generates an invitation record with a unique token, stores it server-side, and decides what permissions it confers when accepted.
  2. Your app calls Apex with commId: "invite" and passes acceptUrl: "${APP_URL}/invite/accept?token=${token}" in the variables map.
  3. Apex renders the workspace's invite template (subject + body + brand), substitutes the variables, sends through SES, returns a send id.
  4. Recipient clicks the button in the email → lands on your /invite/accept?token=… route.
  5. 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

POST/api/v1/transactional/send

Send one transactional email via SES

Authenticate with Authorization: Bearer apex_tx_… or X-Api-Key: apex_tx_….

Headers

ParameterTypeDescription
Authorizationstring`Bearer apex_tx_<secret>` — preferred for server SDKs
X-Api-KeystringAlternative to Bearer — same `apex_tx_` secret
Idempotency-KeystringDedupes retries within ~24h per workspace + key (stores finalized JSON response)
X-Apex-Modestring`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.

ParameterTypeDescription
torequiredstringRecipient email address
subjectrequiredstringSubject line (UTF-8) — only valid in the inline shape
htmlstringHTML body — instrumented like other Apex sends for opens/clicks
textstringPlaintext fallback — required alongside html unless html alone provided
tagsobjectString-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 — /dashboard becomes http://dashboard and 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).

ParameterTypeDescription
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 to marketing instead if you actually want unsubscribe behavior.

ParameterTypeDescription
torequiredstringRecipient email address
commIdrequiredstringId of a workspace `TenantCommunication` whose `commType` is `transactional`
variablesobjectFlat 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}}`).
tagsobjectString-key metadata echoed into COMM record (`txn_tags` JSON blob)
strictbooleanWhen `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:

HTTPCodeMeaning
400validation_errorcommId and inline fields both provided
400comm_type_mismatchReferenced comm has commType other than "transactional"
400missing_variablesStrict mode and at least one {{key}} referenced by the template was absent. Response body includes a missing array.
404comm_not_foundNo 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.

curl -X POST https://your-instance.com/api/v1/transactional/send \ -H "Authorization: Bearer apex_tx_your_secret_here" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: invite-user-92811" \ -d '{ "to": "alex@customer.com", "subject": "You\u0027ve been invited", "html": "<p>Welcome aboard!</p>", "text": "Welcome aboard!", "tags": { "product_area": "invite" } }'

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

  1. Open the composer for the comm (e.g. verification-code, which ships pre-wired).
  2. 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).
  3. 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.
  4. 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.

curl -X POST https://app.apex.inc/api/v1/transactional/send \ -H "Authorization: Bearer apex_tx_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: verify-user-92811" \ -d '{ "to": "alex@customer.com", "commId": "verification-code", "variables": { "code": "482917" }, "tags": { "flow": "signup_verification" } }'

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-Key across 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_id prefixed sbx_).

Rate limits

Per-key throughput inherits the workspace organization plan mapping used elsewhere (free → starter → pro → growth → scale). Responses include X-RateLimit-* headers.

Errors

HTTPCodeMeaning
401unauthorizedMissing/invalid apex_tx credential
409idempotency_key_mismatchRe-used Idempotency-Key with different JSON payload
409idempotency_in_flightParallel duplicate requests racing on same Idempotency-Key
422send_failed / bounce / suppressedSES/suppression issues or sandbox simulations
429rate_limitedTier throughput exceeded

Comparison notes

VendorApex transactional advantage
PostmarkSame SES bill transparency plus Adaptive Journeys + holdout sibling surfaces
SendGridDynamo-backed idempotency slots plus /dashboard/communications/transactional audit trail
ResendWorkspace RBAC + Intelligence beliefs share one tenancy

Official SDKs:

Workspace endpoints

MethodPathNotes
GET/api/transactional-api-keysWorkspace admin — list prefixes
POST/api/transactional-api-keysWorkspace admin — mint key (rawKey returned once)
DELETE/api/transactional-api-keys/:keyIdWorkspace admin — revoke
GET/api/transactional/recentWorkspace admin — recent txn_api COMM rows