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.

Webhook payload (POST to your URL)
{
  "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

FieldTypeRequiredDescription
statusstringNoHarmonised outcome, the field to branch on: Delivered, Failed, Rejected, Expired, Unreachable, or Sent (interim). Same values as the portal and the lookup endpoints.
deliveryStatusstringNoThe 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.
referencestringNoThe 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.
trackingIdstringNoThe 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.
deliveryTimestringNoWhen the carrier confirmed, Nairobi time, YYYY-MM-DD HH:MM:SS.
msisdnstringNoThe recipient in E.164 without the plus. Some Safaricom traffic carries a hashed number instead; see phone lookup.
coststringNoWhat the message cost you in KES, two decimals, as a string.
senderstringNoThe sender ID the message went out under.
dlr.tracking_idstringNoThe 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.gatewaystringNoThe network that delivered: safaricom, airtel, telkom, equitel, airtouch.
dlr.delivery_codestringNoThe 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.descriptionstringNoSame as deliveryStatus.
dlr.created_at / updated_atstringNoUTC RFC 3339: when the carrier confirmed and when we sent this report.
dlr.delivered_atstringNoSame as deliveryTime.
isFromSandBoxbooleanNoAlways false in production.

status values

FieldTypeRequiredDescription
DeliveredfinalNoConfirmed on the handset.
FailedfinalNoThe network could not deliver: invalid number, barred, handset error.
RejectedfinalNoThe carrier refused the message: filtering, or the sender is blocked for that subscriber.
ExpiredfinalNoThe carrier gave up retrying, usually a phone off for the validity period.
UnreachablefinalNoAbsent subscriber: off or out of coverage when the carrier tried.
SentinterimNoAccepted 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

Respond 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

Every DLR we POST carries 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

POSThttps://api.mobilesasa.com/v1/dlr

Parameters

FieldTypeRequiredDescription
messageIdstringYesThe 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"
  }'
Response
{
  "status": true,
  "responseCode": "0200",
  "message": "Delivered"
}

Pull with filters: the v2 lookup

POSThttps://api.mobilesasa.com/v2/dlr

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

FieldTypeRequiredDescription
startstringYesWindow start. YYYY-MM-DD, YYYY-MM-DD HH:MM:SS, or RFC3339.
endstringYesWindow end, same formats. A bare date means end of that day.
messageIdstringNoYour v2 trackingId, or the ID returned at send time.
phonestringNoFilter to one recipient.
senderIDstringNoFilter to one sender name.
pageintNoPage 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.