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 — one of three: a body you write, which SendOps places into a branded layout; a pinned template version (the reproducibility anchor); or raw inline HTML you supply directly. The broadcast always owns the
subject. - Data — the merge defaults (
template_data) used where a recipient’s contact attributes leave a merge field empty (see Merge fields 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 three 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
| Dashboard | API |
|---|---|
| Composing a new broadcast | POST /v1/broadcasts |
| Send (or schedule) a broadcast | POST /v1/broadcasts/{id}/send |
| Send a test to yourself | POST /v1/broadcasts/{id}/test |
| Broadcasts list | GET /v1/broadcasts |
| A single broadcast’s detail page | GET /v1/broadcasts/{id} |
| The pre-send validation / preview panel | POST /v1/broadcasts/{id}/preview |
| The send report (who got it, who didn’t, why) | GET /v1/broadcasts/{id}/results |
| Delete a broadcast | DELETE /v1/broadcasts/{id} |
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 coverage between the two.)
Three content modes
A create request supplies exactly one content source. None, or more than one, is a 422 validation_failed on field content: “Provide exactly one of template_id (a stored email template), html (a complete HTML document) or body (an email body placed into a layout).”
- Composed mode —
body(the message) and optionallylayout. SendOps places the body into a branded layout and sends the result. This is the mode for writing a new email; it is what the dashboard’s Write email produces. - Template mode —
template_id(the template slug / file name fromGET /v1/templates, e.g.welcome.html— not an internal UUID), with optionaltemplate_version(an integer; defaults to latest). Only an email template (kind: email) can be sent this way; a layout’s slug is a422. 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 optionaltext): a complete HTML document, for senders that generate fully-rendered content per send. Inline HTML must satisfy the same limits as a template: ≤ 500 KB, valid UTF-8, and at least one HTML tag.
template_data is the broadcast’s merge defaults in every mode — shared values used when a recipient has no contact attribute for a merge field (see Merge fields and defaults). The response’s content_mode is composed, template or inline.
Composed mode: body and layout
body is the inside of the email — HTML with Handlebars merge fields — not a whole document. The layout supplies the document, your logo and colours, and the slot the body goes into. Layouts are templates with kind: layout.
layout takes one of:
| Value | Layout used |
|---|---|
| a layout’s slug | That layout. List them with GET /v1/templates?kind=layout. |
builtin | The SendOps built-in layout. |
default, or omitted | The organization’s default layout. |
The layout is resolved and frozen when the broadcast is created: changing the organization’s default later does not move an existing broadcast. The broadcast returns it as layout (a slug, or builtin).
layoutwithoutbodyis a422on fieldlayout— a layout wraps a body composed here, not a stored template or a complete HTML document.- An unknown layout is a
422withvalidation_code: unknown_reference. Only layouts match: an email template’s slug is unknown here.
Body rules. The body is checked when the broadcast is created, and a refusal is a 422 validation_failed carrying a validation_code, the offending field and, for merge-field errors, a line and column:
validation_code | Cause |
|---|---|
body_too_large | The body is over 400 KB. |
body_invalid_encoding | The body is not valid UTF-8. |
body_handlebars_invalid | A merge field does not parse. Carries line and column. |
body_disallowed_markup | <html>, <head>, <body>, <script>, <iframe>, <form>, <object> or <embed>; an event-handler attribute (onclick= …); a javascript: or vbscript: URL; a data: URL anywhere but an <img src="data:image/…">; a merge field inside a tag or attribute name; or a partial ({{> name}}). field names the tag or attribute. |
composition_failed | The body and layout could not be assembled for sending. |
composed_too_large | The finished email (layout + body + footer) is over SES’s 500 KB. |
composed_invalid | The finished email fails another SES check. |
What SendOps adds. The composed email gets the unsubscribe link and your postal address in its footer — when your organization has set them in the dashboard’s Email compliance settings (an unsubscribe base URL and a postal address). Neither is added if it is not set. Do not put your own unsubscribe link in the body or layout when the base URL is set, or the email will carry two. preview_text is placed in the email as a hidden preheader.
The unsubscribe footer is composed mode only
Inline (html) broadcasts get the hidden preheader and nothing else: SendOps does not add an unsubscribe link or postal address to HTML you supply. Place {{sendops_unsubscribe_url}} in your HTML yourself. (Every mode still sends the List-Unsubscribe headers, and consent is enforced at send in every mode.)
The subject and preview text
- Composed and inline mode — merge fields in
subjectandpreview_textare filled per recipient, like the body.preview_textis sent as a hidden preheader at the top of the email. - Template mode — the subject is sent exactly as written, and
preview_textis ignored.
Behaviour change: since 2026-10-08, inline (html) broadcasts bind contact attributes
A merge field such as {{first_name}} in inline HTML, its subject or its preview_text is now filled per recipient from the contact attribute of the same name, ahead of template_data. Before this change, inline HTML was treated as fully rendered: such a field reached every recipient blank unless template_data set it.
A read-only audit of production on 2026-10-07 found no inline broadcasts (all 15 broadcasts across 5 organizations were template-based), so no broadcast created before the change behaves differently. The change applies to inline broadcasts created after it.
The plain-text part
Every email a broadcast sends is multipart/alternative with both an HTML and a plain-text body, which is what spam filters and text-only clients expect. Where the text comes from depends on the mode:
- Template mode — SendOps generates the text part from the template’s HTML when it deploys the template to SES, so every template-mode send (and every workflow send) carries one automatically. Handlebars expressions are kept verbatim and rendered per recipient exactly as in the HTML part; links become
label (url);<head>, styles, scripts and hidden preheaders are dropped. There is nothing to supply on the broadcast.POST /v1/templates/renderreturns the same generated text astext, so you can check what a text-only client will see. - Composed mode — SendOps generates the text part from the composed email the same way.
- Inline mode — the text part is only what you pass in
text. It is not generated from yourhtml; omittextand the message goes out HTML-only.
Every mode takes the audience (audience_kind = list | segment + audience_id, given by the List’s or Segment’s key or its UUID), 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.
Sender name
Every broadcast is delivered with a display name, never a bare address — a bare from_identity renders in most inboxes as its local part (no-reply). Two fields control it:
from_name— the name recipients see, e.g.Acme. Optional; at most 128 characters, no line breaks.- If
from_nameis omitted, an explicitfrom_identityis labelled with your organization’s name (the same label the default sender carries).
from_identity also accepts an RFC 5322 formatted address (Acme <news@acme.example>). It is split on the way in: the address is validated as usual and the display name becomes from_name unless you supplied from_name separately, which wins. The stored from_identity is always the bare address, and the broadcast resource returns from_name (or null when your organization’s name is used).
A POST /v1/broadcasts request in composed mode, with a fallback for contacts who have no first name:
{
"name": "Product update — June",
"body": "<h1>Hi {{#if first_name}}{{first_name}}{{else}}there{{/if}},</h1><p>Here's what's new on your {{plan}} plan.</p>",
"layout": "brand",
"template_data": { "plan": "Free" },
"audience_kind": "segment",
"audience_id": "gold-tier",
"topic_id": "product-updates",
"from_identity": "news@acme.example",
"from_name": "Acme",
"subject": "What's new in June",
"preview_text": "New dashboards, faster sync…"
} The same broadcast in inline mode, with your own fully-rendered HTML:
{
"name": "Product update — June",
"html": "<!doctype html><html>…rendered update… <a href=\"{{sendops_unsubscribe_url}}\">Unsubscribe</a></html>",
"text": "Plain-text alternative",
"audience_kind": "segment",
"audience_id": "gold-tier",
"topic_id": "product-updates",
"from_identity": "news@acme.example",
"from_name": "Acme",
"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 sender resolution (from_identity + from_name, defaulting to no-reply@<your-verified-domain> labelled with your organization’s name), template or layout + merge, tracking links, and List-Unsubscribe header 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}/resultsand the broadcast’s rollup. You can test a draft (the common case) without moving it out ofdraft. A composed broadcast is sent as its body inside its layout, composed exactly as the real send would. sample_datafills the merge gaps. The optionalsample_dataobject overlays the broadcast’sdefault_template_datafor 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
sending—422 validation_failed. Any other state (includingdraft,scheduled,sent,partially_failed,cancelled) can be deleted. - An unknown id is
404 not_found. DELETEis idempotent by HTTP semantics, so — unlike create and send — it takes noIdempotency-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",
"content_mode": "template",
"template_id": "welcome.html",
"layout": null,
"template_version": 4,
"audience_kind": "segment",
"audience_id": "gold-tier",
"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
}
} content_mode—composed(abodyplaced intolayout),templateorinline. Neitherbodynorhtmlis echoed back.layout— composed mode only: the layout’s slug, orbuiltin. It is the layout resolved at create, not a live pointer to the org’s default.nullin the other modes, and when the layout has since been deleted.template_id— template mode only;nullotherwise.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, ornullfor a topic-less send (see the warning above).
Convention binding and defaults
A broadcast fills its merge fields per recipient, in every content mode: in the body, layout or template, and — in composed and inline mode — in the subject and preview text. Each field binds by convention, and the first of these that has a value wins:
- The contact’s attribute of the same name (e.g.
first_name), when the field matches a registry attribute and the contact has a non-empty value. - The broadcast’s merge default —
template_dataon create, returned asdefault_template_data. - The field’s own fallback, written as
{{#if first_name}}{{first_name}}{{else}}there{{/if}}. - Otherwise the field renders blank.
Set a default or a fallback, not both
A merge default is filled in before the fallback is evaluated, so it makes the field look set and the fallback never shows. For any one field, give it a template_data default or an {{else}} fallback — not both.
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. For a composed broadcast the sample is the body inside its layout, exactly as the send composes it.
POST /v1/broadcasts/{id}/rehearse also checks a composed broadcast’s content: when its body and layout do not compose, content_error carries the refusal the send itself would fail with — code (for example body_disallowed_markup or layout_missing_slot), message, field, line and column. template_ready is false until it composes.
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 amessage_id.failed— SES rejected it, with afailure_reason.filtered— dropped by the consent filter before send, with afilter_tier(accountfor suppressed / globally-unsubscribed,topicfor opted-out of this broadcast’s topic) and afailure_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"
}