Broadcasts
One email to every contact in a segment, personalised per recipient.
This is a separate path from POST /v1/send on purpose: a single send builds one message addressed to all its recipients, so it cannot carry a per-recipient body or unsubscribe link. A broadcast fans out into one message and one send job per recipient.
A broadcast of N recipients costs N messages of quota
That is correct, and it is the whole reason /v1/send was left alone.
The broadcast object
{
"id": "…",
"name": "March update",
"segment_id": "…",
"template_id": "…",
"subject": "What shipped in March",
"sender_email": "hello@example.com",
"sender_alias": "Example",
"status": "sent",
"recipient_count": 1204,
"queued_count": 1204,
"skipped_count": 0,
"last_error": null,
"created_at": "…",
"updated_at": "…",
"started_at": "…",
"finished_at": "…",
"stats": { "total": 1204, "delivered": 1187, "failed": 9, "pending": 8 }
}Two sets of numbers, and they mean different things
| Describes | |
|---|---|
recipient_count, queued_count, skipped_count | What the fan-out did |
stats | What became of the messages it wrote |
recipient_count is the size of the audience when the send started; queued_count how many of them actually got a message; skipped_count the difference — normally an opt-out, or the monthly message quota running out partway.
stats.pending covers both queued and processing, so the three outcome buckets always add up to stats.total. Every broadcast carries stats, including a draft, where it is all zeros.
Create
POST /v1/broadcastscurl -X POST http://api.localhost/v1/broadcasts \
-H "X-API-Key: $NOTIFYZR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "March update",
"segment_id": "…",
"template_id": "…",
"subject": "What shipped in March",
"sender_email": "hello@example.com",
"sender_alias": "Example"
}'| Field | Required | Notes |
|---|---|---|
name | yes | Up to 200 characters. |
segment_id | yes | The audience. |
template_id | yes | Must be published — a draft is refused. |
subject | yes | |
sender_email | yes | Must be on a verified domain. |
sender_alias | no | Display name. |
Created as a draft. Nothing is sent until you call /send.
Send
POST /v1/broadcasts/{id}/sendReturns as soon as the fan-out is queued. Nothing is mailed synchronously.
draft ──▶ queued ──▶ sending ──▶ sent
└▶ failedThere is no way back to draft, and there is deliberately no paused status — stopping midway leaves an audience half-mailed.
Things that will surprise you otherwise
The audience is resolved when the worker runs, not when you call /send. A segment is a live filter, and freezing 50,000 addresses into a queue message would be both enormous and already stale by the time it ran.
Suppressed addresses are excluded during fan-out, in the query that pages through the audience — so a mostly-opted-out segment does not produce runs of near-empty batches.
Quota is re-checked per recipient. A long fan-out can cross the ceiling partway through. That is not a failure: what was sent was sent, the rest are counted in skipped_count, and the broadcast completes.
A failed fan-out is not retried. Retrying after a partial fan-out would re-mail everyone it had already reached, so the reason is recorded in last_error for an operator instead.
Edit and delete are draft-only — 422 otherwise. Once queued, the content is partly out the door, and a sent broadcast's messages would be orphaned by a delete.
Reading the results
curl http://api.localhost/v1/broadcasts/$ID -H "X-API-Key: $KEY"
# the individual messages
curl "http://api.localhost/v1/messages?broadcast_id=$ID" -H "X-API-Key: $KEY"Emits broadcast.sent when the fan-out completes.