Send a batch
POST /v1/batches
One body to up to 10,000 recipients, each with its own merge variables (or to contacts by tag), sent as individual messages through the normal pipeline. Numbers are normalized, deduplicated and checked against opt-outs before queueing; rejected rows are returned with reasons. Set dry_run to validate without sending.
Send your API key as a bearer token: Authorization: Bearer ssms_sk_.... Test keys run this endpoint against the sandbox; see Authentication for key modes and scopes.
Request body
JSON (Content-Type: application/json).
| Field | Type | Required | Description |
|---|---|---|---|
from | string | Yes | Example: +15005550100. |
body | string | Yes | At most 1600 characters. Example: Hi {{first_name}}, your order {{order_id}} has shipped.. |
name | string | No | At most 80 characters. |
recipients | array of object | No | At most 10000 items. |
recipients[].to | string | Yes | Example: +14155550132. |
recipients[].variables | object | No | |
tags | array of string | No | Send to contacts carrying any of these tags (instead of recipients). |
contact_ids | array of string | No | |
segment_id | string | No | Send to the contacts in this saved segment, evaluated when the batch is created (instead of recipients). |
topic_id | string | No | Subscription topic. Recipients unsubscribed from it are removed at validation (topic_unsubscribed) and skipped at send time, like opt-outs. Omit to apply opt-outs only. Example: tp_marketing. |
scheduled_at | string | No | Future ISO timestamp or epoch ms, up to 30 days out. |
dry_run | boolean | No | Validate only: returns a BatchValidation and sends nothing. |
check_line_types | boolean | No | Reject landlines (first 500 unique numbers are checked). |
Responses
| Status | Meaning | Body |
|---|---|---|
| 200 | Dry run result | BatchValidation |
| 201 | Batch created | Batch |
| 400 | Invalid request or no valid recipients | Error |
| 401 | Missing, malformed, or revoked API key | Error |
| 402 | Billable live calls only: the prepaid credit balance cannot cover the call (insufficient_credits); nothing was done or charged | Error |
| 403 | from not owned, or key mode does not match the number | Error |
| 429 | Rate limit or quota exceeded | Error |
200: BatchValidation fields
| Field | Type | Description |
|---|---|---|
object | batch_validation | |
counts | object | |
counts.rows | integer | |
counts.valid | integer | |
counts.invalid | integer | |
counts.duplicates | integer | |
counts.opted_out | integer | |
counts.topic_unsubscribed | integer | Unsubscribed from the batch topic_id; 0 without a topic |
counts.missing_variable | integer | |
counts.landline | integer | |
counts.empty_after_merge | integer | |
unknown_variables | array of string | |
segments | object | |
segments.total | integer | |
segments.max | integer | |
segments.encoding | GSM-7, UCS-2 | |
estimated_cost_usd | number | |
preview | array of object | |
preview[].to | string | |
preview[].body | string | |
preview[].segments | integer | |
rejected | array of object | |
rejected[].row | integer | 0-based index into recipients |
rejected[].to | string | |
rejected[].reason | invalid, duplicate, opted_out, missing_variable, landline, empty_after_merge | |
rejected[].detail | string |
201: Batch fields
| Field | Type | Description |
|---|---|---|
id | string | Example: bc_a1B2c3D4e5F6. |
object | batch | |
name | string | |
status | scheduled, sending, paused, complete, canceled | |
from | string | |
body | string | |
source | api, csv, contacts | |
test | boolean | |
scheduled_at | string (date-time) | Nullable. |
created_at | string (date-time) | |
completed_at | string (date-time) | Nullable. |
counts | object | total = queued + sent + failed + opted_out + canceled, always. "sent" = accepted by the carrier; what happened next (delivered, failed, replies) is GET /v1/batches/{id}/results. "opted_out" also counts recipients unsubscribed from the batch topic. |
counts.total | integer | |
counts.queued | integer | |
counts.sent | integer | |
counts.failed | integer | |
counts.opted_out | integer | |
counts.canceled | integer | |
rejected | object | Rows dropped at validation (not part of total), by reason. Nullable. |
variables | array of string | |
retries | integer |
Errors
| Status | When |
|---|---|
| 400 | Invalid request or no valid recipients |
| 401 | Missing, malformed, or revoked API key |
| 402 | Billable live calls only: the prepaid credit balance cannot cover the call (insufficient_credits); nothing was done or charged |
| 403 | from not owned, or key mode does not match the number |
| 429 | Rate limit or quota exceeded |
Every error has the same JSON shape, and request_id matches the X-Request-Id response header. Errors lists every code and what to do about it.
{
"error": {
"code": "invalid_request",
"message": "What went wrong, in plain words.",
"param": "the_field",
"request_id": "req_a1B2c3D4e5F6g7H8"
}
}Examples
curl
curl -X POST "https://api.joinsimplesms.com/v1/batches" \
-H "Authorization: Bearer $SIMPLESMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "+15005550100",
"body": "Hi {{first_name}}, your order {{order_id}} has shipped.",
"topic_id": "tp_marketing"
}'Node.js
The Node.js SDK does not wrap this endpoint yet; call it with fetch.
const res = await fetch('https://api.joinsimplesms.com/v1/batches', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SIMPLESMS_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"from": "+15005550100",
"body": "Hi {{first_name}}, your order {{order_id}} has shipped.",
"topic_id": "tp_marketing"
}),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const data = await res.json();Python
The Python SDK does not wrap this endpoint yet; call it over HTTP.
import json, os, urllib.request
req = urllib.request.Request(
"https://api.joinsimplesms.com/v1/batches",
method="POST",
headers={
"Authorization": f"Bearer {os.environ['SIMPLESMS_API_KEY']}",
"Content-Type": "application/json",
},
data=json.dumps({
"from": "+15005550100",
"body": "Hi {{first_name}}, your order {{order_id}} has shipped.",
"topic_id": "tp_marketing"
}).encode(),
)
with urllib.request.urlopen(req) as res:
data = json.load(res)