# 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

```json
{
  "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](#retries)). The response is
`202` with one message per channel sent:

```json
{
  "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`](/docs/api/quickstart#3-add-a-contact) does, and the message can
use them. Consent is never changed by a send.

## Email

Give the content inline:

```json
"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`:

```json
"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

```json
"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:`:

```json
"attachments": [{ "filename": "logo.png", "content": "<base64>", "contentId": "logo" }]
```

```html
<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:

```json
"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:

```json
"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

```json
"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](/docs/api/idempotency).

## Many at once

`POST /v1/transactional/messages/batch` takes up to 100 sends, each with its own key:

```json
{
  "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](/docs/api/rate-limits) 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:

```json
{
  "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](/docs/api/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.