Delivery reports
Two ways to know a message reached the handset: push webhooks to your endpoint (recommended) or the lookup endpoint.
Push: DLR webhooks
Register a callback URL once (see POST /v2/companies/) and we POST each delivery status to it as networks confirm. The body is the same shape the platform has always sent, so an integration built years ago keeps working, plus two fields added in September 2026: a harmonised status and your v2 trackingId.
{
"deliveryStatus": "DeliveredToTerminal",
"status": "Delivered",
"deliveryTime": "2026-09-08 12:35:22",
"reference": "bc8dca81-3324-4498-9914-b8899c3a7ce3",
"trackingId": "order-4521-confirm",
"msisdn": "254705799644",
"cost": "0.20",
"sender": "DigitalyFit",
"dlr": {
"tracking_id": "order-4521-confirm",
"gateway": "safaricom",
"msisdn": "254705799644",
"updated_at": "2026-09-08T09:35:22Z",
"created_at": "2026-09-08T09:35:22Z",
"delivery_code": "000",
"description": "DeliveredToTerminal",
"delivered_at": "2026-09-08 12:35:22"
},
"isFromSandBox": false
}Fields
| Field | Type | Required | Description |
|---|---|---|---|
| status | string | No | Harmonised outcome, the field to branch on: Delivered, Failed, Rejected, Expired, Unreachable, or Sent (interim). Same values as the portal and the lookup endpoints. |
| deliveryStatus | string | No | The carrier's own words for the same report, unchanged: DeliveredToTerminal, DELIVRD, AbsentSubscriber, DeliveryImpossible, Sendername blacklisted, and others. Kept for integrations that already match on it. |
| reference | string | No | The messageId or bulkId we returned when you sent the message. For portal and campaign sends it is the message UUID. Key your reconciliation on this. |
| trackingId | string | No | The trackingId you supplied on a v2 send, repeated at the top level; empty when the send carried none, which is how you tell the two cases apart. |
| deliveryTime | string | No | When the carrier confirmed, Nairobi time, YYYY-MM-DD HH:MM:SS. |
| msisdn | string | No | The recipient in E.164 without the plus. Some Safaricom traffic carries a hashed number instead; see phone lookup. |
| cost | string | No | What the message cost you in KES, two decimals, as a string. |
| sender | string | No | The sender ID the message went out under. |
| dlr.tracking_id | string | No | The trackingId you sent on a v2 send, so a report joins straight back to your record. For a send that carried no trackingId (v1, bulk, portal, campaigns) it is our own dispatch ID for that message. |
| dlr.gateway | string | No | The network that delivered: safaricom, airtel, telkom, equitel, airtouch. |
| dlr.delivery_code | string | No | The carrier's own code when it sent one (Safaricom: 000 delivered, 027 expired, 539 rejected). A delivered report without a carrier code carries 0; a failure without one carries an empty string. Never treat 0 alone as success: read status. |
| dlr.description | string | No | Same as deliveryStatus. |
| dlr.created_at / updated_at | string | No | UTC RFC 3339: when the carrier confirmed and when we sent this report. |
| dlr.delivered_at | string | No | Same as deliveryTime. |
| isFromSandBox | boolean | No | Always false in production. |
status values
| Field | Type | Required | Description |
|---|---|---|---|
| Delivered | final | No | Confirmed on the handset. |
| Failed | final | No | The network could not deliver: invalid number, barred, handset error. |
| Rejected | final | No | The carrier refused the message: filtering, or the sender is blocked for that subscriber. |
| Expired | final | No | The carrier gave up retrying, usually a phone off for the validity period. |
| Unreachable | final | No | Absent subscriber: off or out of coverage when the carrier tried. |
| Sent | interim | No | Accepted by the carrier, delivery not yet confirmed. A final report follows. |
Why a message failed
A failed message carries the carrier's own reason alongside the status, in plain language rather than an operator code: absent subscriber, invalid number, handset memory full, sender blocked for this subscriber. It is on the message in the portal, in the API response, and in a message export, so “why did these 40 fail” is answerable without a support ticket.
Note that a hashed subscriber number can appear in place of the MSISDN on some Safaricom traffic. Use phone lookup to tie it back to a customer.
Webhook hygiene
200 quickly (under 5s) and process asynchronously. Treat deliveries as at-least-once. Dedupe on reference + status. Your endpoint must be HTTPS. Respond 4xx only when the report itself is wrong: on a 5xx or a timeout we retry.Verify it came from us
X-MobileSasa-Secret and X-MobileSasa-Signature headers, so your endpoint can reject forged reports. See Webhook security for verification examples.Pull: the lookup endpoint
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
| messageId | string | Yes | The message reference to look up. Your v2 trackingId, or the ID returned at send time. |
# Paste your token once (it starts with mbs_):
export MOBILESASA_TOKEN="mbs_your_token_here"
curl -X POST https://api.mobilesasa.com/v1/dlr \
-H "Authorization: Bearer $MOBILESASA_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"messageId": "order-4521-confirm"
}'{
"status": true,
"responseCode": "0200",
"message": "Delivered"
}Pull with filters: the v2 lookup
The v2 lookup returns a paginated list instead of a single status, and filters by phone, sender and date range. start and end are required: omitting them answers The start is not a valid date.
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
| start | string | Yes | Window start. YYYY-MM-DD, YYYY-MM-DD HH:MM:SS, or RFC3339. |
| end | string | Yes | Window end, same formats. A bare date means end of that day. |
| messageId | string | No | Your v2 trackingId, or the ID returned at send time. |
| phone | string | No | Filter to one recipient. |
| senderID | string | No | Filter to one sender name. |
| page | int | No | Page number, 1-based. |
# Paste your token once (it starts with mbs_):
export MOBILESASA_TOKEN="mbs_your_token_here"
curl -X POST https://api.mobilesasa.com/v2/dlr \
-H "Authorization: Bearer $MOBILESASA_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"start": "2026-08-01",
"end": "2026-08-01",
"phone": "254712345678",
"page": 1
}'Which should I use?
Webhooks, always, if you can host an endpoint. They're real-time and free of polling overhead. The lookup exists for spot checks, reconciliation jobs, and platforms that can't receive inbound HTTP.