---
title: "Broadcasts"
description: "`broadcasts.preview`, `send`, `list`, `list_all`, `iterate`, `get`, `list_recipients`, `list_all_recipients`, `iterate_recipients`, `get_recipient`, `stats`, `analytics` and `cancel`."
url: "https://openemail.uk/docs/python/broadcasts"
area: "Python"
category: "Mailbox"
---

# Broadcasts

`broadcasts.preview`, `send`, `list`, `list_all`, `iterate`, `get`, `list_recipients`, `list_all_recipients`, `iterate_recipients`, `get_recipient`, `stats`, `analytics` and `cancel`.

## Every method

**broadcasts.py**

```
import time

from openemail import openemail
from openemail.types import BroadcastCreate

draft: BroadcastCreate = {
    'audienceIds': ['aud_4c1b8e2a7d9f05c36b4e8a71'],
    'from': 'Acme <news@acme.com>',
    'subject': '{{firstName|Hello}}, the September release is out',
    'html': '<p>Hi {{firstName|there}},</p><p>Here is what changed this month.</p>',
    'text': 'Hi {{firstName|there}}, here is what changed this month. Unsubscribe: {{unsubscribeUrl}}',
    'tags': {'campaign': 'release-2026-09'},
}

reach = openemail.broadcasts.preview(draft)
print(reach['recipients'], reach['unsubscribed'], reach['suppressed'])

broadcast = openemail.broadcasts.send(draft)

latest = openemail.broadcasts.get(broadcast['id'])
while latest['status'] in ('scheduled', 'queued', 'sending'):
    time.sleep(5)
    latest = openemail.broadcasts.get(broadcast['id'])

for copy in openemail.broadcasts.iterate_recipients(broadcast['id']):
    print(copy['email'], copy['status'], copy['opens'], copy['clicks'])

bounced = openemail.broadcasts.list_recipients(broadcast['id'], filter='bounced')
if bounced['items']:
    content = openemail.broadcasts.get_recipient(broadcast['id'], bounced['items'][0]['emailId'])
    print(content['subject'], content['bouncedAt'])

stats = openemail.broadcasts.stats(broadcast['id'], grain='day')
print(stats['totals']['opened'], stats['totals']['clicked'], stats['totals']['unsubscribed'])

lately = openemail.broadcasts.stats(broadcast['id'], days=1)
print(lately['window']['opened'] if lately['window'] else None)

month = openemail.broadcasts.analytics(days=30)
for row in month['broadcasts']:
    print(row['subject'], row['sent'], row['opened'])

later = openemail.broadcasts.send({**draft, 'scheduledAt': 'P1D'})
openemail.broadcasts.cancel(later['id'])

history = openemail.broadcasts.list(audience_id=draft['audienceIds'][0])
print(latest['status'], latest['counts']['sent'], len(history['items']))
```

A broadcast sends one message to everybody in one or more audiences, as a separate copy for each person. Every copy has exactly one recipient and no cc or bcc, so nobody sees who else it went to, and every copy is an ordinary email with its own `msg_` id, events, tracking and webhooks. `list_recipients` lists them with what happened to each. The copies are not filed in the Sent folder, because the broadcast is the record.

`send` returns straight away with the broadcast `queued`, or `scheduled` when you pass `scheduledAt`, and the sending runs in the background. `send` needs `emails:send` and `audiences:read`, `preview` needs `audiences:read`, `list`, `list_all`, `iterate`, `get`, `list_recipients`, `list_all_recipients`, `iterate_recipients`, `get_recipient`, `stats` and `analytics` need `emails:read`, and `cancel` needs `emails:send`.

> Every `send` carries an `Idempotency-Key`, yours through `idempotency_key=` or one the SDK makes, so a retry after a network failure answers with the broadcast the first attempt created instead of sending twice. `preview`, `get`, `cancel` and every read are safe to repeat and are retried.

