Webhooks & Status Codes
Webhooks push real-time updates into your application as messages move through the network, payments confirm and customers reply. This page covers how to wire them up, every status code Sozuri returns synchronously, and the exact JSON payloads you’ll receive on each delivery callback.
- Types of webhooks
- Registering a callback URL
- Anatomy of a webhook request
- Verifying authenticity
- Status codes (synchronous)
- Response envelope per channel
- Delivery callbacks (asynchronous)
- SMS delivery webhook
- WhatsApp delivery webhook
- Premium SMS callbacks
- Cross-channel mapping
Types of webhooks
| Webhook | Fires when… |
|---|---|
| Message Status | An outbound message changes state (queued → sent → delivered / read / failed). See delivery callbacks. |
| Received Message | A customer sends a message to your shortcode or WhatsApp number. See 2-way SMS and WhatsApp notifications. |
| Payment | An M-Pesa STK Push, C2B or Paybill transaction completes or fails. See payments. |
| Subscription | A customer subscribes, unsubscribes or is billed on a Premium SMS service. See subscriptions. |
Registering a callback URL
- Open your project dashboard.
- Go to Manage API › Callback URLs › Create new.
- Paste your HTTPS endpoint in the field for the event type you want (Status, Received Message, Payment, etc.).
- Save. Your webhook is active immediately — the next event will be delivered to your endpoint.
POST requests and return a 2xx response promptly. Callbacks are not retried on non-2xx responses or timeouts, so keep your receiver highly available and idempotent.
Anatomy of a webhook request
- Method — always
POST - Content-Type —
application/json - Body — JSON with the event payload (see each channel’s payload below)
- messageId — the same ID returned in the API response when the message was sent
- timestamp — Unix epoch in seconds. Always use this for ordering, not the order of arrival
Verifying authenticity
You can register an Auth Key per callback URL from the dashboard. Sozuri will include it in the body of every request so your server can confirm the call is genuine before processing it.
// Pseudocode
if (request.body.authKey !== process.env.SOZURI_CALLBACK_KEY) {
return res.status(401).end();
}
// safe to process
Status codes (synchronous)
Every accepted message comes back with a recipients[].statusCode (numeric) and recipients[].status (string). Codes 11–14, 22 and 23 are returned in the immediate response. The rest (15–21) only appear later on your delivery webhook, never in the synchronous response.
| Code | Status | Stage | Channels | Meaning |
|---|---|---|---|---|
| 11 | accepted | Sync accept | SMS, WhatsApp, Premium, Airtime | Message received by Sozuri and queued for processing. |
| 12 | unsupported_number | Sync fail | SMS | The destination number is not supported on the chosen channel. |
| 13 | queued | Sync accept & DLR | Queued at the carrier, awaiting delivery. | |
| 14 | scheduled | Sync accept | SMS | Delivery has been scheduled for a future time. |
| 15 | device_ack | DLR only | SMS | The recipient’s device acknowledged delivery. |
| 16 | read | DLR only | The recipient read the message. | |
| 17 | expired | DLR only | SMS | The message could not be delivered within its validity window. |
| 18 | stopped_by_user | DLR only | SMS | The recipient opted out and the message was blocked. |
| 19 | stopped_by_admin | DLR only | SMS | Blocked by carrier or platform policy. |
| 21 | delivery_failure | DLR only | SMS | The carrier reported a delivery failure. |
| 22 | insufficient_balance | Sync fail | SMS | Not enough credits in the project to send the message. |
| 23 | volume_limit | Sync fail | SMS | The project’s configured volume limit has been exceeded. |
statusCode per messageId to power dashboards and retries.
Response envelope per channel
Each channel wraps success and error payloads in a slightly different envelope — an artefact of how the underlying providers respond. The table below tells you which fields to parse for the message id, the status, and the error message. Channel-specific error responses are documented on each channel’s own page.
| Channel | Success envelope | Error envelope | Full error list |
|---|---|---|---|
| SMS | messageData.messages, recipients[] |
messageData.message |
SMS errors |
messageData.messages, recipients[] |
error · MessageData.Error · message + errors{} |
WhatsApp errors | |
| Premium SMS (on-demand) | messageData.messages, recipients[] |
status + description |
Premium on-demand errors |
| Premium SMS (subscription) | messageData.messages, recipients[] |
status + description |
Subscription errors |
| Airtime | data.messages, recipients[] |
data.message · status + errorMessage |
Airtime errors |
Sample success response (SMS)
{
"messageData": { "messages": 1 },
"recipients": [
{
"messageId": "MSGBLK6012A7E8B90A21611835368",
"to": "254700000001",
"status": "accepted",
"statusCode": "11",
"messagePart": 1,
"type": "bulk"
}
]
}
Sample error response (SMS)
{
"messageData": {
"message": "Error. Insufficient balance. Top up and try again."
}
}
Project not found., Unknown project.) are shared across every channel and are documented on the Authentication page.
Delivery callbacks (asynchronous)
After Sozuri accepts a message, the final delivery outcome is pushed to your project’s Message Status callback URL. The following sections cover the exact JSON shape per channel and every status string we POST.
- Match callbacks to your records by
messageId. Callbacks can arrive out of order; trustmessageId+timestamp, not arrival order. - Callbacks are not authenticated by default. Use the optional Auth Key (see above) and ignore any
messageIdyou don’t recognise. - No automatic retries. If your endpoint returns a non-2xx or times out, the callback is logged on our side but is not retried.
SMS delivery webhook
Identical shape for Safaricom and Airtel — the only difference is the network field and the values of status.
Payload
POST https://yourdomain.com/your/callback HTTP/1.1
Content-Type: application/json
Accept: application/json
{
"project": "yourproject_name",
"messageId": "MSGBLK5F96B6A0CC2EB1603712672",
"channel": "sms",
"status": "success",
"network": "safaricom",
"type": "bulkDelivery",
"timestamp": 1603713484
}
Safaricom status values
Safaricom delivery reports are normalised to five strings. The carrier-side deliveryStatus is mapped before reaching your webhook.
status | Carrier codes (input) | Meaning |
|---|---|---|
success | 0 | Delivered to the recipient’s device. |
network_failure | 1001 | Network error on the carrier side. Often transient — retry. |
delivery_impossible | 1, 5, 11, 13, 21, 31, 32, 34, 99 | Permanent failure — barred number, terminal incompatible, blocked content, etc. |
absent_subscriber | 6, 27 | Subscriber unreachable (off-network, off-coverage). Will not be retried by the carrier. |
unknown_error | anything else | Carrier returned a code we don’t yet map. Treat as a permanent failure. |
Airtel status values
Airtel delivery strings are forwarded as-is from the carrier — no normalisation is applied. The most common values observed in production:
status | Meaning |
|---|---|
delivered | Delivered to the recipient’s device. |
failed | Carrier reported a delivery failure. Inspect description/status_code in your records for the reason. |
| carrier-specific strings | Other strings may appear depending on the Airtel response. Treat unknown values as permanent failures and log them so we can add a mapping. |
WhatsApp delivery webhook
WhatsApp callbacks fire on every state change reported by Meta. The payload nests the delivery state under a statuses object that mirrors WhatsApp’s own structure.
Payload (successful state change)
{
"project": "yourproject_name",
"channel": "whatsapp",
"phone_number": "254101523135",
"statuses": {
"recipient": "254725164293",
"message_id": "wamid.HBgMMjU0NzI1MTY0Mjkz...",
"status": "delivered",
"type": "status",
"conversation_id": "f659b9ba4d9551c39ca61498126da29f",
"expiration_timestamp": "1692896813",
"category": "user_initiated"
}
}
Payload (failure)
When a message fails, the statuses object replaces conversation metadata with an error string sourced from Meta:
{
"project": "yourproject_name",
"channel": "whatsapp",
"phone_number": "254101523135",
"statuses": {
"recipient": "254725164293",
"message_id": "wamid.HBgMMjU0NzI1MTY0Mjkz...",
"status": "failed",
"type": "status",
"error": "Re-engagement message"
}
}
WhatsApp status values
status | Meaning |
|---|---|
sent | Accepted by WhatsApp servers. |
delivered | Delivered to the recipient’s device. |
read | The recipient opened the message. Only fires when the user has read receipts enabled. |
failed | Permanent failure. The error field carries Meta’s reason (e.g. Re-engagement message for messages outside the 24-hour customer-care window). |
Premium SMS callbacks
Premium SMS uses dedicated callback types for subscriber lifecycle events on top of message delivery. The four event types you can receive:
| Event | When it fires |
|---|---|
activate | A subscriber opted in via your shortcode (texted the keyword). |
deactivate | A subscriber unsubscribed. |
consent | Carrier-side consent confirmation captured. |
delivery | Delivery report for a premium message. |
The full Premium callback payloads — including SubscriberLifeCycle field shapes — live on the Subscription premium SMS page.
Cross-channel mapping
If you handle more than one channel, this table lets you normalise the incoming string to a single internal vocabulary:
| Conceptual state | SMS — Safaricom | SMS — Airtel | |
|---|---|---|---|
| Delivered | success | delivered | delivered |
| Read | — | — | read |
| Sent to network, no DLR yet | — | — | sent |
| Transient failure (retryable) | network_failure | — | — |
| Permanent failure | delivery_impossible, unknown_error | failed | failed |
| Recipient unreachable | absent_subscriber | (varies) | (carried in error) |
Use cases
The most common ways teams put webhooks and status codes to work.
Mark an order as paid
When the payment webhook fires with status: success, flip the order to paid and trigger fulfilment automatically.
Idempotent delivery receivers
Key your handler on messageId and apply changes idempotently. Late or duplicate callbacks become safe by construction.
Real-time delivery dashboard
Stream callbacks into your warehouse and chart delivery rate, network breakdown and failure reasons by hour. The network field gives you Safaricom vs Airtel splits for free.
Smart retries
Re-send only messages with transient failures (network_failure, 17 expired) and skip permanent ones (delivery_impossible, 18 stopped_by_user) automatically.
Wire Sozuri into your stack.
Configure a callback URL on your project, send a test message, and watch the events arrive in real time.