# Batches & broadcasts

Source: https://joinsimplesms.com/docs/broadcasts
Index: https://joinsimplesms.com/llms.txt

A batch sends one message body to many recipients as individual texts.
Recipients never see each other, and variables personalize each body.
Recipients come from a CSV upload (console), `recipients[]` (API), or
contacts carrying a tag.

## Send a batch

```bash
curl -X POST https://api.joinsimplesms.com/v1/batches \
  -H "Authorization: Bearer $SIMPLESMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "+15005550110",
    "body": "Hi {{first_name}}, order {{order_id}} ships today. Reply STOP to opt out.",
    "recipients": [
      {"to": "+14155550132", "variables": {"first_name": "Jane", "order_id": "A-1042"}},
      {"to": "(415) 555-0133", "variables": {"first_name": "Sam", "order_id": "A-1043"}}
    ]
  }'
```

The response is a `batch` object (`bc_` id) with live `counts`, plus a
`validation` summary of any rows that were dropped. To send to contacts
instead, pass `"tags": ["vip", "beta"]` (any of the tags) in place of
`recipients`. Add `scheduled_at` (up to 30 days out) to send later.

## Validate first: dry runs

Add `"dry_run": true` and nothing is sent. You get counts, every rejected
row with its reason, a rendered preview, the segment count and the
estimated cost:

| Reason | Meaning |
|---|---|
| `invalid` | Not a valid US/Canada number (e.g. a +44 number) |
| `duplicate` | Same number as an earlier row - the first one is kept |
| `opted_out` | Replied STOP to you |
| `missing_variable` | A variable the body uses is blank on this row |
| `landline` | Only with `check_line_types: true` (first 500 numbers) |
| `empty_after_merge` | The rendered body is empty |

A row is counted under its first failing reason, so `valid` plus the
rejected counts always equals the number of rows you sent. A variable that
no row has at all (a typo like `{{frist_name}}`) fails the whole request
with `unknown_variables` instead of sending blanks.

The estimate is messages × the per-message rate (sandbox: $0). Segments are
reported too: one emoji or curly quote switches the whole message to UCS-2,
70 characters per segment instead of 160.

## Variables

Any recipient variable (CSV column) is available as `{{column_name}}`;
headers are snake_cased ("First Name" → `{{first_name}}`). Contact batches
also get `{{name}}`, `{{first_name}}`, `{{phone}}` and
`{{field:company}}`, rendered at send time so a contact edit made after
scheduling still lands. Unresolvable tags render empty, never as the raw tag.

## Pause, resume, cancel, retry

| Endpoint | What it does |
|---|---|
| `POST /v1/batches/{id}/pause` | Holds everything not yet handed to the carrier |
| `POST /v1/batches/{id}/resume` | Re-queues held messages |
| `POST /v1/batches/{id}/cancel` | Cancels a scheduled batch, or stops one mid-send |
| `POST /v1/batches/{id}/retry` | Re-sends failures that can succeed on a retry |

A message already in flight when you pause or cancel still sends. Retry only
re-queues transient failures (rate limits, quota, carrier errors), at most 3
attempts per recipient, and only once the batch is `complete`; opt-outs,
invalid numbers and anyone this batch already messaged are never retried.
Two retries at once can't double-send.

## Progress

`GET /v1/batches/{id}` returns `counts`: `total = queued + sent + failed +
opted_out + canceled`, always. "Sent" means accepted by the carrier.
`GET /v1/batches/{id}/recipients?status=failed`
lists rows with `error` and `retryable`. In the console, the batch page
shows live progress and a **Download failed rows** CSV (your original
columns plus the reason), ready to fix and re-upload.

## Audience and topic

Besides `recipients`, a batch can go to contacts: `segment_id` (a saved
[segment](/docs/contacts#segments), evaluated when the batch is created),
`tags`, or `contact_ids`.

`topic_id` names a [subscription topic](/docs/contacts#subscription-topics).
Recipients unsubscribed from it are removed at validation (counted as
`topic_unsubscribed`) and checked again when each message is sent, exactly
like opt-outs; at send time they are counted with `opted_out` and their row's
`error` is `topic_unsubscribed`. Omit `topic_id` and only opt-outs apply.
Console broadcasts default to Marketing.

## Compliance check

The console runs a check before a broadcast is sent (it is part of the
dry run). The rules are fixed; no AI is involved:

| Check | Result |
| --- | --- |
| Sender | Pass in sandbox or with an approved registration; a warning otherwise |
| Opt-out wording | Warning when the message has no "Reply STOP" instruction |
| Content | **Blocks** on a live sender when the content screen refuses the message; a warning in sandbox |
| Public link shortener | Warning |
| Quiet hours | Warning when the send time is outside 8am-9pm on either US coast. We do not know recipients' time zones and do not hold messages for you |
| Consent on file | Warning when contacts in the audience have no opt-in record, and always for an uploaded list |
| Topic | Warning when no topic is set |
| Merge variables, empty audience | **Blocks** (the API refuses these too) |

Warnings never stop a send: consent, timing and wording are your
responsibility under the [Messaging Policy](/messaging-policy).
**Send test** texts the first rendered message to your own verified phone.

## Results

`GET /v1/batches/{id}/results` (and the console's broadcast page) reports
what happened after the queue did its part:

```json
{
  "attempted": 100000, "delivered": 97921, "failed": 1228, "pending": 851,
  "replies": 1402, "stops": 312, "spent_usd": 1142.28,
  "failures": [
    { "code": "invalid_number", "title": "Number not in service", "count": 640,
      "explanation": "The destination number does not exist or is no longer assigned to a phone.",
      "action": "Recommended: stop sending to this number..." }
  ]
}
```

- `attempted = delivered + failed + pending`. Skipped (opted out or
  unsubscribed), canceled and still-queued recipients are reported separately.
- `delivered` and `failed` come from carrier delivery receipts. `pending`
  was accepted by the carrier with no final receipt; `no_receipt` of those
  never got one. Sandbox outcomes are simulated.
- `replies` are messages recipients sent to the sending number within 72
  hours of the batch finishing (STOP keywords are not counted as replies).
  `stops` are recipients who opted out in that time and are still opted out.
- `spent_usd` is the sum of each message's recorded `price`.
- `failures` groups failed recipients by [failure code](/docs/errors), plus
  the reasons a send can be refused before a message exists (allowance
  reached, sending number released, empty after merge...). In the console,
  click a reason for the explanation and a CSV of exactly those recipients.
- `partial: true` means a read bound was hit on a very large or very busy
  account: delivered and replies are then floors.

Events: `batch.created`, `batch.paused`, `batch.resumed`,
`batch.canceled`, `batch.retried`, `batch.complete` (and the legacy
`broadcast.complete`, still sent for existing subscribers).

## Opt-outs are enforced per recipient

Opted-out numbers are dropped at validation, and every recipient is checked
again at send time. Anyone who replies STOP while a batch is running is
counted in `opted_out` - never texted, never silently dropped from the math.

## Limits and pacing

Up to 10,000 recipients per batch. Messages go out at about 100 per minute,
interleaved fairly with your other scheduled sends. Each message counts
against your normal quota and billing; a batch is exactly N messages.
