Menu

Transactional messages

Receipts, resets and alerts by email and push: templates, fallback, batches, secrets

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.

Email

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.