Skip to content
Sendozi

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.

By SendoziUpdated 5 min read

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

EndpointScopeReturns
GET /v1/messages/{message_id}messages:readOne message, from the moment it is queued
GET /v1/messagesmessages:readSent messages, newest first, sandbox and live together
GET /v1/message-batches/{batch_id}messages:readOne batch with its counts and cost
GET /v1/message-batchesmessages:readBatches, newest first
GET /v1/message-batches/{batch_id}/recipientsmessages:readEvery recipient of a batch with its message_id and outcome, in the order they were queued
POST /v1/message-batches/{batch_id}/cancelmessages:sendStops the recipients not yet sent

A message

GET /v1/messages/msg_7c1e0f9a2b3d4e5f
JSON
{
  "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"
}
Message statuses
statusMeaningFinal
queuedAccepted into the batch and waiting its turnNo
acceptedHanded to the network; waiting for the delivery report. The final status of a sandbox messageSandbox only
deliveredThe network confirmed delivery to the handsetYes
failedThe network refused or could not deliver it; error says whyYes
expiredNo delivery report arrived within 72 hoursYes
cancelledThe batch was cancelled before this message was sentYes
Message statuses
  • recipient is always in +234 form, whatever form you sent.
  • customer_price_kobo is what the message cost. It is 0 in sandbox and while the message is queued.
  • request_id on a message sent from a batch identifies the worker run that sent it, not your original request.
  • GET /v1/messages lists 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.codeMeaningWhat to do
INVALID_RECIPIENTThe number could not be reachedCheck the number; do not retry it unchanged
SENDER_NOT_APPROVEDThe network refused the Sender IDCheck the Sender ID's approval for the route
NETWORK_UNAVAILABLEThe network was unavailable at the timeSend again later
DELIVERY_TEMPORARILY_UNAVAILABLEThe message could not be submitted at the timeSend again later
PERMANENT_DELIVERY_FAILURE, delivery_failedThe network could not deliver itUsually the handset or number; retrying rarely helps
delivery_rejectedThe network refused the messageCheck the content and Sender ID
delivery_expiredNo delivery report within 72 hoursThe handset may have been off; the outcome is unknown
RECIPIENT_DNDThe number has Do Not Disturb active for this kind of messageStop sending it promotional SMS; transactional messages are not blocked this way
DELIVERY_UNCONFIRMEDThe network did not answer, so Sendozi cannot tell whether it was sent. It was not charged and is not re-sent automaticallyIt may still arrive; check before sending again, so the person does not get it twice
worker_timeoutThe send was interrupted before it reached the network. It was not chargedSafe to send again
recipient_suppressedThe number opted out of your SMS (recipient skipped)Do not send to it
recipient_dndSkipped, 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_cancelledThe batch was cancelled first (recipient cancelled)None

A batch

GET /v1/message-batches/bat_5f45b8c07bd844c8
JSON
{
  "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"
}
Batch statuses
statusMeaning
queuedAccepted; no recipient has been picked up yet. A live promotional send accepted outside 08:30 to 19:30 WAT waits here
processingRecipients are being sent
completedEvery recipient has been handed to the network or skipped. Delivery reports can still arrive after this
partially_completedFinished, and some recipients failed
failedFinished, and no recipient was sent
cancelledCancelled before every recipient was sent
Batch statuses
  • progress.sent counts recipients handed to the network, including those since delivered.
  • reserved_cost_kobo is held from the wallet when a live send is accepted. actual_cost_kobo is what the recipients sent so far cost. When the batch finishes, the unused part of the hold is released.
  • message_ids is 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

GET /v1/message-batches/{batch_id}/recipients?limit=500
JSON
{
  "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.

Read every recipient of a batch
Node.js
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.

Cancel and read what was stopped
cURL
curl -X POST https://api.sendozi.com/v1/message-batches/bat_5f45b8c07bd844c8/cancel \
  -H "Authorization: Bearer $SENDOZI_API_KEY"
Response
{
  "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_sending recipients 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.completed webhook 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.

Get delivery outcomes pushed to you

Register a webhook for message.delivered, message.failed and batch.completed.