Broadcasts

Search Documentation

Search across all developer documentation

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. 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).

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.

Sending without a topic degrades unsubscribe to account-wide

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

DashboardAPI
Composing a new broadcastPOST /v1/broadcasts
Send (or schedule) a broadcastPOST /v1/broadcasts/{id}/send
Send a test to yourselfPOST /v1/broadcasts/{id}/test
Broadcasts listGET /v1/broadcasts
A single broadcast’s detail pageGET /v1/broadcasts/{id}
The pre-send validation / preview panelPOST /v1/broadcasts/{id}/preview
The send report (who got it, who didn’t, why)GET /v1/broadcasts/{id}/results
Delete a broadcastDELETE /v1/broadcasts/{id}

Scopes

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

ScopeGrantsPII
api.broadcasts.view/v1/broadcasts, /v1/broadcasts/{id}, /v1/broadcasts/{id}/preview, /v1/broadcasts/{id}/resultsYes — results return recipient emails and contact ids. Grant deliberately.
api.broadcasts.managePOST /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 coverage between the two.)

Two content modes

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

  • Template modetemplate_id (the template slug / file name from GET /v1/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, 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.

A POST /v1/broadcasts request in inline mode:

{
"name": "Product update — June",
"html": "<!doctype html><html>…rendered update…</html>",
"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 for per-recipient outcomes.

Consent and suppression are enforced at send time — 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).

Idempotency-Key

Both POST /v1/broadcasts and POST /v1/broadcasts/{id}/send honor the Idempotency-Key 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 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 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.
{
"recipients": ["me@acme.example", "qa@acme.example"],
"sample_data": { "body_html": "<h1>…rendered update…</h1>" }
}

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.

Rate-limited; Idempotency-Key ignored

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} 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 sending422 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.
curl -X DELETE 'https://api.sendops.dev/v1/broadcasts/0192f1a3-.../' \
-H "Authorization: Bearer sk_live_..."

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 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.

{
"id": "0192f1a3-1c2b-7e44-9a01-7d2f6b5e4c3a",
"name": "June product update",
"template_id": "welcome.html",
"template_version": 4,
"audience_kind": "segment",
"audience_id": "0192bb...",
"subject": "What's new this month",
"preview_text": "Three things we shipped",
"topic_id": "product-updates",
"schedule_at": null,
"state": "partially_failed",
"recipient_count": 1000,
"sent_count": 940,
"failed_count": 10,
"started_at": "2026-06-18T15:00:00Z",
"completed_at": "2026-06-18T15:02:11Z",
"created_at": "2026-06-17T09:00:00Z",
"updated_at": "2026-06-18T15:02:11Z",
"summary": {
  "audience_size": 1000,
  "mailable": 950,
  "sent": 940,
  "failed": 10,
  "filtered": 50
}
}
  • 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 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.

Results

GET /v1/broadcasts/{id}/results returns one row per recipient decision, newest-first, cursor-paginated. 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 come from the send log, not the cache

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.

{
"contact_id": "0192dd...",
"email": "casey@acme.example",
"result": "filtered",
"failure_reason": "opted_out",
"filter_tier": "topic",
"occurred_at": "2026-06-18T15:00:03Z"
}