# Send transactional messages in a batch

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

`POST /v1/transactional/messages/batch`

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.

Each entry carries its own `idempotencyKey`; a retried entry replays. [About idempotency](https://kirora.io/docs/api/reference/idempotency)

Sends up to 100 transactional messages in one request. Each entry of `messages` holds a single send (see `POST /v1/transactional/messages`) as its `message`, optionally with its own `idempotencyKey`, and gets its own result: `results` keeps the order of `messages`, each with its `messages` (one per channel the entry sends on) and `replayed`, or the `error` a single send would have returned. One refused entry does not stop the rest. The call counts against the rate limit as one request per entry.

## Header parameters

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

- `messages` (object[], required, at least 1 item, at most 100 items)
  - `idempotencyKey` (string, optional, 8–255 characters) — As the `Idempotency-Key` header of a single send: one per message you mean. Without one, a retried batch sends this message again.
  - `message` (SendTransactionalInput, required)
    - `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.
        - `threading` (EmailThreadingInput, optional) — Places it in an existing conversation, as the `In-Reply-To` and `References` headers.
        - `tracking` (TransactionalEmailTrackingInput, optional) — Open and click tracking; off unless asked for. Contacts whose `country` is in the EEA or the UK are never tracked.
        - `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.
        - `threading` (EmailThreadingInput, optional) — Places it in an existing conversation, as the `In-Reply-To` and `References` headers.
        - `tracking` (TransactionalEmailTrackingInput, optional) — Open and click tracking; off unless asked for. Contacts whose `country` is in the EEA or the UK are never tracked.
        - `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.
        - `android` (object, optional) — Replaces shared fields on Android.
        - `web` (object, optional) — Replaces shared fields on web.
      - `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 `SendTransactionalMessagesBatchResult` 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.

- `results` (object[], required)
  - Any of: **Accepted**
    - `idempotencyKey` (string | null, required) — As sent; null when none was.
    - `replayed` (boolean, required) — True when this key had already been accepted; nothing new was sent.
    - `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)
  - Any of: **Rejected**
    - `idempotencyKey` (string | null, required)
    - `error` (object, required)
      - `code` (string, required, one of `BAD_REQUEST`, `ALREADY_EXISTS`, `CONFLICT`, `IN_USE`, `FORBIDDEN`, `SCOPE_MISSING`, `AGENT_KEY_REFUSED`, `INTERNAL_ERROR`, `NOT_FOUND`, `METHOD_NOT_ALLOWED`, `PAYLOAD_TOO_LARGE`, `UNSUPPORTED_MEDIA_TYPE`, `RATE_LIMITED`, `REVISION_CONFLICT`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED`, `VALIDATION_ERROR`, `API_KEY_IP_NOT_ALLOWED`, `AGENT_CONFIRMATION_REQUIRED`, `EMBED_BUILDER_UNAVAILABLE`, `EMBED_ORIGIN_NOT_ALLOWED`, `WORKSPACE_SUSPENDED`, `WORKSPACE_DELETION_PENDING`, `CONTACT_SEND_IN_PROGRESS`, `EXPORT_UNAVAILABLE`, `NOTHING_TO_EXPORT`, `EVENT_NAME_LIMIT_REACHED`, `PLAN_LIMIT_REACHED`, `SENDING_REVIEW_REQUIRED`, `PLAN_FEATURE_UNAVAILABLE`, `CAMPAIGN_AUDIENCE_CONFLICT`, `EMPTY_AUDIENCE`, `DRAFT_INCOMPLETE`, `INVALID_SCHEDULE`, `UNSUPPORTED_CHANNEL`, `POSTAL_ADDRESS_MISSING`, `CONSENT_MISSING`, `SUPPRESSED`, `TOPIC_UNSUBSCRIBED`, `SIGNUP_BLOCKED`, `INVALID_SENDER_ADDRESS`, `SENDING_DOMAIN_DMARC_MISSING`, `SENDING_DOMAIN_IDENTITY_CONFLICT`, `TEST_SEND_OUTCOME_UNKNOWN`, `TEST_SEND_ROUTE_NOT_READY`, `TEST_SEND_UNAVAILABLE`, `SENDING_DOMAIN_UNVERIFIED`, `IDEMPOTENCY_KEY_REUSED`, `IDEMPOTENCY_KEY_IN_USE`, `IDEMPOTENCY_KEY_OUTCOME_UNKNOWN`, `CONTENT_NOT_RETAINED`, `RECIPIENT_EMAIL_MISSING`, `RECIPIENT_EMAIL_UNSUPPORTED`, `TEMPLATE_NOT_PUBLISHED`, `TEMPLATE_ENVELOPE_INCOMPLETE`, `PUSH_APP_NOT_READY`, `PUSH_DESTINATION_MISSING`, `FORM_CLOSED`)
      - `message` (string, required)
      - `errors` (FieldError[], optional) — Each field the request got wrong: on `VALIDATION_ERROR` and `DRAFT_INCOMPLETE`.
        - `in` (string, optional, one of `body`, `query`, `params`, `headers`) — The part of the request the field is in, when known.
        - `field` (string, required) — Dotted path to the field (`blocks.0.text`); empty for the whole part.
        - `code` (string, optional) — The rule that failed (`too_big`, `invalid_type`, `custom`, …).
        - `message` (string, required) — Plain words, meant to be shown beside the field.
        - `minimum` (number, optional) — The bound a `too_small` rule set.
        - `maximum` (number, optional) — The bound a `too_big` rule set.
      - `details` (object, optional) — What else the error carries, by `code`: see that code's description.
      - `requestId` (string, optional)

