Broadcasts

One message to an audience, with per-recipient results and a working opt-out on every copy.

A broadcast is one message sent to every mailable member of an audience, optionally narrowed by a segment. It is the counterpart to POST /v1/emails: that endpoint sends one email because one person did one thing, and this one sends the same message to a list on purpose.

A broadcast is a draft first and a send second, and the two are separate calls. That is deliberate: creating one is cheap and reversible, sending one is neither. Between the two you can preview it, test it to yourself, and read back the list of problems that stop it from going.

Create a draft

from is the only required field. Everything else can be filled in later with PATCH, and the response tells you what is still missing rather than refusing until it is complete.

POST /v1/broadcasts
curl -X POST https://emails.sh/v1/broadcasts \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "July changelog",
    "from": "Acme <hello@acme.com>",
    "reply_to": "support@acme.com",
    "audience_id": "2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33",
    "topic_id": "8c40e6b2-95a1-4f3d-b708-1e5d72c4a069",
    "subject": "What changed in July",
    "html": "<p>Hello {{ first_name }}, three new things this month.</p>",
    "text": "Hello {{ first_name }}, three new things this month.",
    "track_opens": true,
    "track_clicks": true
  }'
201 Created
{
  "id": "d1f6c48a-2b07-4e93-85ca-7f30b9d16e52",
  "name": "July changelog",
  "status": "draft",
  "audience_id": "2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33",
  "segment_id": null,
  "topic_id": "8c40e6b2-95a1-4f3d-b708-1e5d72c4a069",
  "from": "Acme <hello@acme.com>",
  "reply_to": "support@acme.com",
  "subject": "What changed in July",
  "track_opens": true,
  "track_clicks": true,
  "scheduled_at": null,
  "sent_at": null,
  "failure_reason": null,
  "created_at": "2026-07-28T09:14:01.882Z",
  "stats": null,
  "ready": true,
  "problems": []
}

ready and problems are the pair worth reading. problems is a list of sentences, and it is empty exactly when ready is true. There are four of them and they are the only reasons a broadcast will not send.

ProblemWhat to do
a broadcast needs an audience to send toSet audience_id. A segment on its own is not a recipient list.
a broadcast needs a from address on a verified domainVerify the domain at /docs/domains. The sandbox sender is refused here with 422 sandbox_not_allowed_for_broadcasts, because a shared address must not carry a list send.
a broadcast needs a subjectSet subject.
a broadcast needs an html body, a text body, or a templateSet html, text, or template_id.

Merge fields

The body takes {{ name }} substitution, and the values come from the attributes on each audience membership. Those are per-list merge fields, and they are a different store from contact attributes: /docs/contact-data is the chapter about the difference. The syntax is the same one templates use, with no conditionals and no loops.

Preview tells you which fields the audience actually supplies before you find out the hard way. supplied is false for a field that appears in the body and is missing from the sample of members it checked.

Render it for one real member
curl "https://emails.sh/v1/broadcasts/d1f6c48a-2b07-4e93-85ca-7f30b9d16e52/preview?email=ada@example.com" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
200 OK
{
  "id": "d1f6c48a-2b07-4e93-85ca-7f30b9d16e52",
  "rendered_for": "ada@example.com",
  "from": "Acme <hello@acme.com>",
  "reply_to": "support@acme.com",
  "subject": "What changed in July",
  "html": "<p>Hello Ada, three new things this month.</p>",
  "text": "Hello Ada, three new things this month.",
  "headers": { "List-Unsubscribe": "<https://emails.sh/p/unsub/8c40e6b2>" },
  "merge_fields": [
    { "field": "first_name", "supplied": true },
    { "field": "plan", "supplied": false }
  ],
  "audience_sample_size": 200
}

Preview before you save anything

A body still being written does not need a draft behind it. POST /v1/broadcasts/preview takes the content in the request and renders it through the same function the send uses, so what you see is what would go out. Nothing is written, nothing is sent, and id comes back null because there is no broadcast to fetch later.

Pass audience_id to render against a real member of that audience, and email to pick which one. Leave audience_id out and the recipient is the placeholder someone@example.com with no attributes, so every merge field comes back supplied: false. That is the honest answer: nothing was checked against a real list.

