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 RadarSend
- 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.
A draft with raw content.body_mjml (no template) is compiled on the server
at save too. If you send body_mjml without body_html, the compiled HTML
is stored as body_html. MJML containing an element the compiler doesn’t
recognize is rejected with 400 MJML_UNKNOWN_ELEMENT, in raw or template
mode and in variant content_overrides: the compiler would drop that element
and everything inside it, so the email would send without that content. The
message names the element and its line. The usual cause is MJML 3’s
<mj-container>: delete its opening and closing tags and keep the sections
inside them.
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.seed_mode—none(default) orinclude. Withinclude, the seed lists attached to the broadcast’s senders ride along: each seed mailbox gets one copy, placed in the send like a contact. Seeds are not recipients. They aren’t in the audience count, the recipient list or any report. See Seed lists. It can be changed while the broadcast is a draft, and it is returned on every broadcast read.
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.