Receipts, password resets, sign-in codes, shipping updates: one message to one person,
because of something they did. POST /v1/transactional/messages sends it by email, push or
both. It needs the transactional:send scope.
Transactional mail is the critical tier: it ignores marketing preferences and topic choices, so a receipt arrives even after someone unsubscribed from your newsletter. It still respects suppression (an address that bounced is not mailed again). Do not send marketing through it.
The request
{
"to": { "email": "ada@example.com", "firstName": "Ada" },
"email": { "templateId": "order-receipt", "data": { "orderId": "1001", "total": 42.5 } },
"push": { "pushProjectId": "…", "content": { "title": "Order confirmed", "body": "#1001" } },
"deliver": "all",
"correlationId": "order-1001"
}
Every send needs an Idempotency-Key header (see Retries). The response is
202 with one message per channel sent:
{
"data": {
"messages": [
{
"id": "…",
"channel": "email",
"status": "queued",
"correlationId": "order-1001",
"createdAt": "2026-10-10T09:00:00.000Z",
"sendAt": null
}
]
}
}
Who it goes to
to names one person, in one of three ways:
to |
|
|---|---|
{ "contactId": "…" } |
A contact you know by id. |
{ "externalId": "user_123" } |
Your own id for them; the contact must exist, unless you add profile fields (below). |
{ "email": "ada@example.com" } |
An address. If no contact has it, one is created for transactional mail only (it gets no marketing). |
With externalId or email you may add firstName, lastName and attributes. The
contact is then created or updated with them first, as
POST /v1/contacts/identify does, and the message can
use them. Consent is never changed by a send.
Give the content inline:
"email": {
"subject": "Your order {{ contact.firstName | default: \"\" }}",
"from": "Shop <orders@mail.yourdomain.com>",
"html": "<p>…</p>",
"text": "…",
"previewText": "…",
"replyTo": "help@yourdomain.com"
}
or a stored template, by id or by its alias:
"email": { "templateId": "order-receipt", "data": { "orderId": "1001" } }
A template send renders the template’s published version when the request is
accepted, never its draft, so editing a template cannot change mail in flight; publish to
make a change live. data is readable in it as {{ data.<key> }} (the template editor’s
{{ event.properties.<key> }} names the same value). The template supplies
the subject, preview text, sender and reply-to; any of those you also give in email
override it. Without a subject or a sender from either, the send is refused with
TEMPLATE_ENVELOPE_INCOMPLETE.
The sender’s domain must be verified (INVALID_SENDER_ADDRESS otherwise).
Attachments
"attachments": [{ "filename": "invoice-1001.pdf", "content": "<base64>" }]
Up to 10 files, 10 MB in all once decoded; the request may be up to 15 MB. Send an email
with attachments on its own: /v1/transactional/messages/batch refuses them. The extension
sets the type each is sent as, and only these are accepted: pdf, doc, docx, xls,
xlsx, ppt, pptx, odt, ods, odp, rtf, txt, csv, ics, png, jpg,
jpeg, gif, webp, heic. Archives, HTML and SVG are not, since each can carry
something that runs. Attachments go with email only, are kept just until the email is sent,
and are never readable back.
To show an image inside the body rather than attach it, give it a contentId and refer to
it from the HTML as cid::
"attachments": [{ "filename": "logo.png", "content": "<base64>", "contentId": "logo" }]
<img src="cid:logo" alt="Shop" />
Only an image can be shown inline.
Replying in a conversation
threading sets the In-Reply-To and References headers, so a mail client files the
email in the conversation it continues, as a support reply would be:
"threading": { "inReplyTo": "<a1b2@mail.example.com>", "references": ["<z9y8@mail.example.com>", "<a1b2@mail.example.com>"] }
Without references, it is inReplyTo alone. cc and bcc are not offered: each message
goes to one person, so a bounce or complaint always belongs to the contact it names. Send
a copy as its own message.
Opens and clicks
Transactional email is not tracked unless the send asks:
"tracking": { "opens": true, "clicks": true }
The message then gets openedAt, clickedAt and the message.opened and
message.clicked webhooks. Contacts whose country is in the EEA or the UK are never
tracked: the law there needs their consent first. Leave tracking off for a password reset
or a sign-in code; a link rewritten through a tracker can confuse people who are being
told to check where a link goes.
Sending later
"sendAt": "2026-10-08T09:00:00+01:00" holds the send, email and push alike, until then,
up to 90 days ahead; it goes within a minute of that time. Suppression and push
reachability are checked when the request is accepted (a contact who cannot receive it is
refused then) and again when it goes (one suppressed since is skipped). With
deliver: "first", the channel is picked when the request is accepted. A retry with the
same Idempotency-Key replays even after the time has passed.
POST /v1/transactional/messages/{id}/cancel stops a message before then; it becomes
skipped with cancelled. A send to both channels is two messages: cancel each.
Push, and choosing a channel
"push": { "pushProjectId": "…", "content": { "title": "…", "body": "…", "actionUrl": "https://…" } }
reaches the contact’s devices on every app of that push project. Push needs the contact’s
pushConsent: true: set it with identify or PATCH /v1/contacts/{id} when they opt in.
Allowing notifications on a device does not set it. Give both email and push and
choose with deliver:
deliver |
Sends |
|---|---|
all (default) |
Each channel the contact can receive. |
first |
Push when the contact has a reachable device, otherwise email. |
A channel the contact cannot receive is passed over. Only when none is left does the send
fail, with that channel’s reason (PUSH_DESTINATION_MISSING, CONSENT_MISSING,
SUPPRESSED, …). With one
channel, its reason is the answer.
Secrets and what is kept
What you send is kept for the workspace’s retention period (30 days by default, readable
at GET /v1/transactional/messages/{id}/content), so support can see what a customer
received. "retainContent": false drops a send’s content as soon as it has gone. Mark a
send that carries a secret, such as a reset link or a one-time code, "sensitive": true:
the same, and it wins over retainContent: true.
Retries
Reuse the Idempotency-Key for every retry of the same send: a retry of the same request
answers with the first result and Idempotency-Replay: true, and nothing goes twice. A
key is remembered as long as the message it sent, so it never expires while you could
still retry. See Idempotency.
Many at once
POST /v1/transactional/messages/batch takes up to 100 sends, each with its own key:
{
"sends": [
{ "idempotencyKey": "receipt-1001", "send": { "to": { "externalId": "u1" }, "email": { "templateId": "order-receipt" } } },
{ "idempotencyKey": "receipt-1002", "send": { "to": { "externalId": "u2" }, "email": { "templateId": "order-receipt" } } }
]
}
Each entry is answered on its own, in order, with its messages and replayed, or with
the error a single send would have returned; one refused entry does not stop the rest,
including a send that fails validation (its error is VALIDATION_ERROR with the
issues). Only the batch’s own shape is checked up front: 1–100 entries, each with its own
key.
A batch counts against the rate limit as one request per entry, on the
send budget.
Marketing to a list you choose
A price drop or a back-in-stock notice is marketing, even when each person’s copy differs.
Send it as a campaign: POST /v1/campaigns/{id}/trigger sends a draft to up to 5,000
contacts you list, each with their own data, read as {{ data.<key> }} as here:
{
"data": { "store": "Shoreditch" },
"recipients": [
{ "externalId": "u1", "data": { "item": "Desk lamp", "price": "$45" } },
{ "email": "ada@example.com", "data": { "item": "Chair", "price": "$120" } }
]
}
It then goes as any campaign does: only to contacts with marketing consent, with an
unsubscribe link, counted in the campaign’s report. Recipients that name no contact come
back in notFound. A campaign sends once; duplicate it to send again.
What happened
correlationId is your own reference; it comes back on the message and on every webhook
about it, and GET /v1/transactional/messages?correlationId=… finds the messages that
carried it (templateId narrows to one template the same way). Follow outcomes with
webhooks (message.sent, message.delivered, message.bounced,
message.complained, message.opened, message.clicked, message.failed,
message.skipped) or read GET /v1/transactional/messages/{id}.
A skipped message was not sent and says why in failureReason: cancelled (stopped
with the cancel call, email or push), and for email suppressed, test_address,
contact_not_found, workspace_suspended or workspace_deletion_pending; for push,
push_no_eligible_installations. For a push send, GET /v1/push/messages/{id}/deliveries
lists what happened on each of the contact’s devices.
Test addresses
Email to example.com, example.net, example.org (and their subdomains) or to any
.test, .example, .invalid or .localhost address goes through every check a real
send does, then stops: it is skipped with test_address and never delivered, counts
toward no limit, and affects no sending reputation. Build and test with them freely.
Three local parts at those domains act out an outcome instead, so you can see every webhook your handler must cope with:
| Address | Becomes | Webhooks |
|---|---|---|
delivered@example.com |
delivered |
message.delivered |
bounce@example.com |
bounced |
message.bounced |
complaint@example.com |
complained |
message.complained |
A +tag is allowed (bounce+order-42@example.com). Nothing is delivered, nothing is
suppressed, so the same address works for every test, and none of it counts toward the
workspace’s bounce or complaint rates.