Guides
Bulk SMS
Send one SMS body to a list of recipients in a single call with POST /api/v1/sms/bulk, and track each recipient's message individually.
POST /api/v1/sms/bulk sends the same body to every recipient in one call, prices the whole
send up front, and returns one message id per recipient. Those ids behave exactly like
single-send ids, so you track each recipient with GET /api/v1/sms/{id}.
Sending to a list
to is an array of MSISDNs in international format; message and from apply to every
recipient.
curl -X POST https://api.sendai.co.zw/api/v1/sms/bulk \
-H "Authorization: Bearer $SENDAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": ["263771000000", "263772000001"],
"from": "YOUR_SENDER_ID",
"message": "Polls close at 18:00. Thanks for taking part!",
"webhook_url": "https://example.com/webhooks/dlr"
}'
Unlike the single-send endpoint, this response is enveloped — the payload sits under
data, with one entry per recipient in request order:
{
"status": "success",
"message": "bulk sms enqueued",
"data": {
"total": 2,
"charge": 900,
"currency": "USD",
"items": [
{
"id": "0f9c1b3a-6d2e-4a1b-9c3d-7e5f2a8b4c10",
"to": "263771000000",
"status": "enqueued",
"operator": "econet",
"charge": 450,
"charge_currency": "USD"
},
{
"id": "7e5f2a8b-4c10-4a1b-9c3d-0f9c1b3a6d2e",
"to": "263772000001",
"status": "enqueued",
"operator": "netone",
"charge": 450,
"charge_currency": "USD"
}
]
}
}
Store each items[].id against your own recipient record — that id is how you check delivery
later.
chargeis an integer here. The single-send endpoint returns it as a string. Both are in ten-thousandths of the currency (450== 0.045); only the JSON type differs.
Per-recipient charge values can differ within one send: operators are priced independently,
so an Econet and a NetOne recipient may not cost the same. data.charge is the total held.
The optional webhook_url is validated as a URL and applied to every message in the send.
For delivery callbacks on your whole account rather than one send, see
Webhooks.
Tracking each recipient
There is no batch-level status endpoint on this surface — you poll the individual message ids:
curl https://api.sendai.co.zw/api/v1/sms/0f9c1b3a-6d2e-4a1b-9c3d-7e5f2a8b4c10 \
-H "Authorization: Bearer $SENDAI_API_KEY"
As with a single send, status tracks the pipeline (created → enqueued → processing →
success / failed) and delivered_at is the delivery confirmation — null until the
carrier confirms, then a timestamp.
Errors
The whole send is accepted or rejected together; there is no partial success.
| Status | Meaning |
|---|---|
400 | Validation failed — to empty, message or from blank, or webhook_url not a URL. The body lists the offending fields in error.validation_errors. |
401 | Missing, malformed, or revoked API key. |
422 | insufficient balance — your prepaid balance does not cover the send. |
{
"status": "error",
"message": "Validation failed",
"error": {
"code": "VALIDATION_ERROR",
"description": "One or more fields failed validation",
"validation_errors": [
{ "field": "from", "description": "cannot be blank" }
]
}
}
Getting bulk sends right
- Watch the encoding. One non-GSM-7 character switches the whole body to UCS-2 and roughly halves the characters per segment. Across 50,000 recipients that is a large and avoidable bill — see billing and segments.
- Deduplicate before you send. A repeated MSISDN is not collapsed; the recipient gets two messages and you are billed for both.
- Normalise to international format (
263771000000). A number that does not resolve to an operator is a message you paid to have rejected. - Make retries safe. If a request times out, do not blindly resend — the send may well have been accepted. Reconcile against the returned ids first.
- Poll on an interval, not in a tight loop. Delivery settles over minutes to hours, bounded by how fast carriers confirm.
Next steps
- Send a single message → Quickstart
- Get delivery pushed instead of polling → Webhooks
- Check your balance before a big send → Balances
- Every field and response → Send bulk SMS