Transactional email API
Send receipts, password resets, and notifications on an isolated transactional stream — no campaign, no list membership required. One POST queues a message; delivery, opens, clicks, and bounces are tracked on the message itself, separate from your marketing stats. Requires the sends scope.
How the stream works
Transactional messages live in their own log with a simple lifecycle: queued → sent → delivered, with bounced, complained, and failed as terminal problem states. Three properties define the stream:
- Queue-and-return.
POST /transactional/sendvalidates, snapshots your HTML (or the referenced template's HTML) onto the message row, and returns201immediately withstatus: "queued". The actual vendor send happens in a background job — a template deleted a second after you queue cannot break the send. - Isolated engagement. Opens and clicks are counted on the message row (visible in the API detail and the in-app log), never mixed into campaign or automation reports.
- Isolated policy. Recipients with a hard-bounce or spam-complaint history are refused synchronously; marketing unsubscribes do not block transactional mail by default. The exact rules are on the transactional suppression policy page.
Endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /transactional/send | Queue one message: to, subject, from_email (required), exactly one of template_uid or html, plus optional merge_data (≤16 KB JSON), from_name, reply_to, track (default true), include_unsubscribe (default false). Rate class api.sends. |
| GET | /transactional | Paginated log, newest first. Filters: status, to, date_from/date_to (Y-m-d), per_page (≤100). |
| GET | /transactional/{uid} | One message with merge_data and the full events timeline (queued, sent, delivered, opened, clicked, bounce/complaint entries; the newest 50 are kept). |
Sending a message
# Inline HTML with merge_data (fallbacks after | render when a key is absent)
curl -X POST https://emailflow.ai/api/v1/transactional/send \
-H "Authorization: Bearer efa_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: receipt-ord_42" \
-d '{
"to": "alice@example.com",
"subject": "Your receipt for order {{ order_id }}",
"html": "Hi {{ name | there }}, thanks for order {{ order_id }}.
",
"merge_data": { "name": "Alice", "order_id": "ord_42" },
"from_email": "billing@yourdomain.com",
"from_name": "Acme Billing",
"reply_to": "support@yourdomain.com"
}'
# Response (201)
{ "data": { "message_uid": "64a91b2c7de31", "status": "queued" } }
Content. Provide either inline html (≤500 KB) or a template_uid from your template library — never both. Merge tags use the same fallback grammar as campaigns: {{ key }} renders from merge_data, {{ key | fallback }} renders the fallback when the key is missing.
From address. from_email must be one of your verified senders or any address at a verified sending domain — unverified addresses are refused with a 422 at queue time rather than a vendor rejection at send time.
Retry safety. Always pass an Idempotency-Key: a network-level retry then replays the original response instead of queuing a duplicate email.
Synchronous refusals
Everything that can be checked up front is refused at queue time with the standard error envelope, so your application learns immediately:
| Status | code | When |
|---|---|---|
409 | CONFLICT | The recipient is suppressed. details.reason is "suppressed" and details.suppression carries the matrix token (blacklisted, spam-reported, transactional-feedback, or unsubscribed) — see the policy page. |
402 | CREDITS_EXHAUSTED | Your monthly send credits are used up (transactional draws from the same wallet as marketing; the usage endpoint reports the per-stream split). |
422 | VALIDATION_ERROR | Invalid body — including from_email outside your verified identities, both/neither of template_uid/html, or merge_data over 16 KB. |
404 | NOT_FOUND | template_uid that does not exist in your account. |
Tracking, headers & the footer
- Open/click tracking is on by default (
track: true): links are rewritten through your tracking host and an invisible open pixel is appended. Settrack: falsefor a byte-exact body — no pixel, no rewritten links. - No unsubscribe footer or List-Unsubscribe header by default — a password reset is not a mailing-list message. Set
include_unsubscribe: trueto add one: recipients who are contacts in your account get the full RFC 8058 one-click pair (the native Gmail/Apple unsubscribe button); unknown addresses get amailto:header pointing at your reply-to. - No promotional watermark, on any plan — the Free-plan footer that marketing sends carry is never injected into transactional mail.
Delivery feedback & webhooks
Transactional sends go out through a dedicated Amazon SES configuration set, so deliveries, bounces, and complaints route back to the exact message row — the log updates itself within seconds of the mailbox provider's verdict. Each transition appends a timeline event and emits the corresponding webhook (email.delivered, email.bounced, email.complained) with stream: "transactional" and message_uid in the payload, so one receiver can tell the two streams apart. Messages are retained for 13 months, then purged automatically.
Using the SDK
The TypeScript SDK wraps the stream as emailflow.transactional, with the same idempotent-retry semantics:
const { message_uid } = await emailflow.transactional.send(
{
to: 'alice@example.com',
subject: 'Reset your password',
html: resetEmailHtml,
from_email: 'security@yourdomain.com',
track: false,
},
{ idempotencyKey: `pw-reset-${userId}-${tokenId}` },
);
const message = await emailflow.transactional.retrieve(message_uid);
console.log(message.status, message.events);
A full receipt-sending recipe (send, poll the outcome, react to a suppression refusal) ships in the SDK cookbook.