Sending
Messages, batches and delivery status
A send returns a batch_id. The batch holds one recipient per accepted number, and each recipient becomes a message with its own message_id. Look up either by id, page through recipients to see every number's outcome, and cancel a batch that is still queued or sending.
How a send is tracked
- Batch
- Created by every send request, identified by batch_id (bat_...). It carries the counts, the cost and the overall status.
- Recipient
- One per accepted number in the batch, identified by recipient_id (mbr_...). It shows whether that number was sent, skipped, cancelled or failed.
- Message
- What a recipient becomes, identified by message_id (msg_...). The id exists from the moment the recipient is queued, so it can be looked up straight away.
Endpoints
| Endpoint | Scope | Returns |
|---|---|---|
| GET /v1/messages/{message_id} | messages:read | One message, from the moment it is queued |
| GET /v1/messages | messages:read | Sent messages, newest first, sandbox and live together |
| GET /v1/message-batches/{batch_id} | messages:read | One batch with its counts and cost |
| GET /v1/message-batches | messages:read | Batches, newest first |
| GET /v1/message-batches/{batch_id}/recipients | messages:read | Every recipient of a batch with its message_id and outcome, in the order they were queued |
| POST /v1/message-batches/{batch_id}/cancel | messages:send | Stops the recipients not yet sent |
A message
{
"success": true,
"data": {
"message_id": "msg_7c1e0f9a2b3d4e5f",
"channel": "sms",
"mode": "production",
"sender": "YourBrand",
"recipient": "+2348012345678",
"sms_type": "promotional",
"workflow": "api",
"campaign_id": null,
"status": "delivered",
"status_timeline": [
{ "status": "accepted", "at": "2026-09-24T10:15:02.114Z" },
{ "status": "delivered", "at": "2026-09-24T10:15:09.870Z" }
],
"request_id": "req_9d8e7f6a5b4c3d2e",
"customer_price_kobo": 700,
"error": null,
"created_at": "2026-09-24T10:15:02.114Z",
"sent_at": "2026-09-24T10:15:02.114Z",
"delivered_at": "2026-09-24T10:15:09.870Z"
},
"request_id": "req_1a2b3c4d5e6f7a8b"
}| status | Meaning | Final |
|---|---|---|
| queued | Accepted into the batch and waiting its turn | No |
| accepted | Handed to the network; waiting for the delivery report. The final status of a sandbox message | Sandbox only |
| delivered | The network confirmed delivery to the handset | Yes |
| failed | The network refused or could not deliver it; error says why | Yes |
| expired | No delivery report arrived within 72 hours | Yes |
| cancelled | The batch was cancelled before this message was sent | Yes |
recipientis always in +234 form, whatever form you sent.customer_price_kobois what the message cost. It is 0 in sandbox and while the message is queued.request_idon a message sent from a batch identifies the worker run that sent it, not your original request.GET /v1/messageslists messages once they have been sent. A queued message is found by its id, not in the list.
Why a message failed
A message or recipient that did not arrive carries error with a stable code and a plain sentence. Branch on the code; the sentence may be reworded.
| error.code | Meaning | What to do |
|---|---|---|
| INVALID_RECIPIENT | The number could not be reached | Check the number; do not retry it unchanged |
| SENDER_NOT_APPROVED | The network refused the Sender ID | Check the Sender ID's approval for the route |
| NETWORK_UNAVAILABLE | The network was unavailable at the time | Send again later |
| DELIVERY_TEMPORARILY_UNAVAILABLE | The message could not be submitted at the time | Send again later |
| PERMANENT_DELIVERY_FAILURE, delivery_failed | The network could not deliver it | Usually the handset or number; retrying rarely helps |
| delivery_rejected | The network refused the message | Check the content and Sender ID |
| delivery_expired | No delivery report within 72 hours | The handset may have been off; the outcome is unknown |
| RECIPIENT_DND | The number has Do Not Disturb active for this kind of message | Stop sending it promotional SMS; transactional messages are not blocked this way |
| DELIVERY_UNCONFIRMED | The network did not answer, so Sendozi cannot tell whether it was sent. It was not charged and is not re-sent automatically | It may still arrive; check before sending again, so the person does not get it twice |
| worker_timeout | The send was interrupted before it reached the network. It was not charged | Safe to send again |
| recipient_suppressed | The number opted out of your SMS (recipient skipped) | Do not send to it |
| recipient_dnd | Skipped, not charged: the number returned a Do Not Disturb block to your workspace in the last 90 days (live promotional sends only) | None; it is tried again after 90 days |
| batch_cancelled | The batch was cancelled first (recipient cancelled) | None |
A batch
{
"success": true,
"data": {
"batch_id": "bat_5f45b8c07bd844c8",
"channel": "sms",
"mode": "production",
"status": "processing",
"sender": "YourBrand",
"sms_type": "promotional",
"requested_count": 50000,
"accepted_count": 49812,
"duplicates_removed": 140,
"invalid_count": 36,
"suppressed_count": 12,
"dnd_skipped_count": 0,
"delivered_count": 20114,
"failed_count": 203,
"progress": { "queued": 24480, "processing": 64, "sent": 25065, "delivered": 20114, "failed": 203, "skipped": 0 },
"estimated_cost_kobo": 34868400,
"reserved_cost_kobo": 34868400,
"actual_cost_kobo": 17545500,
"message_ids": [],
"created_at": "2026-09-24T09:00:00.000Z",
"completed_at": null,
"cancelled_at": null
},
"request_id": "req_2b3c4d5e6f7a8b9c"
}| status | Meaning |
|---|---|
| queued | Accepted; no recipient has been picked up yet. A live promotional send accepted outside 08:30 to 19:30 WAT waits here |
| processing | Recipients are being sent |
| completed | Every recipient has been handed to the network or skipped. Delivery reports can still arrive after this |
| partially_completed | Finished, and some recipients failed |
| failed | Finished, and no recipient was sent |
| cancelled | Cancelled before every recipient was sent |
progress.sentcounts recipients handed to the network, including those since delivered.reserved_cost_kobois held from the wallet when a live send is accepted.actual_cost_kobois what the recipients sent so far cost. When the batch finishes, the unused part of the hold is released.message_idsis kept for older integrations and is empty for queued sends. Read message ids from the recipients endpoint.- While a large batch is sending, its counts are refreshed about every 15 seconds, so they can trail the webhooks slightly. They are exact once the batch finishes.
Every recipient of a batch
{
"success": true,
"data": {
"data": [
{
"recipient_id": "mbr_7c1e0f9a2b3d4e5f",
"recipient": "+2348012345678",
"status": "delivered",
"message_id": "msg_7c1e0f9a2b3d4e5f",
"charged_kobo": 700,
"error": null,
"sent_at": "2026-09-24T09:00:04.210Z",
"delivered_at": "2026-09-24T09:00:11.002Z",
"failed_at": null
}
],
"has_more": true,
"next_cursor": "MjAyNi0wOS0yNFQwOTowMDowMC4wMDBafG1icl83YzFl"
},
"request_id": "req_3c4d5e6f7a8b9c0d"
}Recipient statuses are queued, sent, delivered, failed, skipped (not sent, for the reason in error) and cancelled. limit defaults to 100, maximum 500.
Paging through a list
Every list returns data, has_more and next_cursor inside the usual envelope. Pass next_cursor back as cursor for the next page until has_more is false. Treat the cursor as opaque. limit defaults to 50, maximum 100, for messages and batches.
async function* recipients(batchId) {
let cursor;
do {
const url = new URL(`https://api.sendozi.com/v1/message-batches/${batchId}/recipients`);
url.searchParams.set("limit", "500");
if (cursor) url.searchParams.set("cursor", cursor);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SENDOZI_API_KEY}` },
});
const body = await response.json();
if (!body.success) throw new Error(`${body.error.code}: ${body.error.message}`);
yield* body.data.data;
cursor = body.data.has_more ? body.data.next_cursor : undefined;
} while (cursor);
}
for await (const r of recipients("bat_5f45b8c07bd844c8")) {
if (r.status === "failed") console.log(r.recipient, r.error.code);
}Cancelling a send
POST /v1/message-batches/{batch_id}/cancel stops every recipient not yet sent. Messages already handed to the network cannot be recalled. A batch that is no longer queued or processing returns 409 batch_not_cancellable.
curl -X POST https://api.sendozi.com/v1/message-batches/bat_5f45b8c07bd844c8/cancel \
-H "Authorization: Bearer $SENDOZI_API_KEY"{
"success": true,
"data": {
"batch_id": "bat_5f45b8c07bd844c8",
"status": "cancelled",
"cancelled_recipients": 24480,
"still_sending": 64,
"cancelled_at": "2026-09-24T09:07:31.550Z"
},
"request_id": "req_4d5e6f7a8b9c0d1e"
}- The response carries every batch field shown above; the example is shortened.
still_sendingrecipients were mid-send when you cancelled. They finish, and are charged if they went out.- The unused part of the wallet hold is released once nothing is still sending.
- A
batch.completedwebhook with"status": "cancelled"is sent.
Polling or webhooks
For a handful of messages, polling GET /v1/messages/{id} is fine. For anything larger, use webhooks: message.delivered, message.failed and batch.completed arrive as they happen, with no polling loop to run.
Frequently asked questions
- How do I get the message_id of each number in a bulk send?
- Page through GET /v1/message-batches/{batch_id}/recipients. Each recipient carries its message_id, which you can look up straight away, even before the message is sent.
- Why does my message not appear in GET /v1/messages?
- The list shows messages once they have been sent. A message still queued is found by GET /v1/messages/{message_id}, or through its batch's recipients.
- Does batch status completed mean every message was delivered?
- No. completed means every recipient was handed to the network or skipped. Delivery reports can still arrive afterwards; read delivered_count, or the message.delivered webhooks.
Related reading
Sending
Sending SMS
POST /v1/sms/send and /v1/sms/bulk: every request field, the response, how recipients are cleaned, limits, the delivery window, and exactly how a send is priced and charged.
Reliability
Webhooks
Register a webhook endpoint, the eight events and their payloads, verifying the signature over the raw body, how retries and pausing work, and a receiver that survives repeats and reordering.
Reliability
Errors and the response envelope
The Sendozi response envelope, every error code the API returns with its HTTP status and fix, which errors are safe to retry, Retry-After, and how request_id traces a request.
Reliability
Limits, large sends and pagination
The Sendozi request limits, what 429 and Retry-After mean, how a send of up to 1,000,000 recipients is accepted and paced out to the networks, and how cursor pagination works.