Statuses:

- `202 Accepted`: One result per entry, in order.
- `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`).

## Example: Two receipts in one request

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

Response `202`:

```json
{
  "data": {
    "results": [
      {
        "idempotencyKey": "order-1001-receipt",
        "replayed": false,
        "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": []
      },
      {
        "idempotencyKey": "order-1002-receipt",
        "replayed": false,
        "messages": [
          {
            "id": "0192f3b0-3c4d-7e5f-8a61-7c8d9e0f1a2b",
            "channel": "email",
            "status": "queued",
            "correlationId": "order-1002",
            "createdAt": "2026-11-17T08:30:00.000Z",
            "sendAt": null,
            "fallbackFor": null,
            "copy": null
          }
        ],
        "skippedChannels": [],
        "skippedCopies": []
      }
    ]
  }
}
```

## Example: One message fails, the other goes

```bash
curl -X POST https://api.kirora.io/v1/transactional/messages/batch \
  -H "Authorization: Bearer $KIRORA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      {
        "idempotencyKey": "order-1003-receipt",
        "message": {
          "to": {
            "externalId": "customer-4821"
          },
          "correlationId": "order-1003",
          "data": {
            "order_id": "1003",
            "total": "$24.00",
            "item_count": 2
          },
          "email": {
            "template": "order-receipt"
          }
        }
      },
      {
        "idempotencyKey": "order-1004-receipt",
        "message": {
          "to": {
            "externalId": "customer-0000"
          },
          "correlationId": "order-1004",
          "data": {
            "order_id": "1004",
            "total": "$7.25",
            "item_count": 1
          },
          "email": {
            "template": "order-receipt"
          }
        }
      }
    ]
  }'
```

Response `202`:

```json
{
  "data": {
    "results": [
      {
        "idempotencyKey": "order-1003-receipt",
        "replayed": false,
        "messages": [
          {
            "id": "0192f3b0-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
            "channel": "email",
            "status": "queued",
            "correlationId": "order-1003",
            "createdAt": "2026-11-17T08:30:00.000Z",
            "sendAt": null,
            "fallbackFor": null,
            "copy": null
          }
        ],
        "skippedChannels": [],
        "skippedCopies": []
      },
      {
        "idempotencyKey": "order-1004-receipt",
        "error": {
          "code": "NOT_FOUND",
          "message": "No contact matches `to`",
          "details": {
            "field": "to"
          }
        }
      }
    ]
  }
}
```
