Batches & broadcasts
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
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, evaluated when the batch is created),
tags, or contact_ids.
topic_id names a subscription topic.
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. 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:
{
"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.deliveredandfailedcome from carrier delivery receipts.pendingwas accepted by the carrier with no final receipt;no_receiptof those never got one. Sandbox outcomes are simulated.repliesare messages recipients sent to the sending number within 72 hours of the batch finishing (STOP keywords are not counted as replies).stopsare recipients who opted out in that time and are still opted out.spent_usdis the sum of each message's recordedprice.failuresgroups failed recipients by failure code, 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: truemeans 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.