Transactional SDK · TypeScript
@apex-inc/transactional is the official client for the Transactional Email API. It works on Node 18+ and modern runtimes (Edge, Deno, Bun, browsers).
It handles the boring parts you don't want to re-implement:
- Auto-attached
Idempotency-Keyso transparent retries can't cause duplicate sends. - Exponential backoff with jitter for transient failures (network errors, 5xx, 429).
- Typed errors that mirror the public error contract.
- A sandbox mode toggle plus a
sandboxRecipient()helper that builds canonical test addresses.
Install
npm install @apex-inc/transactional
Info
Get an apex_tx_* secret from Settings → Workspace → Transactional API. Treat it like a server-side secret — never ship it in browser code.
Quickstart
import { ApexTransactionalClient } from "@apex-inc/transactional";
const apex = new ApexTransactionalClient({
baseUrl: "https://app.apex.inc",
apiKey: process.env.APEX_TX_KEY!,
});
const result = await apex.send({
to: "alex@customer.com",
subject: "You've been invited",
html: "<p>Welcome aboard!</p>",
text: "Welcome aboard!",
tags: { product_area: "invite" },
});
console.log(result.id, result.messageId);
// comm_1730150400123_ab12cd 0100019abcdef
Send shapes
send() accepts two payload shapes — pick whichever fits the use case. The SDK type system enforces mutual exclusion at compile time (TypeScript will flag mixing them).
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).
await 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.
await 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: `${process.env.APP_URL}/invite/accept?token=${invitation.token}`,
},
}, {
idempotencyKey: `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 |
|---|---|---|
baseUrlrequired | string | Apex API origin (no trailing /api/v1/...) |
apiKeyrequired | string | Workspace transactional secret. Must start with apex_tx_ |
sandbox | boolean | Default sandbox flag for every call (overridable per call) |
timeoutMs | number | Per-request timeout. Default 30000 |
maxRetries | number | Total attempts on transient failures (network, 5xx, 429). Default 3 |
retryBaseDelayMs | number | Base delay for exponential backoff with full jitter. Default 250 |
retryMaxDelayMs | number | Cap for individual retry delays. Default 5000 |
fetchFn | typeof fetch | Inject custom fetch (tests, edge proxies) |
userAgent | string | Override the User-Agent header |
Idempotency
The client always sends an Idempotency-Key header. Caller-supplied keys win; otherwise the SDK generates one per call and reuses the same key across retry attempts, so the server's idempotency slot replays a cached response instead of double-sending.
await apex.send(
{ to: "alex@customer.com", subject: "Receipt #1812", text: "..." },
{ idempotencyKey: "receipt-1812" },
);
Reusing the same key with a different payload raises ApexIdempotencyMismatchError (the API responds 409 idempotency_key_mismatch).
Sandbox
import { ApexTransactionalClient, sandboxRecipient } from "@apex-inc/transactional";
const sandbox = new ApexTransactionalClient({
baseUrl: "https://app.apex.inc",
apiKey: process.env.APEX_TX_KEY!,
sandbox: true,
});
await sandbox.send({
to: sandboxRecipient("success"),
subject: "Smoke test",
text: "Will resolve to status: sent with mode: sandbox",
});
await sandbox.send({
to: sandboxRecipient("bounce"),
subject: "Bounce simulation",
text: "ignored",
}).catch((err) => err); // ApexSendFailedError, code: "bounce"
sandboxRecipient(kind, tag?) builds canonical addresses (ok@sandbox.apex.inc, bounce@sandbox.apex.inc, suppress-<tag>@sandbox.apex.inc).
Error handling
import {
ApexAuthError,
ApexIdempotencyMismatchError,
ApexRateLimitError,
ApexSendFailedError,
ApexTimeoutError,
ApexValidationError,
} from "@apex-inc/transactional";
try {
await apex.send(payload);
} catch (err) {
if (err instanceof ApexAuthError) {
// 401 — rotate the apex_tx_ key
} else if (err instanceof ApexRateLimitError) {
// 429 — already retried internally; surface to caller / queue
} else if (err instanceof ApexIdempotencyMismatchError) {
// 409 — same key reused with a different body. Generate a new key.
} else if (err instanceof ApexSendFailedError) {
// 422 — err.code is "bounce" | "suppressed" | "send_failed"
} else if (err instanceof ApexValidationError) {
// 400 — bad payload
} else if (err instanceof ApexTimeoutError) {
// request budget exceeded
} else {
throw err;
}
}
Every error exposes status, code, requestId, and the raw decoded body.
Retry policy
| Failure | Retried? |
|---|---|
| Network error / TCP reset | Yes |
Timeout (AbortError) | No (single attempt — the budget is the timeout itself) |
429 rate-limited | Yes |
5xx server error | Yes |
409 idempotency_in_flight | Yes (concurrent retry — server resolves once finalized) |
400 / 401 / 409 mismatch / 422 | No (these are deterministic; retrying won't help) |
The same Idempotency-Key is reused across retries, so the server replays a cached response instead of double-billing the send.
Browser usage
The SDK works in browsers, but you should never ship an apex_tx_* key in client code. Stick to using the SDK from server endpoints / edge functions / mobile servers.