# Broadcasts

One message to many recipients, each with their own unsubscribe link.

A broadcast sends the same content to many people who asked for it, such as a newsletter or a notice to every customer. You send the content once, with a list of messages. Sendora gives every recipient a copy with their own unsubscribe link, and tells you how far it has come.

A broadcast goes on a broadcast stream. Create one with `kind: "broadcast"` before your first broadcast, as [Streams](https://sendora.se/docs/streams#create-a-stream) shows.

## Send a broadcast

cURL:

```bash
curl -X POST https://api.sendora.se/v1/broadcasts \
  -H "Authorization: Bearer $SENDORA_API_TOKEN" \
  -H "Idempotency-Key: newsletter-2026-10" \
  -H "Content-Type: application/json" \
  -d '{
  "streamId": "a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "from": {
    "email": "news@example.se",
    "name": "Example AB"
  },
  "subject": "News from Example in October",
  "text": "Hi!\n\nHere is what happened in October.\n\nNo more of these? {{ unsubscribe_url }}",
  "html": "<p>Hi!</p><p>Here is what happened in 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"
        }
      ]
    }
  ]
}'
```

TypeScript:

```ts
import { Sendora } from '@sendora/sdk';

const sendora = new Sendora({
  token: process.env.SENDORA_API_TOKEN,
});

const { broadcastId, total } =
  await sendora.broadcasts.send(
    {
      streamId: 'a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
      from: {
        email: 'news@example.se',
        name: 'Example AB',
      },
      subject: 'News from Example in October',
      text: 'Hi!\n\nHere is what happened in October.\n\nNo more of these? {{ unsubscribe_url }}',
      html: '<p>Hi!</p><p>Here is what happened in 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' },
          ],
        },
      ],
    },
    { idempotencyKey: 'newsletter-2026-10' },
  );

console.log(broadcastId, total);
```

Python:

```python
import os

from sendora import Sendora

sendora = Sendora(os.environ["SENDORA_API_TOKEN"])

accepted = sendora.broadcasts.send(
    stream_id="a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    from_={
        "email": "news@example.se",
        "name": "Example AB",
    },
    subject="News from Example in October",
    text=(
        "Hi!\n\n"
        "Here is what happened in October.\n\n"
        "No more of these? {{ unsubscribe_url }}"
    ),
    html=(
        "<p>Hi!</p>"
        "<p>Here is what happened in 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",
                }
            ]
        },
    ],
    idempotency_key="newsletter-2026-10",
)

print(accepted.broadcast_id, accepted.total)
```

Response:

```json
{
  "broadcastId": "2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01",
  "status": "accepted",
  "total": 2,
  "suppressed": 0,
  "submittedAt": "2026-10-01T08:00:00.000Z",
  "test": false
}
```

Give the content once: `from`, `subject`, `text`, `html`, `headers`, `attachments`, `tag` and `metadata`. Each entry of `messages` is one message with its own `to`, `cc` and `bcc`. An entry may also set `metadata` and `headers`, which are laid over the broadcast's.

[Limits](https://sendora.se/docs/limits#broadcasts) gives the most messages and the largest size a broadcast may have. Each message in it has the size and recipient limits of a single send. Send a longer list as several broadcasts under one tag.

The `Idempotency-Key` header is required, and a request without it fails with `idempotency_key_required`. A retry with the same key returns the same broadcast and never sends it twice. Choose the key yourself, such as the newsletter's name, so that a retry from another process uses it too.

With the TypeScript SDK:

Pass the key as `idempotencyKey`, in the second argument to `sendora.broadcasts.send`. Without it, the SDK makes up a random key, which covers only its own retries. A refused broadcast throws `SendoraError` with its code.

With the Python SDK:

Pass the key as `idempotency_key` to `sendora.broadcasts.send`. Without it, the SDK makes up a random key, which covers only its own retries. A refused broadcast raises `SendoraError` with its code.

## Rules of a broadcast stream

These rules apply to every message on a broadcast stream, whether it goes in a broadcast or alone.

- Send only to people who asked for the mail, as the [acceptable use rules](https://sendora.se/acceptable-use) say.
- Write `{{ unsubscribe_url }}` in the text and in the HTML, where the link should go. A message without it in every part fails with `unsubscribe_placeholder_missing`.
- Sendora sends each recipient a copy addressed to them alone, with their own link. A message with several addresses gives each address its own copy, so the recipients never see each other. Sendora also sets the `List-Unsubscribe` headers that mail clients show as a button. A `List-Unsubscribe` header of your own fails with `list_unsubscribe_reserved`.
- A recipient who unsubscribes goes on the stream's [suppression list](https://sendora.se/docs/suppressions), and your [webhook](https://sendora.se/docs/webhooks) gets the event `unsubscribed`.

If complaints pass the rate on [Limits](https://sendora.se/docs/limits#broadcasts), Sendora pauses the stream. Every send on it then fails with `stream_paused`, and the stream shows `pausedAt`. Support goes through your mailing list with you, then resumes the stream.

## What is dropped and what is refused

- An address on the stream's suppression list is dropped and counted in `suppressed`, and the rest are still sent. A message with no address left is not stored. If no address is left at all, the broadcast fails with `recipient_suppressed`.
- Every address left counts against the monthly cap when the broadcast is accepted. If it would pass the cap, it fails with `monthly_cap_reached` and nothing is stored. The per-minute limits do not apply to a broadcast.
- A broadcast on a transactional or an inbound stream fails with `stream_not_broadcast`, and on an archived one with `stream_archived`.

## Personalise

cURL:

```bash
curl -X POST https://api.sendora.se/v1/broadcasts \
  -H "Authorization: Bearer $SENDORA_API_TOKEN" \
  -H "Idempotency-Key: welcome-2026-10" \
  -H "Content-Type: application/json" \
  -d '{
  "streamId": "a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "from": {
    "email": "news@example.se",
    "name": "Example AB"
  },
  "subject": "{{ first_name }}, your October news",
  "text": "Hi {{ first_name }}!\n\nYou have {{ points }} points to spend before the end of the year.\n\nNo more of these? {{ unsubscribe_url }}",
  "messages": [
    {
      "to": [
        "anna@example.com"
      ],
      "substitutions": {
        "first_name": "Anna",
        "points": "120"
      }
    },
    {
      "to": [
        "bo@example.com"
      ],
      "substitutions": {
        "first_name": "Bo",
        "points": "35"
      }
    }
  ]
}'
```

TypeScript:

```ts
import { Sendora } from '@sendora/sdk';

const sendora = new Sendora({
  token: process.env.SENDORA_API_TOKEN,
});

const { broadcastId } = await sendora.broadcasts.send(
  {
    streamId: 'a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
    from: {
      email: 'news@example.se',
      name: 'Example AB',
    },
    subject: '{{ first_name }}, your October news',
    text: 'Hi {{ first_name }}!\n\nYou have {{ points }} points to spend before the end of the year.\n\nNo more of these? {{ unsubscribe_url }}',
    messages: [
      {
        to: ['anna@example.com'],
        substitutions: {
          first_name: 'Anna',
          points: '120',
        },
      },
      {
        to: ['bo@example.com'],
        substitutions: { first_name: 'Bo', points: '35' },
      },
    ],
  },
  { idempotencyKey: 'welcome-2026-10' },
);

console.log(broadcastId);
```

Python:

```python
import os

from sendora import Sendora

sendora = Sendora(os.environ["SENDORA_API_TOKEN"])

accepted = sendora.broadcasts.send(
    stream_id="a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    from_={
        "email": "news@example.se",
        "name": "Example AB",
    },
    subject="{{ first_name }}, your October news",
    text=(
        "Hi {{ first_name }}!\n\n"
        "You have {{ points }} points to spend "
        "before the end of the year.\n\n"
        "No more of these? {{ unsubscribe_url }}"
    ),
    messages=[
        {
            "to": ["anna@example.com"],
            "substitutions": {
                "first_name": "Anna",
                "points": "120",
            },
        },
        {
            "to": ["bo@example.com"],
            "substitutions": {
                "first_name": "Bo",
                "points": "35",
            },
        },
    ],
    idempotency_key="welcome-2026-10",
)

print(accepted.broadcast_id)
```

Response:

```json
{
  "broadcastId": "9b7e5d3c-1a2f-4e6b-8c0d-2f4a6b8c0d1e",
  "status": "accepted",
  "total": 2,
  "suppressed": 0,
  "submittedAt": "2026-10-01T08:10:00.000Z",
  "test": false
}
```

Write `{{ key }}` in the subject, the text or the HTML, and give each message its values in `substitutions`. A key is letters, digits and underscores, and every value is a string. [Limits](https://sendora.se/docs/limits#broadcasts) gives how many values a message may have, and how long each may be.

Sendora fills in each message before it adds the unsubscribe link, and escapes the values in the HTML. The log shows the filled-in subject. If the content names a key that a message lacks, the whole broadcast fails with `substitution_missing`. The error names the message's position and the missing keys.

Nothing else is rendered: there are no conditions, loops or filters.

## Follow a broadcast

cURL:

```bash
curl https://api.sendora.se/v1/broadcasts/{broadcastId} \
  -H "Authorization: Bearer $SENDORA_API_TOKEN"
```

TypeScript:

```ts
import { Sendora } from '@sendora/sdk';

const sendora = new Sendora({
  token: process.env.SENDORA_API_TOKEN,
});

const broadcast = await sendora.broadcasts.get(
  '2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01',
);

console.log(
  broadcast.status,
  broadcast.released,
  broadcast.total,
);
```

Python:

```python
import os

from sendora import Sendora

sendora = Sendora(os.environ["SENDORA_API_TOKEN"])

broadcast = sendora.broadcasts.get(
    "2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01"
)

print(
    broadcast.status, broadcast.released, broadcast.total
)
```

Response:

```json
{
  "broadcastId": "2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01",
  "streamId": "a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "status": "processing",
  "from": "news@example.se",
  "subject": "News from Example in October",
  "tag": "newsletter-2026-10",
  "metadata": {},
  "total": 2,
  "released": 1,
  "failed": 0,
  "suppressed": 0,
  "submittedAt": "2026-10-01T08:00:00.000Z",
  "completedAt": null
}
```

- `total`: the messages stored.
- `released`: the messages handed to the mail server so far. Sendora releases them at the pace on [Limits](https://sendora.se/docs/limits#broadcasts). Divide the messages by that pace for the minutes a broadcast takes.
- `failed`: the messages that never reached the mail server, cancelled ones included. A bounce is not counted here. It shows on its message.
- `status`: `accepted`, then `processing`, then `completed` once nothing is left to send. A cancelled broadcast is `cancelled`. `completedAt` is set when it ends.

Each message of a broadcast is a message in the [log](https://sendora.se/docs/messages), with its own timeline and the `broadcastId`. The search filters by `broadcastId`, and every [webhook](https://sendora.se/docs/webhooks) event about such a message includes it. Follow deliveries, bounces, complaints and unsubscribes there, as for any other message.

[List the broadcasts](https://sendora.se/docs/api/get-v1-broadcasts) returns them newest first. In the dashboard, a broadcast stream's page lists its broadcasts. Each broadcast's page shows its counts, and a cancel button while it runs.

## Cancel a broadcast

cURL:

```bash
curl -X POST https://api.sendora.se/v1/broadcasts/{broadcastId}/cancel \
  -H "Authorization: Bearer $SENDORA_API_TOKEN"
```

TypeScript:

```ts
import { Sendora } from '@sendora/sdk';

const sendora = new Sendora({
  token: process.env.SENDORA_API_TOKEN,
});

const cancelled = await sendora.broadcasts.cancel(
  '2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01',
);

console.log(cancelled.status, cancelled.failed);
```

Python:

```python
import os

from sendora import Sendora

sendora = Sendora(os.environ["SENDORA_API_TOKEN"])

cancelled = sendora.broadcasts.cancel(
    "2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01"
)

print(cancelled.status, cancelled.failed)
```

Response:

```json
{
  "broadcastId": "2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01",
  "streamId": "a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "status": "cancelled",
  "from": "news@example.se",
  "subject": "News from Example in October",
  "tag": "newsletter-2026-10",
  "metadata": {},
  "total": 2,
  "released": 1,
  "failed": 1,
  "suppressed": 0,
  "submittedAt": "2026-10-01T08:00:00.000Z",
  "completedAt": "2026-10-01T08:05:00.000Z"
}
```

A cancel stops every message that is not yet handed to the mail server. Those count as `failed`, and the broadcast becomes `cancelled` with `completedAt` set. A message already on its way is still delivered. Cancelling a completed or cancelled broadcast fails with `broadcast_not_open`.
