# Broadcasts

A **broadcast** is a one-time, point-in-time bulk send to an audience. It ties together three things:

- **Audience** — a [List or a Segment](/api-reference/lists-segments). At send time the audience is **frozen to a snapshot**, so everything downstream operates on that fixed set, not the live (still-changing) audience.
- **Content** — either a pinned **template version** (the reproducibility anchor) plus merge data, **or** raw **inline HTML** you supply directly. The broadcast always owns the `subject`.
- **Data** — for template mode, the merge data each recipient renders with (see [Convention binding & defaults](#convention-binding-and-defaults)).

The Public API can **create and send** broadcasts (`api.broadcasts.manage`) as well as read them (`api.broadcasts.view`). You can compose a broadcast in two content modes, send a test to yourself, send it now or schedule it, and poll its per-recipient results. Editing, rescheduling, and cancelling an existing broadcast remain **dashboard-only**.


  Every broadcast should carry an **opt-out topic**. A broadcast with **no topic** still sends, but its per-recipient `List-Unsubscribe` link resolves to an **account-wide opt-out** (`unsubscribe_all`) — a recipient who unsubscribes leaves **every** stream you send, not just this one. That is a blunt instrument with real **compliance and sender-reputation** consequences. The dashboard requires explicitly acknowledging this before a topic-less send; the API surfaces a broadcast's `topic_id` so you can tell which sends were unmanaged. Prefer sending with a topic.


## How it maps to the dashboard

| Dashboard | API |
|---|---|
| Composing a new broadcast | [`POST /v1/broadcasts`](/api-reference/endpoints/broadcasts/create-broadcast) |
| **Send** (or schedule) a broadcast | [`POST /v1/broadcasts/{id}/send`](/api-reference/endpoints/broadcasts/send-broadcast) |
| **Send a test** to yourself | [`POST /v1/broadcasts/{id}/test`](/api-reference/endpoints/broadcasts/test-send-broadcast) |
| **Broadcasts** list | [`GET /v1/broadcasts`](/api-reference/endpoints/broadcasts/list-broadcasts) |
| A single broadcast's detail page | [`GET /v1/broadcasts/{id}`](/api-reference/endpoints/broadcasts/get-broadcast) |
| The pre-send validation / preview panel | [`POST /v1/broadcasts/{id}/preview`](/api-reference/endpoints/broadcasts/preview-broadcast) |
| The send report (who got it, who didn't, why) | [`GET /v1/broadcasts/{id}/results`](/api-reference/endpoints/broadcasts/list-broadcast-results) |
| **Delete** a broadcast | [`DELETE /v1/broadcasts/{id}`](/api-reference/endpoints/broadcasts/delete-broadcast) |

## Scopes

Two scopes gate this surface, each granted by the matching dashboard permission:

| Scope | Grants | PII |
|---|---|---|
| `api.broadcasts.view` | `/v1/broadcasts`, `/v1/broadcasts/{id}`, `/v1/broadcasts/{id}/preview`, `/v1/broadcasts/{id}/results` | **Yes** — results return recipient emails and contact ids. Grant deliberately. |
| `api.broadcasts.manage` | `POST /v1/broadcasts` (create), `POST /v1/broadcasts/{id}/send` (send or schedule), `POST /v1/broadcasts/{id}/test` (test send), `DELETE /v1/broadcasts/{id}` (delete) | No recipient PII, but it can **send mail to your audience** — grant deliberately. |

## Creating and sending a broadcast

Creation and sending are two steps: `POST /v1/broadcasts` composes a **draft** and returns its `id`; `POST /v1/broadcasts/{id}/send` dispatches it. (Keeping them separate lets you [preview](#convention-binding-and-defaults) coverage between the two.)

### Two content modes

A create request supplies **exactly one** content source — supplying both, or neither, is a `422`:

- **Template mode** — `template_id` (the template **slug** / file name from [`GET /v1/templates`](/api-reference/endpoints/templates/list-templates), e.g. `welcome.html` — not an internal UUID), with optional `template_version` (an integer; defaults to latest) and `template_data` (shared merge data, overlaid under each recipient's contact attributes). To drop a large pre-rendered HTML block into a template, expose it as a triple-stache variable (e.g. `{{{body_html}}}`) so it isn't HTML-escaped.
- **Inline mode** — raw `html` (plus optional `text`), for senders that generate fully-rendered, unique content per send. Inline HTML must satisfy the same limits as a template: **≤ 500 KB**, valid UTF-8, and at least one HTML tag.

Both modes take the audience (`audience_kind` = `list` | `segment` + `audience_id`), an optional `topic_id` (the topic **name** from [`GET /v1/topics`](/api-reference/endpoints/topics/list-topics), not a UUID), and a per-send `subject` / `preview_text`. An optional `from_identity` overrides the default `no-reply@<your-verified-domain>` sender — it must be one of your [verified identities](/api-reference/endpoints/identities/list-identities).

A `POST /v1/broadcasts` request in inline mode:

<CodeBlock lang="json" code={`{
  "name": "Product update — June",
  "html": "<!doctype html>…rendered update…",
  "text": "Plain-text alternative",
  "audience_kind": "segment",
  "audience_id": "0192bb...",
  "topic_id": "product-updates",
  "from_identity": "news@acme.example",
  "subject": "What's new in June",
  "preview_text": "New dashboards, faster sync…"
}`} />

### Sending

`POST /v1/broadcasts/{id}/send` accepts an optional body. With **no `scheduled_at`** it sends immediately; with a future RFC 3339 `scheduled_at` it schedules the send. It returns `202` with the broadcast `id` and a `status` of `sending` or `scheduled`. Poll [`GET /v1/broadcasts/{id}/results`](#results) for per-recipient outcomes.

[Consent and suppression are enforced at send time](#results) — suppressed, globally-unsubscribed, and (when the broadcast has a topic) topic-opted-out recipients are filtered before SES hand-off, regardless of how the broadcast was created. A topic-less send requires `acknowledge_no_topic: true` (otherwise the send returns `422 no_topic_acknowledgement_required` with the warnings to confirm).


  Both `POST /v1/broadcasts` and `POST /v1/broadcasts/{id}/send` honor the [`Idempotency-Key`](/api-reference/contacts#idempotent-writes) header. Reusing a key within 24 h replays the original response, so a retried request never creates a duplicate broadcast or fires a send twice.


### Test send

[`POST /v1/broadcasts/{id}/test`](/api-reference/endpoints/broadcasts/test-send-broadcast) renders the broadcast and delivers it to an explicit set of **1–10 recipients** you name, so you can eyeball the real email before committing to the audience. It runs the **exact production path** — the same `from_identity` resolution (defaulting to `no-reply@<your-verified-domain>`), template + merge, tracking links, and `List-Unsubscribe` footer the audience would receive. What you see in your inbox is what the audience gets.

A test send is **not** a scaled-down broadcast — it is a deliberate self-send, so it differs in three ways:

- **It bypasses the audience and the consent filter.** Mail goes to exactly the addresses you list, even if they are suppressed or opted out. The audience snapshot is never taken.
- **It never touches the broadcast's state or counts.** Test deliveries are recorded separately and **excluded** from [`GET /v1/broadcasts/{id}/results`](#results) and the broadcast's rollup. You can test a draft (the common case) without moving it out of `draft`.
- **`sample_data` fills the merge gaps.** The optional `sample_data` object overlays the broadcast's `default_template_data` for this render — use it to preview output for addresses that aren't contacts (for example, inject a rendered `{{{body_html}}}`). A recipient who *is* a contact still renders from their own attributes, which win over the sample.

<CodeBlock lang="json" code={`{
  "recipients": ["me@acme.example", "qa@acme.example"],
  "sample_data": { "body_html": "…rendered update…" }
}`} />

The response is `200` with one outcome per recipient, in request order — `status` of `sent` (with a `message_id`) or `failed` (with an `error`), plus `sent`/`total` counts.


  A test send delivers real email, so it is **rate-limited per org** (10/hour by default) on top of the general API limit — exhausting it returns `429 rate_limited` with a `Retry-After`. Unlike create and send, the test endpoint **accepts and ignores** an `Idempotency-Key`: test sends are meant to be repeatable, so re-issuing the same request sends again.


### Deleting a broadcast

[`DELETE /v1/broadcasts/{id}`](/api-reference/endpoints/broadcasts/delete-broadcast) permanently removes a broadcast and returns `204 No Content`. Its most common use is **cleaning up drafts you created only to test-send**: because a test send needs a persisted broadcast, iterating on subject or content tends to leave a trail of throwaway `draft` broadcasts behind. Delete them when you're done.

- It **refuses while a broadcast is `sending`** — `422 validation_failed`. Any other state (including `draft`, `scheduled`, `sent`, `partially_failed`, `cancelled`) can be deleted.
- An unknown id is `404 not_found`.
- `DELETE` is idempotent by HTTP semantics, so — unlike create and send — it takes **no `Idempotency-Key`**.



## Lifecycle & state

A broadcast moves through a small state machine: `draft → scheduled → sending → sent` (or `partially_failed`); a `draft` or `scheduled` broadcast can be `cancelled`. The two terminal outcomes are both normal:

- **`sent`** — every destination was handed to SES successfully.
- **`partially_failed`** — at least one destination failed. This is **first-class**, not an error — read the [results](#results) to see which recipients failed and why.

## The broadcast object

`recipient_count`, `sent_count`, and `failed_count` are `null` until a send finalizes. Once finalized, the `summary` object makes the audience→mailable→sent funnel explicit.



- **`audience_size`** — the frozen snapshot size (everyone the broadcast resolved to).
- **`mailable`** — those who passed the consent filter (`sent + failed`).
- **`filtered`** — those dropped before send by consent (`audience_size − mailable`). The [results](#results) explain each drop.
- **`topic_id`** — the opt-out topic **name**, or `null` for a topic-less send (see the warning above).

## Convention binding and defaults

A broadcast renders its template per recipient. Each declared template variable binds **by convention**:

- A variable whose name matches a **registry attribute** (e.g. `first_name`) resolves to that contact's value.
- Where a contact has no value, the broadcast's **`default_template_data`** supplies a fallback.
- Anything still unresolved renders blank.

`POST /v1/broadcasts/{id}/preview` computes this **over the whole audience** without sending: for every variable it reports how many recipients resolve by attribute, fall back to a default, or render blank, plus a rendered sample. It is a non-mutating dry-run — the parallel to [segment preview](/api-reference/lists-segments#preview).

## Results

`GET /v1/broadcasts/{id}/results` returns one row per recipient decision, newest-first, [cursor-paginated](/api-reference/pagination). Each row is one of:

- **`sent`** — handed to SES, with a `message_id`.
- **`failed`** — SES rejected it, with a `failure_reason`.
- **`filtered`** — dropped by the consent filter **before** send, with a `filter_tier` (`account` for suppressed / globally-unsubscribed, `topic` for opted-out of this broadcast's topic) and a `failure_reason`.

The `filtered` rows are what explain the gap between `audience_size` and `sent` — who was dropped, and why. Narrow to one outcome with `?result=sent|failed|filtered`.


  Results read the authoritative per-recipient send log directly. The `summary` counts on the broadcast object are a cache of the same log, so they can lag a still-finalizing send by moments; the results endpoint is always current.






<ApiSidePanel
  requestTabs={[
    {
      label: 'cURL',
      lang: 'bash',
      code: `# Create an inline broadcast (api.broadcasts.manage) → returns its id
curl -X POST 'https://api.sendops.dev/v1/broadcasts' \\
  -H "Authorization: Bearer sk_live_..." \\
  -H "Idempotency-Key: $(uuidgen)" \\
  -H "Content-Type: application/json" \\
  -d '{
    "name": "June product update",
    "html": "…rendered update…",
    "audience_kind": "segment",
    "audience_id": "0192bb...",
    "topic_id": "product-updates",
    "subject": "What'"'"'s new in June"
  }'

# Send it now (omit scheduled_at), or schedule it
curl -X POST 'https://api.sendops.dev/v1/broadcasts/0192f1a3-.../send' \\
  -H "Authorization: Bearer sk_live_..." \\
  -H "Idempotency-Key: $(uuidgen)"

# List the org's broadcasts (api.broadcasts.view)
curl 'https://api.sendops.dev/v1/broadcasts' \\
  -H "Authorization: Bearer sk_live_..."

# Who failed, and why?
curl 'https://api.sendops.dev/v1/broadcasts/0192f1a3-.../results?result=failed' \\
  -H "Authorization: Bearer sk_live_..."

# Delete a throwaway draft (api.broadcasts.manage)
curl -X DELETE 'https://api.sendops.dev/v1/broadcasts/0192f1a3-.../' \\
  -H "Authorization: Bearer sk_live_..."`,
    },
  ]}
  responseCode={`{
  "data": [
    {
      "contact_id": "0192dd...",
      "email": "dana@acme.example",
      "result": "failed",
      "failure_reason": "MessageRejected: Email address is not verified",
      "occurred_at": "2026-06-18T15:00:04Z"
    }
  ],
  "pagination": { "has_more": false, "next_cursor": null }
}`}
/>