# Send a transactional message

Source: https://kirora.io/docs/api/reference/sendTransactionalMessage

`POST /v1/transactional/messages`

Requires an API key with the `transactional:send` scope. [About keys and scopes](https://kirora.io/docs/api/reference/authentication)

Works with integration and partner keys, not agent keys.

Send `Idempotency-Key`. A retry with the same key and body repeats nothing. Most writes replay the first response; a transactional send returns the message as it stands now. [About idempotency](https://kirora.io/docs/api/reference/idempotency)

Sends a one-off message to one recipient — a receipt, a password reset, a confirmation — bypassing campaigns. It ignores marketing preferences (it is the critical tier) but respects suppression. Agent keys are refused with 403.

**Channels.** Give `email`, `push`, or both. With both, each one the contact can receive is sent. `fallbackOrder: ["email", "push"]` tries them in that order instead: the first the contact can receive goes, and each later one waits as a fallback (`fallbackFor`), sent only when the one before it fails, is skipped or bounces (an email gets 15 minutes to bounce), else skipped with reason `fallback_not_needed`. A fallback is charged only when it goes; with no room left in the plan by then it is skipped with reason `plan_limit_reached`. A channel the contact cannot receive, or the plan has no room for, is passed over and listed in `skippedChannels` with its error code; only when none is left does the send fail, with that channel's error. So without `fallbackOrder` too, a 202 can leave a channel unsent: check `skippedChannels`. The response lists one message per channel stored, and one per `cc` or `bcc` copy. Each copy is its own message with its own bounces, and an address that cannot take one is listed in `skippedCopies` while the send goes on.

**Data.** `data` holds the values the content reads as `{{ data.<key> }}`, once for every channel: a stored template, inline email, and the push `title` and `body`. Inline content (email or push) whose `{{ data.<key> }}` tag has no value in `data`, and no default, is refused with 400 rather than sent blank.

**Email.** Inline (`subject`, `from`, `html`, optional `text`, `previewText`, `replyTo`), or a stored template named by `template` (its alias, such as `password-reset`, or its id). The template's published version is rendered when the request is accepted and supplies the envelope; each envelope field given overrides it. The result needs a `subject` and a `from` (409 `TEMPLATE_ENVELOPE_INCOMPLETE`), and the `from` domain must be verified. A template never published is 409 `TEMPLATE_NOT_PUBLISHED`. Email is tracked only when `tracking` asks, and never for a contact in the EEA or the UK; `threading` continues a conversation. `attachments`, `threading` and `tracking` sit inside `email`.

**Push.** `push` takes the notification fields (`title`, `body`, `actionUrl`, …) beside `pushProjectId`, which is needed only when the workspace has several push projects. Unlike email, push needs the contact's push consent (`consent.push`) besides a device that allows notifications.

**Recipient.** `to` names one `contactId`, `externalId` or `email`, and nothing else: set a contact's name or attributes with `POST /v1/contacts/identify` first. An `email` that matches no contact creates one for transactional mail only.

**Retries.** Send an `Idempotency-Key` header to make a retry safe. A retry with the same key and request returns the first result, with `Idempotency-Replay: true`, and sends nothing again; the same key with a different request is 409 `IDEMPOTENCY_KEY_REUSED`. Without a key, every request sends: a retry after a timeout can send the message twice. A replay answers from the messages as they are now, so its `status` can differ from the first answer, and an `externalId` or `email` recipient the first request created or updated stays so even when the send itself was refused. Set `correlationId` to your own reference: it is echoed on each message's webhooks and its read, and `GET /v1/messages?correlationId=` finds them. `sendAt` times the send, email and push alike.

## Header parameters

- `Idempotency-Key` (string, optional, 8–255 characters) — Makes a retry safe for as long as what the request created exists: a repeat with the same key and body gets the first response again, with `Idempotency-Replay: true`. The same key with another request is 409 `IDEMPOTENCY_KEY_REUSED`. Use a new key, such as a UUID, for each new request.
- `Kirora-Workspace` (string, optional, pattern `^[A-Za-z0-9._:-]{1,128}$`) — Partner keys only: the child workspace to act in, by the external id the partner gave it. Without it, a partner key acts in its own workspace. Any other key that sends it is refused with 403, and an unknown workspace is 404.

## Request body

- `to` (object, required) — Who receives it.
  - Any of: **By contact id**
    - `contactId` (string, required, uuid) — An existing contact.
  - Any of: **By external id**
    - `externalId` (string, required, 1–255 characters) — Your own id for an existing contact, as set with identify.
  - Any of: **By email**
    - `email` (string, required, email, up to 320 characters) — The recipient's address. An email send to an address no contact has creates a transactional-only contact; a push-only send needs an existing contact.
- `data` (map<string, any>, optional, keys: pattern `^[A-Za-z0-9_-]{1,64}$`) — Values the content reads as `{{ data.<key> }}`: a stored template, inline email and the push `title` and `body` alike.
- `sensitive` (boolean, optional) — Set when the message carries a secret (a reset link, a one-time code): its content is kept only while the message is queued or sending, and removed once sending finishes, whatever the workspace's retention says.
- `correlationId` (string, optional, 1–255 characters) — Your own reference for this send, echoed on its webhooks and its read.
- `email` (object, optional) — Inline (`subject`, `from`, `html`), or a stored template by `template`; either may carry `attachments`, `threading`, `tracking`, `cc` and `bcc`.
  - Any of: **Stored template**
    - `template` (string, required) — The template's alias, such as `password-reset`, or its id.
    - `subject` (string, optional, 1–998 characters) — The subject line. Merge tags are allowed.
    - `previewText` (string, optional, 1–255 characters) — Shown by inboxes after the subject. Merge tags are allowed; keep it near 100 characters.
    - `from` (string, optional, 1–320 characters) — The sender address, on a verified sending domain.
    - `replyTo` (string, optional, email) — Where replies go; the part before the @ must be ASCII.
    - `attachments` (EmailAttachmentInput[], optional, at least 1 item, at most 10 items) — Files it carries: up to 10, 10 MB in all.
      - `filename` (string, required, 1–255 characters) — Its extension sets the type it is sent as.
      - `content` (string, required, base64) — The file, base64-encoded.
      - `contentId` (string, optional, pattern `^[A-Za-z0-9._-]{1,100}$`) — Shows an image inside the body instead of attaching it: the HTML refers to it as `<img src="cid:<contentId>">`.
    - `threading` (EmailThreadingInput, optional) — Places it in an existing conversation, as the `In-Reply-To` and `References` headers.
      - `inReplyTo` (string, required, pattern `^<[^<>\s@]+@[^<>\s@]+>$`) — The `Message-ID` of the email being replied to, angle brackets included.
      - `references` (string[], optional, at least 1 item, at most 20 items) — The `Message-ID`s earlier in the conversation, oldest first (up to 20).
    - `tracking` (TransactionalEmailTrackingInput, optional) — Open and click tracking; off unless asked for. Contacts whose `country` is in the EEA or the UK are never tracked.
      - `opens` (boolean, optional, default `false`) — Track opens with a pixel.
      - `clicks` (boolean, optional, default `false`) — Rewrite links to track clicks.
    - `cc` (string[], optional, at least 1 item, at most 14 items) — Addresses that get a copy and are shown in its `Cc` line. Each goes as its own message, with its own bounces and complaints, and counts as one email. Up to 14 across `cc` and `bcc`.
    - `bcc` (string[], optional, at least 1 item, at most 14 items) — Addresses that get a copy without being shown to anyone. Sent and counted as `cc` is.
  - Any of: **Inline content**
    - `subject` (string, required, 1–998 characters) — The subject line. Merge tags are allowed.
    - `previewText` (string, optional, 1–255 characters) — Shown by inboxes after the subject. Merge tags are allowed; keep it near 100 characters.
    - `html` (string, required, 1–512,000 characters) — The HTML body. Rendered from the design when one is set.
    - `text` (string, optional, 1–512,000 characters) — The plain-text alternative.
    - `from` (string, required, 1–320 characters) — The sender address, on a verified sending domain.
    - `replyTo` (string, optional, email) — Where replies go; the part before the @ must be ASCII.
    - `attachments` (EmailAttachmentInput[], optional, at least 1 item, at most 10 items) — Files it carries: up to 10, 10 MB in all.
      - `filename` (string, required, 1–255 characters) — Its extension sets the type it is sent as.
      - `content` (string, required, base64) — The file, base64-encoded.
      - `contentId` (string, optional, pattern `^[A-Za-z0-9._-]{1,100}$`) — Shows an image inside the body instead of attaching it: the HTML refers to it as `<img src="cid:<contentId>">`.
    - `threading` (EmailThreadingInput, optional) — Places it in an existing conversation, as the `In-Reply-To` and `References` headers.
      - `inReplyTo` (string, required, pattern `^<[^<>\s@]+@[^<>\s@]+>$`) — The `Message-ID` of the email being replied to, angle brackets included.
      - `references` (string[], optional, at least 1 item, at most 20 items) — The `Message-ID`s earlier in the conversation, oldest first (up to 20).
    - `tracking` (TransactionalEmailTrackingInput, optional) — Open and click tracking; off unless asked for. Contacts whose `country` is in the EEA or the UK are never tracked.
      - `opens` (boolean, optional, default `false`) — Track opens with a pixel.
      - `clicks` (boolean, optional, default `false`) — Rewrite links to track clicks.
    - `cc` (string[], optional, at least 1 item, at most 14 items) — Addresses that get a copy and are shown in its `Cc` line. Each goes as its own message, with its own bounces and complaints, and counts as one email. Up to 14 across `cc` and `bcc`.
    - `bcc` (string[], optional, at least 1 item, at most 14 items) — Addresses that get a copy without being shown to anyone. Sent and counted as `cc` is.
    - `mergeTags` (boolean, optional) — Set to `false` for HTML that is already finished: `{{` then goes out exactly as written, and nothing is checked or filled in. `{{unsubscribe_url}}` and `{{web_version_url}}` still work. Defaults to `true`.
- `push` (TransactionalPushInput, optional) — A push notification to the contact's devices. Give `push`, `email`, or both.
  - `title` (string, required, 1–100 characters) — The notification title (up to 100 characters).
  - `body` (string, required, 1–500 characters) — The notification text (up to 500 characters).
  - `actionUrl` (string, optional, uri, up to 2,048 characters) — The HTTPS page opened when the notification is tapped.
  - `imageUrl` (string, optional, uri, up to 2,048 characters) — A large picture: web and Android show it; iOS needs the app's service extension.
  - `iconUrl` (string, optional, uri, up to 2,048 characters) — Web only; iOS and Android show the app's own notification icon.
  - `data` (map<string, string>, optional, at most 10 keys, keys: pattern `^[A-Za-z0-9_.-]{1,64}$`, values: up to 1,024 characters) — Up to 10 string key-value pairs passed to the app untouched, for deep links and in-app routing.
  - `buttons` (object[], optional, at least 1 item, at most 2 items) — Up to 2 action buttons.
    - `title` (string, required, 1–30 characters) — The button label.
    - `url` (string, required, uri, up to 2,048 characters) — The HTTPS page the button opens.
  - `collapseKey` (string, optional, pattern `^[A-Za-z0-9_-]{1,64}$`) — A newer notification with the same key replaces the older one on the device.
  - `sound` (string, optional, 1–64 characters) — The name of a sound the app ships; the device's default if unset.
  - `badge` (integer, optional, min 0, max 99999) — The number shown on the iOS app icon.
  - `ttl` (DurationOrZeroInput, optional) — How long a provider keeps trying an offline device, up to 28 days; zero delivers now or never. Defaults to one day.
    - `value` (integer, required, min 0)
    - `unit` (string, required, one of `seconds`, `minutes`, `hours`, `days`)
  - `urgency` (string, optional, one of `high`, `normal`) — `high` asks the push service to deliver promptly, even to a sleeping device; `normal` lets it wait for a good moment to save battery. Device settings and the app still decide whether and how it is shown. Unset, a transactional push is `high` and any other `normal`.
  - `overrides` (object, optional) — Fields that differ on one platform, each replacing the shared one there (a whole `data` map, not merged key by key). On iOS and Android `actionUrl` may be an app link such as `myapp://orders`; the shared one and the web one must be HTTPS.
    - `ios` (object, optional) — Replaces shared fields on iOS.
      - `title` (string, optional, 1–100 characters) — The notification title (up to 100 characters).
      - `body` (string, optional, 1–500 characters) — The notification text (up to 500 characters).
      - `actionUrl` (string, optional, pattern `^[a-z][a-z0-9+.-]*:\S+$`, up to 2,048 characters)
      - `imageUrl` (string, optional, uri, up to 2,048 characters) — A large picture: web and Android show it; iOS needs the app's service extension.
      - `iconUrl` (string, optional, uri, up to 2,048 characters) — Web only; iOS and Android show the app's own notification icon.
      - `data` (map<string, string>, optional, at most 10 keys, keys: pattern `^[A-Za-z0-9_.-]{1,64}$`, values: up to 1,024 characters) — Up to 10 string key-value pairs passed to the app untouched, for deep links and in-app routing.
      - `buttons` (object[], optional, at least 1 item, at most 2 items) — Up to 2 action buttons.
        - `title` (string, required, 1–30 characters) — The button label.
        - `url` (string, required, uri, up to 2,048 characters) — The HTTPS page the button opens.
      - `collapseKey` (string, optional, pattern `^[A-Za-z0-9_-]{1,64}$`) — A newer notification with the same key replaces the older one on the device.
      - `sound` (string, optional, 1–64 characters) — The name of a sound the app ships; the device's default if unset.
      - `badge` (integer, optional, min 0, max 99999) — The number shown on the iOS app icon.
      - `ttl` (DurationOrZeroInput, optional) — How long a provider keeps trying an offline device, up to 28 days; zero delivers now or never. Defaults to one day.
        - `value` (integer, required, min 0)
        - `unit` (string, required, one of `seconds`, `minutes`, `hours`, `days`)
      - `urgency` (string, optional, one of `high`, `normal`) — `high` asks the push service to deliver promptly, even to a sleeping device; `normal` lets it wait for a good moment to save battery. Device settings and the app still decide whether and how it is shown. Unset, a transactional push is `high` and any other `normal`.
      - `interruptionLevel` (string, optional, one of `passive`, `active`, `time-sensitive`) — How iOS presents it: `passive` arrives silently, `time-sensitive` can break through Focus when the user allows it, and needs the app's Time Sensitive Notifications capability.
      - `threadId` (string, optional, pattern `^[A-Za-z0-9_.-]{1,64}$`) — Notifications with the same thread are grouped together in Notification Center.
    - `android` (object, optional) — Replaces shared fields on Android.
      - `title` (string, optional, 1–100 characters) — The notification title (up to 100 characters).
      - `body` (string, optional, 1–500 characters) — The notification text (up to 500 characters).
      - `actionUrl` (string, optional, pattern `^[a-z][a-z0-9+.-]*:\S+$`, up to 2,048 characters)
      - `imageUrl` (string, optional, uri, up to 2,048 characters) — A large picture: web and Android show it; iOS needs the app's service extension.
      - `iconUrl` (string, optional, uri, up to 2,048 characters) — Web only; iOS and Android show the app's own notification icon.
      - `data` (map<string, string>, optional, at most 10 keys, keys: pattern `^[A-Za-z0-9_.-]{1,64}$`, values: up to 1,024 characters) — Up to 10 string key-value pairs passed to the app untouched, for deep links and in-app routing.
      - `buttons` (object[], optional, at least 1 item, at most 2 items) — Up to 2 action buttons.
        - `title` (string, required, 1–30 characters) — The button label.
        - `url` (string, required, uri, up to 2,048 characters) — The HTTPS page the button opens.
      - `collapseKey` (string, optional, pattern `^[A-Za-z0-9_-]{1,64}$`) — A newer notification with the same key replaces the older one on the device.
      - `sound` (string, optional, 1–64 characters) — The name of a sound the app ships; the device's default if unset.
      - `badge` (integer, optional, min 0, max 99999) — The number shown on the iOS app icon.
      - `ttl` (DurationOrZeroInput, optional) — How long a provider keeps trying an offline device, up to 28 days; zero delivers now or never. Defaults to one day.
        - `value` (integer, required, min 0)
        - `unit` (string, required, one of `seconds`, `minutes`, `hours`, `days`)
      - `urgency` (string, optional, one of `high`, `normal`) — `high` asks the push service to deliver promptly, even to a sleeping device; `normal` lets it wait for a good moment to save battery. Device settings and the app still decide whether and how it is shown. Unset, a transactional push is `high` and any other `normal`.
      - `channelId` (string, optional, pattern `^[A-Za-z0-9_.-]{1,64}$`) — The Android notification channel to post in, which the app creates; it sets the sound and importance a person can tune, e.g. one for offers and one for orders.
    - `web` (object, optional) — Replaces shared fields on web.
      - `title` (string, optional, 1–100 characters) — The notification title (up to 100 characters).
      - `body` (string, optional, 1–500 characters) — The notification text (up to 500 characters).
      - `actionUrl` (string, optional, uri, up to 2,048 characters) — The HTTPS page opened when the notification is tapped.
      - `imageUrl` (string, optional, uri, up to 2,048 characters) — A large picture: web and Android show it; iOS needs the app's service extension.
      - `iconUrl` (string, optional, uri, up to 2,048 characters) — Web only; iOS and Android show the app's own notification icon.
      - `data` (map<string, string>, optional, at most 10 keys, keys: pattern `^[A-Za-z0-9_.-]{1,64}$`, values: up to 1,024 characters) — Up to 10 string key-value pairs passed to the app untouched, for deep links and in-app routing.
      - `buttons` (object[], optional, at least 1 item, at most 2 items) — Up to 2 action buttons.
        - `title` (string, required, 1–30 characters) — The button label.
        - `url` (string, required, uri, up to 2,048 characters) — The HTTPS page the button opens.
      - `collapseKey` (string, optional, pattern `^[A-Za-z0-9_-]{1,64}$`) — A newer notification with the same key replaces the older one on the device.
      - `sound` (string, optional, 1–64 characters) — The name of a sound the app ships; the device's default if unset.
      - `badge` (integer, optional, min 0, max 99999) — The number shown on the iOS app icon.
      - `ttl` (DurationOrZeroInput, optional) — How long a provider keeps trying an offline device, up to 28 days; zero delivers now or never. Defaults to one day.
        - `value` (integer, required, min 0)
        - `unit` (string, required, one of `seconds`, `minutes`, `hours`, `days`)
      - `urgency` (string, optional, one of `high`, `normal`) — `high` asks the push service to deliver promptly, even to a sleeping device; `normal` lets it wait for a good moment to save battery. Device settings and the app still decide whether and how it is shown. Unset, a transactional push is `high` and any other `normal`.
  - `pushProjectId` (string, optional, uuid) — Reaches the contact's devices on every app of the project. Needed only when the workspace has more than one push project.
- `sendAt` (string, optional, date-time) — When to send, up to 90 days ahead, with its UTC offset. Suppression and push reachability are checked when the request is accepted and again when it goes. A `fallbackOrder` fallback keeps this time but goes only after the message before it fails, is skipped or bounces.
- `fallbackOrder` (string[], optional, at least 1 item) — With both `push` and `email`, each once, in the order to try them: the first the contact can receive is sent, and the next only if it fails, is skipped or bounces. Left out, each channel the contact can receive is sent.

## Returns

Returns `202 Accepted` with a `TransactionalSendResult` in `data`. It can also return `409 Conflict`, `413 Payload Too Large` or `415 Unsupported Media Type`; see the statuses below. Otherwise returns an error; [Errors](https://kirora.io/docs/api/reference/errors) lists every code and which to retry.

- `messages` (TransactionalMessage[], required)
  - `id` (string, required, uuid)
  - `channel` (string, required, one of `email`, `push`)
  - `status` (string, required, one of `queued`, `sending`, `skipped`, `sent`, `failed`, `delivered`, `bounced`, `complained`)
  - `correlationId` (string | null, required) — The `correlationId` given when it was sent, if any.
  - `createdAt` (string, required, date-time)
  - `sendAt` (string | null, required, date-time) — When a timed message goes out; null when it went at once.
  - `fallbackFor` (string | null, required, uuid) — With `fallbackOrder`: the message this one stands in for. It waits as `queued` until that one ends: sent when it failed, was skipped or bounced, else skipped with reason `fallback_not_needed`. Null otherwise.
  - `copy` (object | null, required) — Set on a `cc` or `bcc` copy; null on the message itself.
    - `kind` (string, required, one of `cc`, `bcc`)
    - `of` (string, required, uuid) — The message it is a copy of.
- `skippedChannels` (TransactionalSkippedChannel[], required) — Channels given that were not sent, each with the reason: the contact cannot receive it, or the plan has no room for it.
  - `channel` (string, required, one of `email`, `push`)
  - `code` (string, required) — Its error code, such as `SUPPRESSED`.
  - `message` (string, required)
- `skippedCopies` (TransactionalSkippedCopy[], required) — `cc` or `bcc` addresses that got no copy, each with the reason.
  - `email` (string, required)
  - `kind` (string, required, one of `cc`, `bcc`)
  - `code` (string, required) — Its error code, such as `SUPPRESSED`.
  - `message` (string, required)

Statuses:

- `202 Accepted`: Accepted: one message per channel sent.
- `409 Conflict`: The request conflicts with current state — a duplicate unique value, or a resource whose status does not allow this operation.
- `413 Payload Too Large`: The body is larger than this operation accepts (`PAYLOAD_TOO_LARGE`).
- `415 Unsupported Media Type`: The body is not JSON, or not the upload the operation takes (`UNSUPPORTED_MEDIA_TYPE`).

Headers on `202 Accepted`:

- `Idempotency-Replay` (string, optional, one of `true`) — `true` when this is the stored response to an earlier request with the same key.

## Example: Email from a stored template

```bash
curl -X POST https://api.kirora.io/v1/transactional/messages \
  -H "Authorization: Bearer $KIRORA_API_KEY" \
  -H "Idempotency-Key: order-1001-receipt" \
  -H "Content-Type: application/json" \
  -d '{
    "to": {
      "externalId": "customer-4821"
    },
    "correlationId": "order-1001",
    "data": {
      "order_id": "1001",
      "total": "$45.00",
      "item_count": 3
    },
    "email": {
      "template": "order-receipt"
    }
  }'
```

Response `202`:

```json
{
  "data": {
    "messages": [
      {
        "id": "0192f3b0-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
        "channel": "email",
        "status": "queued",
        "correlationId": "order-1001",
        "createdAt": "2026-11-17T08:30:00.000Z",
        "sendAt": null,
        "fallbackFor": null,
        "copy": null
      }
    ],
    "skippedChannels": [],
    "skippedCopies": []
  }
}
```

## Example: Email written in the request

```bash
curl -X POST https://api.kirora.io/v1/transactional/messages \
  -H "Authorization: Bearer $KIRORA_API_KEY" \
  -H "Idempotency-Key: signin-code-8f3a2c" \
  -H "Content-Type: application/json" \
  -d '{
    "to": {
      "email": "amaka@example.com"
    },
    "sensitive": true,
    "data": {
      "code": "482913"
    },
    "email": {
      "subject": "Your sign-in code",
      "from": "Juniper Kitchen <security@juniperkitchen.com>",
      "html": "<p>Your code is <strong>{{ data.code }}</strong>. It expires in 10 minutes.</p>",
      "text": "Your code is {{ data.code }}. It expires in 10 minutes."
    }
  }'
```

Response `202`:

```json
{
  "data": {
    "messages": [
      {
        "id": "0192f3b0-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
        "channel": "email",
        "status": "queued",
        "correlationId": null,
        "createdAt": "2026-11-17T08:30:00.000Z",
        "sendAt": null,
        "fallbackFor": null,
        "copy": null
      }
    ],
    "skippedChannels": [],
    "skippedCopies": []
  }
}
```

## Example: Push and email together

```bash
curl -X POST https://api.kirora.io/v1/transactional/messages \
  -H "Authorization: Bearer $KIRORA_API_KEY" \
  -H "Idempotency-Key: delivery-7731-shipped" \
  -H "Content-Type: application/json" \
  -d '{
    "to": {
      "contactId": "0192f3a1-7c2e-7d4b-9a10-3e5f6a7b8c9d"
    },
    "correlationId": "delivery-7731",
    "push": {
      "pushProjectId": "0192f3a1-91d4-7b2a-8c3e-4d5e6f7a8b9c",
      "title": "Order {{ data.order_id }} is on its way",
      "body": "Arriving {{ data.eta }}."
    },
    "data": {
      "order_id": "7731",
      "eta": "Thursday"
    },
    "email": {
      "template": "order-shipped"
    }
  }'
```

Response `202`:

```json
{
  "data": {
    "messages": [
      {
        "id": "0192f3b0-2b3c-7d4e-9f50-6b7c8d9e0f1a",
        "channel": "push",
        "status": "queued",
        "correlationId": "delivery-7731",
        "createdAt": "2026-11-17T08:30:00.000Z",
        "sendAt": null,
        "fallbackFor": null,
        "copy": null
      },
      {
        "id": "0192f3b0-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
        "channel": "email",
        "status": "queued",
        "correlationId": "delivery-7731",
        "createdAt": "2026-11-17T08:30:00.000Z",
        "sendAt": null,
        "fallbackFor": null,
        "copy": null
      }
    ],
    "skippedChannels": [],
    "skippedCopies": []
  }
}
```

## Example: Push first, email only if the push cannot be sent

```bash
curl -X POST https://api.kirora.io/v1/transactional/messages \
  -H "Authorization: Bearer $KIRORA_API_KEY" \
  -H "Idempotency-Key: signin-code-2b7d41" \
  -H "Content-Type: application/json" \
  -d '{
    "to": {
      "contactId": "0192f3a1-7c2e-7d4b-9a10-3e5f6a7b8c9d"
    },
    "sensitive": true,
    "data": {
      "code": "739204"
    },
    "push": {
      "pushProjectId": "0192f3a1-91d4-7b2a-8c3e-4d5e6f7a8b9c",
      "title": "Your sign-in code",
      "body": "{{ data.code }}"
    },
    "email": {
      "subject": "Your sign-in code",
      "from": "Juniper Kitchen <security@juniperkitchen.com>",
      "html": "<p>Your code is <strong>{{ data.code }}</strong>.</p>"
    },
    "fallbackOrder": [
      "push",
      "email"
    ]
  }'
```

Response `202`:

```json
{
  "data": {
    "messages": [
      {
        "id": "0192f3b0-2b3c-7d4e-9f50-6b7c8d9e0f1a",
        "channel": "push",
        "status": "queued",
        "correlationId": null,
        "createdAt": "2026-11-17T08:30:00.000Z",
        "sendAt": null,
        "fallbackFor": null,
        "copy": null
      },
      {
        "id": "0192f3b0-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
        "channel": "email",
        "status": "queued",
        "correlationId": null,
        "createdAt": "2026-11-17T08:30:00.000Z",
        "sendAt": null,
        "fallbackFor": "0192f3b0-2b3c-7d4e-9f50-6b7c8d9e0f1a",
        "copy": null
      }
    ],
    "skippedChannels": [],
    "skippedCopies": []
  }
}
```

## Example: Sent later, at 09:00 London time (GMT, +00:00)

```bash
curl -X POST https://api.kirora.io/v1/transactional/messages \
  -H "Authorization: Bearer $KIRORA_API_KEY" \
  -H "Idempotency-Key: class-reminder-3315" \
  -H "Content-Type: application/json" \
  -d '{
    "to": {
      "contactId": "0192f3a1-7c2e-7d4b-9a10-3e5f6a7b8c9d"
    },
    "email": {
      "subject": "Your class starts in an hour",
      "from": "Juniper Kitchen <hello@juniperkitchen.com>",
      "html": "<p>See you at 10:00 in the Shoreditch studio.</p>"
    },
    "sendAt": "2026-12-09T09:00:00+00:00"
  }'
```

Response `202`:

```json
{
  "data": {
    "messages": [
      {
        "id": "0192f3b0-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
        "channel": "email",
        "status": "queued",
        "correlationId": null,
        "createdAt": "2026-11-17T08:30:00.000Z",
        "sendAt": "2026-12-09T09:00:00.000Z",
        "fallbackFor": null,
        "copy": null
      }
    ],
    "skippedChannels": [],
    "skippedCopies": []
  }
}
```
