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

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:

ReasonMeaning
invalidNot a valid US/Canada number (e.g. a +44 number)
duplicateSame number as an earlier row - the first one is kept
opted_outReplied STOP to you
missing_variableA variable the body uses is blank on this row
landlineOnly with check_line_types: true (first 500 numbers)
empty_after_mergeThe 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

EndpointWhat it does
POST /v1/batches/{id}/pauseHolds everything not yet handed to the carrier
POST /v1/batches/{id}/resumeRe-queues held messages
POST /v1/batches/{id}/cancelCancels a scheduled batch, or stops one mid-send
POST /v1/batches/{id}/retryRe-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:

CheckResult
SenderPass in sandbox or with an approved registration; a warning otherwise
Opt-out wordingWarning when the message has no "Reply STOP" instruction
ContentBlocks on a live sender when the content screen refuses the message; a warning in sandbox
Public link shortenerWarning
Quiet hoursWarning 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 fileWarning when contacts in the audience have no opt-in record, and always for an uploaded list
TopicWarning when no topic is set
Merge variables, empty audienceBlocks (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:

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, 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.