# POST /v1/broadcasts

Send a broadcast

Stores the content once and one message per entry of `messages`, up to 50,000, on a broadcast stream, in one transaction. Each message is a message in the log with its own events and webhooks, sent with the recipient’s own unsubscribe link where `{{ unsubscribe_url }}` stands. Each message may carry `substitutions`, up to 20 strings that replace `{{ key }}` in the subject, the text and the HTML, escaped in the HTML; a key the content names must be given by every message. Addresses on the stream’s suppression list are dropped and counted; a list with nothing left, a missing placeholder, a missing substitution, a transactional or archived stream and a count over the monthly cap are refused whole. The Idempotency-Key is required: a retry under the same key answers the same broadcast.

Takes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.broadcasts.send(broadcast)`. Python: `sendora.broadcasts.send(**broadcast)`.

## Parameters

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

## Request body

- `streamId` (string (uuid), required): The broadcast stream of this server the messages go on.
- `from` (one of 2, required): The sender. Its domain must be a verified sending domain of the server.
- `subject` (string (at least 1, at most 998 characters), required): The subject line.
- `text` (string (at least 1 character)): The plain-text part, with {{ unsubscribe_url }} where the link goes. At least one of text and html is required.
- `html` (string (at least 1 character)): The HTML part, with {{ unsubscribe_url }} where the link goes.
- `headers` (map of string to string): Custom headers on every message, at most 20. The List-Unsubscribe pair is Sendora’s too. Sendora writes these itself, so a message may not set them: From, Sender, To, Cc, Bcc, Subject, Date, Message-ID, MIME-Version, Return-Path, Received, Delivered-To, DKIM-Signature, DomainKey-Signature, Authentication-Results, Received-SPF, and any name that starts with Content-, Resent-, ARC- or X-Kumo.
- `attachments` (array of object, at most 20): At most 20, sent with every message. A message with its attachments encoded may be at most 10 MB.
  - `name` (string, required)
  - `content` (string (base64), required)
  - `contentType` (string)
  - `contentId` (string or null)
- `tag` (string or null): A label of up to 100 characters, returned with the message and its events.
- `metadata` (map of string to string): Up to 20 key-value pairs of your own, on the broadcast and on every message.
- `messages` (array of object, at least 1, at most 50000, required): One entry per message, at most 50000; a larger list is several broadcasts under one tag.
  - `to` (array of one of 2, at least 1, required): Recipients of this message; at most 50 across to, cc and bcc.
  - `cc` (array of one of 2): Copied recipients, shown in the message.
  - `bcc` (array of one of 2): Blind-copied recipients.
  - `metadata` (map of string to string): Up to 20 key-value pairs for this message, merged over the broadcast's.
  - `headers` (map of string to string): Custom headers for this message, merged over the broadcast's.
  - `substitutions` (map of string to string): Up to 20 strings that replace {{ key }} in the subject, the text and the HTML of this message; a key the content names must be given.

The forms of `from`:

- Form 1: string (email)
- Form 2
  - `email` (string (email), required)
  - `name` (string (at least 1, at most 200 characters) or null): A display name of up to 200 characters.

Example:

```json
{
  "streamId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "from": {
    "email": "news@acme.se",
    "name": "Acme"
  },
  "subject": "News from Acme in October",
  "text": "Hi!\n\nHere is the news for October.\n\nRather not get these? {{ unsubscribe_url }}",
  "html": "<p>Hi!</p><p>Here is the news for October.</p><p><a href=\"{{ unsubscribe_url }}\">Unsubscribe</a></p>",
  "tag": "newsletter-2026-10",
  "messages": [
    {
      "to": [
        "anna@example.com"
      ],
      "metadata": {
        "customer": "1042"
      }
    },
    {
      "to": [
        {
          "email": "bo@example.com",
          "name": "Bo Berg"
        }
      ]
    }
  ]
}
```

## Responses

### 200 The broadcast was stored; Sendora sends its messages from here.

- `broadcastId` (string (uuid), required): The id the messages, the progress and the cancel refer to.
- `status` ("accepted", required)
- `total` (integer, required): Messages stored; each is a message in the log.
- `suppressed` (integer, required): Addresses dropped for standing on the stream’s suppression list.
- `submittedAt` (string (date-time), required)
- `test` (boolean, required): True when the server is a test server: the messages go through everything but delivery, and nobody receives them.

Headers:

- `Idempotent-Replayed` ("true"): true when this is the first answer again, to a request sent before under the same Idempotency-Key; absent otherwise.

Example:

```json
{
  "broadcastId": "2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01",
  "status": "accepted",
  "total": 2,
  "suppressed": 0,
  "submittedAt": "2026-10-01T08:00:00.000Z",
  "test": 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. |
| `from_domain_not_verified` | 422 | The From domain is not a verified sending domain of this server. |
| `stream_not_found` | 422 | The streamId names no stream of this server. |
| `stream_archived` | 422 | The stream is archived and takes no new messages. |
| `stream_paused` | 422 | Sendora has paused the stream after complaints; it takes no messages until support has resumed it. |
| `stream_not_broadcast` | 422 | A broadcast goes on a broadcast stream; the streamId names a transactional one. |
| `unsubscribe_placeholder_missing` | 422 | A message on a broadcast stream must carry {{ unsubscribe_url }} in every part it has. |
| `list_unsubscribe_reserved` | 422 | On a broadcast stream Sendora writes the List-Unsubscribe pair itself, so a message may not carry one. |
| `substitution_missing` | 422 | The subject, text or HTML names a {{ key }} that a message does not give. |
| `recipient_suppressed` | 422 | One or more recipients are on the suppression list of the stream. |
| `test_address_on_live_server` | 422 | Addresses at simulator.sendora.se act out an outcome on a test server; a live server never sends to them. |
| `idempotency_key_mismatch` | 422 | The Idempotency-Key was already used for a different request. |
| `monthly_cap_reached` | 429 | The monthly cap of the account, the server or the test servers is used up. |
| `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"
    }
  ]
}
```

### `substitution_missing`

- `index` (integer, required): The position of the message in `messages`.
- `keys` (array of string, required): The keys the content names that the message lacks.

Example:

```json
{
  "error": "substitution_missing",
  "message": "The content names {{ firstName }}, which message 1 does not give.",
  "index": 1,
  "keys": [
    "firstName"
  ]
}
```

### `recipient_suppressed`

- `streamId` (string (uuid), required): The stream whose suppression list refused the send.
- `suppressed` (array of object, required): Each refused address, lower-cased, with the reason it is on the list.
  - `address` (string, required)
  - `reason` ("hard_bounce" | "spam_complaint" | "manual" | "unsubscribe", required)

Example:

```json
{
  "error": "recipient_suppressed",
  "message": "One or more recipients are on the suppression list of this stream; suppressed says which and why.",
  "streamId": "3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "suppressed": [
    {
      "address": "anna@example.com",
      "reason": "hard_bounce"
    }
  ]
}
```

### `test_address_on_live_server`

- `addresses` (array of string, required): The recipients at simulator.sendora.se, each once.

Example:

```json
{
  "error": "test_address_on_live_server",
  "message": "Addresses at simulator.sendora.se act out an outcome on a test server and are never sent to from a live one. Send them from a test server.",
  "addresses": [
    "hardbounce@simulator.sendora.se"
  ]
}
```

### `monthly_cap_reached`

- `scope` ("account" | "server" | "test", required): Whose limit it is: `account` for the account's, `server` for the server's own, `test` for the cap the account's test servers share.
- `cap` (integer, required): Emails the scope may send in a calendar month, in UTC.
- `used` (integer, required): Emails sent this month, the refused send not counted.
- `resetsAt` (string (date-time), required): When the month counter resets.

Example:

```json
{
  "error": "monthly_cap_reached",
  "message": "This account has used 50000 of 50000 emails this month. The cap resets at 2026-11-01T00:00:00.000Z.",
  "scope": "account",
  "cap": 50000,
  "used": 50000,
  "resetsAt": "2026-11-01T00:00:00.000Z"
}
```
