# POST /v1/email/batch

Send up to 100 messages

Accepts an array of messages and answers one result per message, in order: accepted with its id, or refused with the same error the single send would give. The Idempotency-Key is required. A retry under the same key sends nothing twice: every item accepted before is answered again with replayed set, and only the items that failed are attempted again.

Takes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.email.sendBatch(messages)`. Python: `sendora.email.send_batch(messages)`.

## Parameters

- `idempotency-key` (header, required, string): Any text of 1 to 255 printable characters that identifies this batch; the same key with a different item is refused for that item.

## Request body

A array of object, at least 1, at most 100.

Example:

```json
[
  {
    "from": {
      "email": "no-reply@example.se",
      "name": "Example AB"
    },
    "to": [
      "anna@example.com"
    ],
    "subject": "Your invoice for September",
    "text": "Hi Anna, your invoice is attached.",
    "attachments": [
      {
        "name": "invoice.pdf",
        "content": "JVBERi0xLjcK",
        "contentType": "application/pdf"
      }
    ],
    "tag": "invoice",
    "metadata": {
      "invoiceId": "2026-0912"
    }
  }
]
```

## Responses

### 200 Every message answered on its own; the batch as a whole never fails halfway.

- `results` (array of one of 18, required): One result per message, in request order.

The forms of each item of `results`:

- Form 1: The message was accepted for delivery.
  - `messageId` (string (uuid), required): The id the message log, the events and the webhooks refer to.
  - `status` ("accepted", required)
  - `submittedAt` (string (date-time), required): When the message was accepted.
  - `test` (boolean, required): True when the server is a test server: the message goes through everything but delivery, and nobody receives it.
  - `index` (integer, required): The position of the message in the request.
  - `replayed` (boolean, required): True when an earlier request under the same key already sent this item.

A refusal carries `message`, `index`, `status` and `error`, one of these codes, with the fields the code adds:

| Code | Meaning | Adds |
| --- | --- | --- |
| `invalid_request` | The message does not match what a send takes. | `issues` (`path`, `message`) |
| `from_domain_not_verified` | The From domain is not a verified sending domain of this server. |  |
| `stream_not_found` | The streamId names no stream of this server. |  |
| `stream_archived` | The stream is archived and takes no new messages. |  |
| `stream_paused` | Sendora has paused the stream after complaints; it takes no messages until support has resumed it. |  |
| `stream_not_sendable` | An inbound stream receives mail; it takes no messages and has no suppression list. Name a transactional or broadcast stream. |  |
| `unsubscribe_placeholder_missing` | A message on a broadcast stream must carry {{ unsubscribe_url }} in every part it has. |  |
| `list_unsubscribe_reserved` | On a broadcast stream Sendora writes the List-Unsubscribe pair itself, so a message may not carry one. |  |
| `recipient_suppressed` | One or more recipients are on the suppression list of the stream. | `streamId`, `suppressed` (`address`, `reason`) |
| `test_address_on_live_server` | Addresses at simulator.sendora.se act out an outcome on a test server; a live server never sends to them. | `addresses` |
| `idempotency_key_mismatch` | The Idempotency-Key was already used for a different request. |  |
| `rate_limited` | More was sent within a minute than the limit allows. | `scope`, `limit`, `retryAfter` |
| `monthly_cap_reached` | The monthly cap of the account, the server or the test servers is used up. | `scope`, `cap`, `used`, `resetsAt` |
| `sending_disabled` | Sending is disabled for everyone for the moment; retry after the seconds in Retry-After. |  |
| `account_paused` | The account is paused by Sendora. |  |
| `account_not_active` | The account is closed, or not yet approved for a live server's sends or an inbound stream. |  |
| `payment_required` | The account has no active subscription. |  |

Example:

```json
{
  "results": [
    {
      "index": 0,
      "messageId": "5f432ffd-c005-499b-aa3a-5b8a088ea20e",
      "status": "accepted",
      "submittedAt": "2026-09-15T12:00:00.000Z",
      "test": false,
      "replayed": false
    }
  ]
}
```

## Errors

Every error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.

| Code | Status | Meaning |
| --- | --- | --- |
| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |
| `idempotency_key_required` | 400 | Batch sends need an Idempotency-Key header. |
| `unauthorized` | 401 | The key is missing, malformed or revoked. |
| `payment_required` | 402 | The account has no active subscription. |
| `wrong_token_kind` | 403 | The key is of the other kind: a server key (sk_) where an account key (ak_) is needed, or the reverse. The message names the kind the operation takes. |
| `account_paused` | 403 | The account is paused by Sendora. |
| `account_not_active` | 403 | The account is closed, or not yet approved for a live server's sends or an inbound stream. |
| `request_too_large` | 413 | The body exceeds 10 MB. |
| `sending_disabled` | 503 | Sending is disabled for everyone for the moment; retry after the seconds in Retry-After. |

Headers sent with an error:

- `Retry-After` (integer) on 503: Seconds to wait before trying again.

### `invalid_request`

- `issues` (array of object): One entry per invalid field; absent when a header is wrong.
  - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.
  - `message` (string, required)

Example:

```json
{
  "error": "invalid_request",
  "message": "The request is invalid: to.0: Invalid email address",
  "issues": [
    {
      "path": "to.0",
      "message": "Invalid email address"
    }
  ]
}
```