- [Retries](https://openemail.uk/docs/python/retries.md): What the client repeats by itself, and how the idempotency key keeps a retry from sending twice.

## Merge fields

`subject`, `html` and `text` are filled in for each person from their contact. `{{firstName}}` is the first word of the contact name, `{{lastName}}` the rest of it, `{{name}}` the whole name, `{{email}}` the address the copy goes to and `{{unsubscribeUrl}}` the link that unsubscribes them.

Every field takes a fallback after a bar, used when the contact has no value for it, so `{{firstName|there}}` becomes "there" for a contact saved without a name. Values are escaped in `html`, and any other `{{…}}` is left exactly as written.

Pass `template` instead of `html` and `text` to send a stored template. The same five values reach it as props, but only the props the template declares, so a template that declares `firstName` gets it and one that does not is never refused for it. Anything in `template.props` goes to every copy alike.

## Unsubscribe

Every copy carries the one-click unsubscribe headers that let a mail client show its own unsubscribe button, which the large mailbox providers require of bulk mail. An `html` or `text` body that does not place `{{unsubscribeUrl}}` itself gets a one-line footer with the link. A template is sent exactly as it is, so put `{{unsubscribeUrl}}` in the template.

Unsubscribing marks the person unsubscribed in every audience that broadcast went to, and `AudienceContactResource.unsubscribedAt` shows it on `audiences.list_contacts`. They stay in the audience and in the address book, their other audiences are untouched, and mail sent to them one message at a time still goes. Taking them out of the audience and adding them again makes them subscribed afresh.

## Who is skipped

A broadcast reaches every contact in at least one of `audienceIds`, once however many of them hold it. It skips a contact that has unsubscribed from every one of those audiences it is in, and an address on the suppression list after a bounce or a complaint, or because somebody added it there. A contact added to one of the audiences after `send` but before the sending reaches it is included.

`preview` returns the same numbers without sending: `recipients`, `unsubscribed` and `suppressed`. A `send` that would reach nobody raises 422 `no_recipients`.

> The whole send is checked against the monthly sends of the plan before anything is written, so a broadcast the allowance cannot cover raises 429 `send_quota_exceeded` and leaves nothing behind. Each copy counts as one send.

## Status and progress

`get` reads `counts` live from the copies, so poll it while a broadcast sends. `status` moves from `scheduled` or `queued` to `sending` and settles on `sent` once every copy handed over has gone out or failed. It stays `sending` while copies are still waiting, even after `completedAt` says the last person was reached. `failed` means the whole broadcast stopped, and `lastError` says why: the `from` address can no longer be sent from, the template stopped resolving, the plan ran out part way, the sending itself kept failing, or not one copy could be written.

`cancel` stops a broadcast that is `scheduled`, `queued` or `sending`. Nobody else is added and every copy still waiting is cancelled, while copies that have gone cannot be recalled. Once every copy has gone, `cancel` raises 409 `broadcast_not_cancellable`, and cancelling a cancelled broadcast returns it as it stands.

## Who it reached

`list_recipients` returns one page of the people a broadcast went to, one row per copy, sorted by address, as a dict with `items`, `hasMore` and `nextCursor`. `list_all_recipients` walks every page into one list and `iterate_recipients` yields one copy at a time, fetching the next page only when the loop asks for it. `limit` goes from 1 to 200 and defaults to 50, and a `cursor` goes back with the same `filter` and `q`.

| filter | Keeps |
| --- | --- |
| `pending` | Copies still queued, scheduled or sending. |
| `sent` | Copies that went out. |
| `delivered` | Copies the receiving server accepted. |
| `opened` | Copies opened at least once. |
| `not_opened` | Copies sent and never opened. |
| `clicked` | Copies with at least one tracked click. |
| `bounced` | Copies that bounced. |
| `complained` | Copies the person reported as spam. |
| `failed` | Copies that failed or were cancelled. |
| `unsubscribed` | People who unsubscribed after the broadcast went out. |

`BROADCAST_RECIPIENT_FILTERS` names each filter, and `q` searches the address and the name, ignoring case. Opens and clicks leave out image proxies and link scanners, and stay 0 when the broadcast went out with tracking off.

`get_recipient(id, email_id)` returns one copy: the same row, plus `subject`, `html` and `text` exactly as that person received them, with the merge fields filled in and their own unsubscribe link. The HTML is from before open and click tracking was added. An `email_id` that is not a copy of this broadcast raises 404 `recipient_not_found`, and an unknown broadcast raises 404 `broadcast_not_found`.

`stats` returns the totals and a series. `totals` counts copies `sent`, `delivered`, `bounced`, `complained` and `failed`, with `pending` for the ones still waiting, and people who `opened`, `clicked` and `unsubscribed`, with `opens` and `clicks` as event counts. `series` is sparse and oldest first, one bucket per `grain` (`minute`, `hour` or `day`, defaulting to `hour`) in which something happened, cut in `offset_minutes` east of UTC. It counts each person once, at the first time it happened to them, so it adds up to the totals.

> A key limited to particular addresses or domains reaches only the broadcasts sent from an address or domain it holds. `list`, `list_all` and `iterate` leave the others out, and `get`, the recipient methods, `stats` and `cancel` raise 404 `broadcast_not_found` for them.

## Response: BroadcastResource

`get` and `cancel` each return one of these, and `send` returns a `SentBroadcastResource`, the same fields plus `replayed`, which is `True` when the answer is the broadcast an earlier call with the same idempotency key created. `list` returns a page of them, a dict with `items`, `hasMore` and `nextCursor`, newest first, and `list_all` and `iterate` walk every page. `preview` returns a `BroadcastPreviewResource` with `audienceIds`, `recipients`, `unsubscribed` and `suppressed`. `list_recipients` returns a page of `BroadcastRecipientResource` rows, `get_recipient` a `BroadcastRecipientContentResource` and `stats` a `BroadcastStatsResource`.

- `id` (str): The durable handle, `brd_` followed by 24 hex characters.
- `status` (BroadcastStatus): `scheduled`, `queued`, `sending`, `sent`, `cancelled` or `failed`. `BROADCAST_STATUSES` names each one.
- `mode` (ApiKeyMode): `live` or `test`, from the key that created it. The copies of a test broadcast are marked sent and delivered to nobody.
- `source` (EmailSource | str): Where it was started: `api` for a key, `oauth` for a connected app, `composer` for the app, `mcp` for an assistant.
- `audienceIds` (list[str]): The audiences it was sent to, each once.
- `from` (str): The address every copy is sent from.
- `subject` (str): The subject as written, merge fields and all. Empty when a template supplies the subject.
- `counts` (BroadcastCounts): `recipients` is the estimate taken at `send`. `created` counts the copies written, `skipped` the people passed over because their address was suppressed by then, and `failedToQueue` the people whose copy could not be written. `queued`, `sending`, `sent`, `failed` and `cancelled` count the copies by the state each one is in now.
- `lastError` (str | None): Why the broadcast failed, or the most recent copy that could not be written and why. `None` while nothing has gone wrong.
- `scheduledAt` (str | None): ISO-8601 UTC, when the sending is due to start. `None` for a broadcast sent straight away.
- `startedAt` (str | None): ISO-8601 UTC, when the sending reached the first people.
- `completedAt` (str | None): ISO-8601 UTC, when the last person was reached. Copies can still be waiting to go after it.
- `cancelledAt` (str | None): ISO-8601 UTC, when `cancel` stopped it.
- `createdAt` (str): ISO-8601 UTC, when `send` was called. Fixes the list order.
- `updatedAt` (str): ISO-8601 UTC, bumped as the sending moves on.

## Response: BroadcastRecipientResource

Each row of `list_recipients`, `list_all_recipients` and `iterate_recipients`. `BroadcastRecipientContentResource`, from `get_recipient`, adds `subject`, `html` and `text`.

- `emailId` (str): The `msg_` id of this person's copy. `get_recipient` reads it with its content and `emails.get` reads it as a sent email.
- `contactId` (str | None): The contact it went to, or `None` when the contact has been deleted since.
- `email` (str): The address the copy went to.
- `name` (str | None): The name on the contact.
- `status` (str): The state of the copy: `queued`, `scheduled`, `sending`, `sent`, `failed` or `cancelled`.
- `sentAt` (str | None): ISO-8601 UTC, when the copy went out.
- `deliveredAt` (str | None): ISO-8601 UTC, when the receiving server accepted it, the first `email.delivered`.
- `bouncedAt` (str | None): ISO-8601 UTC, when it bounced, the first `email.bounced`.
- `complainedAt` (str | None): ISO-8601 UTC, when the person reported it as spam, the first `email.complained`.
- `failure` (str | None): Why the copy failed, when it did.
- `opens` (int): Opens recorded, without the ones image proxies and scanners make. 0 when tracking was off.
- `firstOpenAt` (str | None): ISO-8601 UTC, the first open.
- `clicks` (int): Clicks recorded on tracked links, without scanners.
- `firstClickAt` (str | None): ISO-8601 UTC, the first click.
- `unsubscribedAt` (str | None): ISO-8601 UTC, when this person unsubscribed from one of the broadcast's audiences after it went out, through its link or otherwise.

## Reference

- [`broadcasts.preview()`](https://openemail.uk/docs/python/reference/broadcasts#preview): full reference
- [`broadcasts.send()`](https://openemail.uk/docs/python/reference/broadcasts#send): full reference
- [`broadcasts.list()`](https://openemail.uk/docs/python/reference/broadcasts#list): full reference
- [`broadcasts.list_all()`](https://openemail.uk/docs/python/reference/broadcasts#listAll): full reference
- [`broadcasts.iterate()`](https://openemail.uk/docs/python/reference/broadcasts#iterate): full reference
- [`broadcasts.get()`](https://openemail.uk/docs/python/reference/broadcasts#get): full reference
- [`broadcasts.list_recipients()`](https://openemail.uk/docs/python/reference/broadcasts#listRecipients): full reference
- [`broadcasts.list_all_recipients()`](https://openemail.uk/docs/python/reference/broadcasts#listAllRecipients): full reference
- [`broadcasts.iterate_recipients()`](https://openemail.uk/docs/python/reference/broadcasts#iterateRecipients): full reference
- [`broadcasts.get_recipient()`](https://openemail.uk/docs/python/reference/broadcasts#getRecipient): full reference
- [`broadcasts.stats()`](https://openemail.uk/docs/python/reference/broadcasts#stats): full reference
- [`broadcasts.analytics()`](https://openemail.uk/docs/python/reference/broadcasts#analytics): full reference
- [`broadcasts.cancel()`](https://openemail.uk/docs/python/reference/broadcasts#cancel): full reference
