# 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:
```bash
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:
```json
{
  "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.

| Problem | What to do |
| --- | --- |
| a broadcast needs an audience to send to | Set audience_id. A segment on its own is not a recipient list. |
| a broadcast needs a from address on a verified domain | Verify 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 subject | Set subject. |
| a broadcast needs an html body, a text body, or a template | Set 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:
```bash
curl "https://emails.sh/v1/broadcasts/d1f6c48a-2b07-4e93-85ca-7f30b9d16e52/preview?email=ada@example.com" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

200 OK:
```json
{
  "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:
```bash
# 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:
```json
{
  "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:
```bash
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:
```bash
# 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:
```json
{
  "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.

| Refusal | What happened |
| --- | --- |
| 422 broadcast_incomplete | problems is not empty. Read it and fix what it names. |
| 409 broadcast_already_sending | A send is already in flight. This is what stops a double-clicked button becoming two sends. |
| 409 broadcast_not_schedulable | It is past draft, so it cannot be booked for later. |
| 409 broadcast_not_editable | PATCH on something that is no longer a draft. |
| 409 broadcast_not_cancellable | It has already finished sending. |
| 422 sandbox_not_allowed_for_broadcasts | from resolved to onboarding@emails.sh. Verify a domain first. |

### Statuses

| Status | What it means |
| --- | --- |
| draft | Editable. Nothing has been sent and nothing is booked. |
| scheduled | Booked for scheduled_at. Cancellable. |
| sending | In flight, going out in batches. |
| sent | Every batch has been handed off. Delivery events keep arriving afterwards. |
| cancelled | Called off. Anything already handed to the mail servers has gone. |
| failed | The 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:
```bash
curl "https://emails.sh/v1/broadcasts/d1f6c48a-2b07-4e93-85ca-7f30b9d16e52/recipients?status=bounced&limit=100" \
  -H "Authorization: Bearer $EMAILSSH_API_KEY"
```

200 OK:
```json
{
  "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`

`{ from: string, audience_id?: string, segment_id?: string, topic_id?: string, subject?: string, name?: string, html?: string, text?: string, template_id?: string, template_version_id?: string, reply_to?: string, track_opens?: boolean, track_clicks?: boolean }`

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

| Parameter | Type | Required |
| --- | --- | --- |
| from | `string` | yes |
| audience_id | `string` | no |
| segment_id | `string` | no |
| topic_id | `string` | no |
| subject | `string` | no |
| name | `string` | no |
| html | `string` | no |
| text | `string` | no |
| template_id | `string` | no |
| template_version_id | `string` | no |
| reply_to | `string` | no |
| track_opens | `boolean` | no |
| track_clicks | `boolean` | no |

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

#### `broadcasts.send`

`{ id: string, scheduled_at?: string }`

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.

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| scheduled_at | `string` | no |

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

#### `broadcasts.test`

`{ id: string, to: string | string[] }`

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

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| to | `string | string[]` | yes |

Returns: { sent, skipped }

#### `broadcasts.preview`

`{ id: string, email?: string }`

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

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| email | `string` | no |

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

#### `broadcasts.previewContent`

`{ subject: string, html?: string, text?: string, from?: string, replyTo?: string, audienceId?: string, email?: string }`

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.

| Parameter | Type | Required |
| --- | --- | --- |
| subject | `string` | yes |
| html | `string` | no |
| text | `string` | no |
| from | `string` | no |
| replyTo | `string` | no |
| audienceId | `string` | no |
| email | `string` | no |

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

#### `broadcasts.recipients`

`{ id: string, status?: string, limit?: number, offset?: number }`

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

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |
| status | `string` | no |
| limit | `number` | no |
| offset | `number` | no |

Returns: { stats, recipients: Recipient[] }

#### `broadcasts.cancel`

`{ id: string }`

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

| Parameter | Type | Required |
| --- | --- | --- |
| id | `string` | yes |

Returns: { id, status: "cancelled" }

Open and click tracking are off unless you turn them on per broadcast, and both want a custom tracking domain so the rewritten links carry your name. See /docs/tracking-domain.

---

Base URL: https://emails.sh/v1. Auth: `Authorization: Bearer esh_...`.
Whole API in one file: https://emails.sh/llms.txt. All documentation: https://emails.sh/docs.md.