Render a body that has never been saved
# The key is the one from https://emails.sh/dashboard/keys, or from
# POST /v1/api-keys with a key you already have.
export EMAILSSH_API_KEY="esh_live_your_key"

curl https://emails.sh/v1/broadcasts/preview \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"subject":"What changed in {{ month }}","html":"<p>Hello {{ first_name }}, three new things.</p>","from":"Acme <hello@acme.com>"}'
200 OK, with no audience: every merge field renders empty
{
  "id": null,
  "rendered_for": "someone@example.com",
  "from": "Acme <hello@acme.com>",
  "reply_to": null,
  "subject": "What changed in ",
  "html": "<p>Hello , three new things.</p>",
  "text": "Hello , three new things.",
  "headers": { "List-Unsubscribe": "<https://emails.sh/p/unsub/preview>" },
  "merge_fields": [
    { "field": "month", "supplied": false },
    { "field": "first_name", "supplied": false }
  ],
  "audience_sample_size": 0
}

audience_sample_size is 0 exactly when the placeholder was used. Add "audience_id":"<audience-id>" to the same request and both numbers change: the render is done against a real member and supplied starts telling you something.

Test it to yourself

Up to 5 of your own addresses, rendered the way a recipient would get it. It does not move the broadcast out of draft and it does not count anybody as sent.

POST /v1/broadcasts/:id/test
curl -X POST https://emails.sh/v1/broadcasts/d1f6c48a-2b07-4e93-85ca-7f30b9d16e52/test \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": ["you@acme.com", "colleague@acme.com"]}'

Send it

With no body it goes now. With scheduled_at it is booked, and the timestamp must be strictly in the future and no more than 30 days out.

POST /v1/broadcasts/:id/send
# Now
curl -X POST https://emails.sh/v1/broadcasts/d1f6c48a-2b07-4e93-85ca-7f30b9d16e52/send \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"

# Or at a time you pick
curl -X POST https://emails.sh/v1/broadcasts/d1f6c48a-2b07-4e93-85ca-7f30b9d16e52/send \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"scheduled_at": "2026-08-01T09:00:00Z"}'
Sending now
{
  "id": "d1f6c48a-2b07-4e93-85ca-7f30b9d16e52",
  "status": "sending",
  "queued": 1842,
  "batches": 19,
  "skipped": 37,
  "recipients": 1879
}

skipped is the number that deserves a look. Those are members the send refused to touch: suppressed addresses, people who opted out of the topic, and anybody not in the subscribed state. Each one has a reason on its recipient row.

RefusalWhat happened
422 broadcast_incompleteproblems is not empty. Read it and fix what it names.
409 broadcast_already_sendingA send is already in flight. This is what stops a double-clicked button becoming two sends.
409 broadcast_not_schedulableIt is past draft, so it cannot be booked for later.
409 broadcast_not_editablePATCH on something that is no longer a draft.
409 broadcast_not_cancellableIt has already finished sending.
422 sandbox_not_allowed_for_broadcastsfrom resolved to onboarding@emails.sh. Verify a domain first.

Statuses

StatusWhat it means
draftEditable. Nothing has been sent and nothing is booked.
scheduledBooked for scheduled_at. Cancellable.
sendingIn flight, going out in batches.
sentEvery batch has been handed off. Delivery events keep arriving afterwards.
cancelledCalled off. Anything already handed to the mail servers has gone.
failedThe send itself broke. failure_reason says how.

What happened to each person

GET /v1/broadcasts/:id/recipients is the per-recipient log, filterable by status, and it is where "did Ada get it" is answered. The stats it returns alongside carry rates as well as counts.

Everyone it bounced for
curl "https://emails.sh/v1/broadcasts/d1f6c48a-2b07-4e93-85ca-7f30b9d16e52/recipients?status=bounced&limit=100" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
200 OK
{
  "stats": {
    "recipients": 1879,
    "sent": 1842,
    "delivered": 1801,
    "bounced": 28,
    "complained": 2,
    "failed": 13,
    "skipped": 37,
    "unique_opens": 604,
    "unique_clicks": 121,
    "total_opens": 903,
    "total_clicks": 168,
    "unsubscribed": 9,
    "rates": { "delivered": 0.978, "bounced": 0.015, "complained": 0.001 }
  },
  "recipients": [
    {
      "id": "0b7e5a41-9c62-4d38-a015-3e8f6b204c79",
      "email": "grace@example.com",
      "status": "bounced",
      "reason": "smtp; 550 5.1.1 Recipient address rejected: User unknown",
      "message_id": "f3a91c07-4e28-4b6d-9c15-8d02a7e5b431",
      "sent_at": "2026-07-28T09:14:12.004Z",
      "opened_at": null,
      "clicked_at": null
    }
  ]
}

