pex

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-Key so 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" },
});

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

ParameterTypeDescription
baseUrlrequiredstringApex API origin (no trailing /api/v1/...)
apiKeyrequiredstringWorkspace transactional secret. Must start with apex_tx_
sandboxbooleanDefault sandbox flag for every call (overridable per call)
timeoutMsnumberPer-request timeout. Default 30000
maxRetriesnumberTotal attempts on transient failures (network, 5xx, 429). Default 3
retryBaseDelayMsnumberBase delay for exponential backoff with full jitter. Default 250
retryMaxDelayMsnumberCap for individual retry delays. Default 5000
fetchFntypeof fetchInject custom fetch (tests, edge proxies)
userAgentstringOverride 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

FailureRetried?
Network error / TCP resetYes
Timeout (AbortError)No (single attempt — the budget is the timeout itself)
429 rate-limitedYes
5xx server errorYes
409 idempotency_in_flightYes (concurrent retry — server resolves once finalized)
400 / 401 / 409 mismatch / 422No (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.

See also