Broadcasts
The external broadcast API is designed for content-first draft creation.
Typical flow:
- discover reference data
- create a draft
- optionally review and complete the draft in SonarSend
- schedule the draft when it is ready
Supported broadcast shapes#
The current public API supports:
- single broadcast drafts (inline
body_html/body_mjml/body_text) - single broadcast drafts authored via a template binding (
template_id+template_binding) — seetemplate-mode.mdfor the full flow including binding discovery, validation, and preview rendering - simple A/B drafts with content-only variants
It does not support:
- holdout/winner split-test workflows
- per-variant MTA overrides
- per-variant sending-identity overrides
When a draft is created with template_id + template_binding, the
server runs Phase 0 substitution at save time and persists the resolved
body_html / body_mjml / body_text on broadcasts.content. If the
post-binding MJML compiles to empty html, the request is rejected with
400 MJML_COMPILE_FAILED. Templates pass MJML compile validation at
create/update time (MJML_INVALID), so this code typically signals a
slot binding pulled in a block whose MJML breaks the parent’s structure.
Create a draft#
POST /api/broadcasts
Required scope:
broadcasts:write
Supported reference fields#
email_type_keymta_config_keyinclude_segment_keysexclude_segment_keyssending_identity_emailscontent.template_slug(template-mode — resolves to the template by slug)
Additional fields#
tags— string array for categorizing broadcastsmetadata— free-form key-value object for workflow context (e.g., post IDs, campaign sources). See metadata rules below.
Retrying safely#
Send an Idempotency-Key header and a replay returns the original 201 body
with idempotency-replay: true instead of creating a second broadcast. The key
must match ^[A-Za-z0-9_-]{8,128}$ and is remembered for 24 hours. A key that
does not match the pattern is ignored silently — you get no protection and no
error — so generate one that fits.
Single-draft example#
{
"name": "April newsletter",
"email_type_key": "prospect_newsletter",
"mta_config_key": "primary-mail",
"include_segment_keys": ["vip-list"],
"exclude_segment_keys": ["recent-unsubscribers"],
"sending_identity_emails": ["ops@example.com"],
"tracking_enabled": true,
"content": {
"subject": "April updates",
"body_html": "<html><body><h1>Hello</h1></body></html>",
"body_text": "Hello"
}
}
Simple A/B example#
{
"name": "April newsletter subject test",
"email_type_key": "prospect_newsletter",
"mta_config_key": "primary-mail",
"include_segment_keys": ["vip-list"],
"sending_identity_emails": ["ops@example.com"],
"content": {
"subject": "Fallback subject",
"body_html": "<html><body>Fallback</body></html>",
"body_text": "Fallback"
},
"variants": [
{
"id": "a",
"name": "Variant A",
"weight": 50,
"content_overrides": {
"subject": "April updates",
"body_html": "<html><body>A</body></html>",
"body_text": "A"
}
},
{
"id": "b",
"name": "Variant B",
"weight": 50,
"content_overrides": {
"subject": "What’s new this month",
"body_html": "<html><body>B</body></html>",
"body_text": "B"
}
}
]
}
Read broadcasts#
List broadcasts#
GET /api/broadcasts
Required scope:
broadcasts:read
Query filters#
| Parameter | Type | Description |
|---|---|---|
status | string | Comma-separated statuses: draft, scheduled, sending, paused, completed, failed, cancelled. Any other value rejects the whole request with INVALID_STATUS. |
tags | string | Comma-separated tag names. Returns broadcasts whose tags overlap with any given value. |
metadata | string | JSON object for containment filtering. Returns broadcasts whose metadata contains all specified key-value pairs. |
since | string | ISO 8601 timestamp. Returns broadcasts created on or after this date. |
before | string | ISO 8601 timestamp. Returns broadcasts created before this date. |
full | string | true to return full content bodies. Default is lean (see below). |
archived | string | true includes archived broadcasts alongside live ones; only returns archived ones alone. Omit to exclude them — false is not an accepted value. |
The list is not paginated. It returns every match in one response, with
next_cursor: null and has_more: false, so narrow it with the filters above
rather than paging.
Example — find completed broadcasts containing a specific post ID from the last 6 months:
GET /api/broadcasts?status=completed&metadata={"post_ids":[4729]}&since=2025-10-29
Lean response (default)#
The list endpoint returns a lean shape by default. Content drops the heavy body fields (body_html, body_mjml, body_text) and template_snapshot, replacing them with boolean flags. Keeps template_slug and template_binding so callers know which template was used and what was bound.
{
"id": "33333333-3333-4333-8333-333333333333",
"name": "April newsletter",
"status": "draft",
"tags": ["newsletter", "subscriber"],
"metadata": { "newsletter_type": "subscriber", "post_ids": [4729, 5102] },
"email_type_key": "prospect_newsletter",
"mta_config_key": "primary-mail",
"include_segment_keys": ["vip-list"],
"exclude_segment_keys": [],
"sending_identity_emails": ["ops@example.com"],
"content": {
"subject": "April updates",
"template_slug": "dog-ear-weekly",
"template_binding": { "parameters": {}, "sections": {}, "slots": {} },
"has_body_html": true,
"has_body_mjml": true,
"has_body_text": true,
"has_template_snapshot": true
}
}
Pass ?full=true for the complete payload including rendered bodies and template snapshot.
Full response fields#
For API-key callers, all dependency references are returned in their public form:
email_type_key(instead ofemail_type_id)mta_config_key(instead ofmta_config_id)include_segment_keys(instead ofinclude_segment_ids)exclude_segment_keys(instead ofexclude_segment_ids)sending_identity_emails(instead ofsending_identity_ids)content.template_slug(instead ofcontent.template_id)
Get one broadcast#
GET /api/broadcasts/:broadcast_id
Required scope:
broadcasts:read
Returns the full broadcast payload (not lean). Broadcast URLs use the broadcast’s canonical ID.
Update a draft#
PUT /api/broadcasts/:broadcast_id
Required scope:
broadcasts:write
Drafts only. Once a broadcast has been scheduled the row is frozen and this
returns 409 NOT_DRAFT. Unschedule it first if you need to edit it.
You can continue using the same public reference fields on update:
email_type_keymta_config_keyinclude_segment_keysexclude_segment_keyssending_identity_emails
Fields you omit are left alone; fields you send REPLACE what is stored. tags
and metadata are overwritten wholesale, not merged — unlike the contact upsert,
which unions tags.
Delete a draft#
DELETE /api/broadcasts/:broadcast_id
Required scope:
broadcasts:write
Only draft broadcasts can be deleted, and the delete is permanent — there is no
undelete. Anything that has been scheduled or sent returns 409 NOT_DRAFT so its
record survives for reporting; archive those instead.
Schedule a broadcast#
POST /api/broadcasts/:broadcast_id/schedule
Required scope:
broadcasts:schedule
The draft must already be complete enough to schedule successfully.
Typical schedule-time requirements:
- at least one included audience segment
- valid email type
- valid MTA config
- valid content
- resolvable sending identities or delivery pool
GET /api/broadcasts/{id} reports the same guards up front in
view_model.schedule_readiness, so you can find out what is missing without
provoking an error.
Set scheduled_at before you schedule. This call does not supply a default,
and the dispatcher only picks up broadcasts whose scheduled_at has passed — a
broadcast scheduled with a null scheduled_at sits in scheduled indefinitely.
Test send#
POST /api/broadcasts/:broadcast_id/test-send
Required scope:
broadcasts:schedule
This sends real mail through the real send path, which is why it needs the same
grant as scheduling rather than broadcasts:write. It publishes no events and
creates no recipient rows, so it never shows up in the broadcast’s report.
A proof for a human to read. Takes 1–10 recipients. Every variant is sent to
every address from every sending identity — five addresses across three
variants and two senders is thirty emails. Subjects are prefixed
[TEST: <variant> · <sender>] so the copies are distinguishable in one inbox.
Metadata#
Free-form key-value pairs for external workflow context. Stored as JSONB.
- Keys must be lowercase snake_case (
/^[a-z][a-z0-9_]{0,63}$/) - Values: strings (max 256 chars), numbers, booleans, or arrays of those (max 50 items)
- Maximum 20 keys, 8KB serialized
- No nested objects
{
"metadata": {
"newsletter_type": "subscriber",
"post_ids": [4729, 5102, 5387],
"send_date": "2026-04-29"
}
}
Use the metadata query filter on the list endpoint to search by containment:
GET /api/broadcasts?metadata={"post_ids":[4729]}
Unsupported advanced split-test fields#
These are rejected in the external API:
split_test_config- variant
sending_identity_id - variant
mta_config_id
If sent, expect a 400 response such as:
{
"error": {
"code": "ADVANCED_SPLIT_TEST_UNSUPPORTED",
"message": "Advanced split-test workflows are not supported in the external broadcast API"
}
}
Rescheduling and archiving#
POST /api/broadcasts/{id}/reschedule # edit the calendar of a RUNNING spread send
POST /api/broadcasts/{id}/unschedule # return a scheduled broadcast to draft
POST /api/broadcasts/{id}/pause # stop a running send
POST /api/broadcasts/{id}/resume # restart a paused send
POST /api/broadcasts/{id}/cancel # stop a send for good
POST /api/broadcasts/{id}/archive # hide a finished broadcast
POST /api/broadcasts/{id}/unarchive # restore an archived broadcast
POST /api/broadcasts/bulk-archive # archive many at once
reschedule, unschedule, pause, resume and cancel need
broadcasts:schedule — the same grant as scheduling, because they change when
(or whether) mail goes out. archive, unarchive and bulk-archive need
broadcasts:write; archiving is housekeeping, not a send decision.
Each of these is legal only from certain statuses, and calling one from the
wrong status returns 409 INVALID_TRANSITION:
| Call | Legal from |
|---|---|
schedule | draft |
unschedule | scheduled |
pause | sending |
resume | paused |
cancel | scheduled, sending, paused |
reschedule | sending, and only with a spread plan |
archive | completed, failed, cancelled |
reschedule is not “change the send time”. It takes only skipped_dates,
and it applies only to a broadcast that is actively sending on a multi-day
spread plan — any other status is 409 NOT_SENDING, and a send with no spread
plan is 409 NO_SPREAD_PLAN. skipped_dates is the complete replacement list,
not a delta, and only future days can be newly skipped (400 INVALID_SKIP
otherwise). To move a send that has not started, unschedule it, set
scheduled_at, and schedule it again.
unschedule does not stop a live send. It is legal from scheduled only;
a broadcast that has started sending returns 409 INVALID_TRANSITION. cancel
is the call for stopping a send in flight — it drops every spread batch that has
not fired, though mail the provider has already accepted cannot be recalled.
bulk-archive takes { "ids": [...] }, up to 100. It reports partial success as
a 200 with { "archived": [...], "skipped": [...] } and gives no reason for a
skip, so read the arrays rather than the status code.