File it under a topic

Set topic_id on anything that is not strictly transactional. It gives every copy a one-click List-Unsubscribe for that topic alone, it adds the footer line pointing at the preference centre, and it refuses to send to anybody who already opted out. Without it a recipient who wants to stop hearing from you has one available button, and it is the spam button. See /docs/topics.

GET /v1/broadcasts

Broadcasts, newest first. limit defaults to 50 and tops out at 100.

POST /v1/broadcasts

Create a draft. from is the one required field.

GET /v1/broadcasts/:id

One broadcast with its body, its stats, and its problems[].

PATCH /v1/broadcasts/:id

Edit a draft. from is not patchable, and a broadcast past draft answers 409.

DELETE /v1/broadcasts/:id

Cancel it.

POST /v1/broadcasts/:id/cancel

The same cancel, as a POST.

POST /v1/broadcasts/:id/send

{ scheduled_at? }. Without it, it goes now.

POST /v1/broadcasts/:id/test

{ to } sends it to up to 5 addresses of yours.

GET /v1/broadcasts/:id/preview

?email= renders it without sending, and lists the merge fields.

POST /v1/broadcasts/preview

The same render for a body you have not saved. Nothing is written.

GET /v1/broadcasts/:id/recipients

?status=&limit=&offset= over per-recipient results.

broadcasts.create

Create a broadcast as a draft. The response carries ready and problems[], so you can tell whether it can send yet without trying.

Arguments

from string required
Sender, as an address or as "Name <address>". The domain must be verified on this workspace, or be onboarding@emails.sh while you are testing.
audience_id string
Who it goes to. Required before it can send.
segment_id string
Narrow the audience to the members a segment matches.
topic_id string
The topic the send is filed under, which is what gives it a working one-click opt-out.
subject string
name string
What it is called in the dashboard. Not seen by a recipient.
html string
text string
template_id string
Use a stored template instead of an inline body.
template_version_id string
reply_to string
track_opens boolean
track_clicks boolean

Returns{ id, status: "draft", ready, problems[] }

broadcasts.send

Send a draft, or book it. Sending now answers with the recipient count and how many were skipped; booking answers with the time it will go.

Arguments

id string required
scheduled_at string
RFC 3339, strictly in the future and at most 30 days out. Omit to send now.

Returns{ id, status, queued, batches, skipped, recipients } or { id, status, scheduled_at }

broadcasts.test

Send the broadcast to yourself first, rendered exactly as a recipient would get it. It does not change the draft status.

Arguments

id string required
to string | string[] required
Up to 5 addresses.

Returns{ sent, skipped }

broadcasts.preview

The rendered subject and body without sending anything, plus merge_fields saying which fields the audience actually supplies and which are missing.

Arguments

id string required
email string
Render for one member of the audience rather than for a sample.

Returns{ subject, html, text, merge_fields[], audience_sample_size }

broadcasts.previewContent

The same render as broadcasts.preview, for a body you have not saved. Nothing is written and nothing is sent, so id comes back null.

Arguments

subject string required
html string
One of html or text is required.
text string
from string
Shown back on the preview. It is not resolved against your domains here.
replyTo string
audienceId string
Render against a real member of this audience. Leave it out and the recipient is a placeholder with no attributes.
email string
Which member of audienceId to render for.

Returns{ id: null, subject, html, text, headers, merge_fields[], audience_sample_size }

broadcasts.recipients

Per-recipient results for one broadcast, with the reason a skipped or failed row did not go.

Arguments

id string required
status string
pending, sent, delivered, bounced, complained, failed, or skipped.
limit number
Defaults to 100, maximum 500.
offset number

Returns{ stats, recipients: Recipient[] }

broadcasts.cancel

Call off a scheduled or sending broadcast. Messages already handed to the mail servers have gone.

Arguments

id string required

Returns{ id, status: "cancelled" }