Skip to content

Delivery reports

A 200 from the send endpoint means queued. A delivery report (DLR) is how you find out what actually happened.

Set delivery_report: true when you send, then either poll or receive a push.

GEThttps://api.9bits.net/ng/v1/sendsms/status/{‘{reference_id}’}

Terminal window
curl https://api.9bits.net/ng/v1/sendsms/status/fdab8f46df049e58b0af7c2d7 \
-H "Authorization: Bearer $NINEBITS_TOKEN"
{
"reference_id": "fdab8f46df049e58b0af7c2d7",
"total": 3,
"counts": {
"delivered": 2,
"pending": 1
},
"recipients": [
{
"destination": "2348020000000",
"status": "delivered",
"operator": "mtn",
"message_id": "4f2a9c1b7e"
},
{
"destination": "2348030000000",
"status": "failed",
"status_reason": "absent subscriber",
"operator": "mtn",
"message_id": "7b3d5e2a9f"
},
{
"destination": "2349010000000",
"status": "pending",
"operator": "airtel",
"message_id": "1c8e4a6b2d"
}
]
}

counts gives you campaign progress in one field — usually all a dashboard needs. recipients gives you the per-number detail.

StatusMeaningFinal?
pendingQueued with us, not yet handed to the operator.No
submittedHanded to the operator, awaiting their receipt.No
deliveredConfirmed on the handset.✅ Yes
failedRejected or undeliverable. Check status_reason.✅ Yes
expiredValidity period elapsed before it could be delivered — typically the phone was off or out of coverage for the whole window.✅ Yes

Only delivered, failed and expired are terminal. Anything else will still change.

Set callback_url on the send and we POST each receipt to you as it arrives — no polling, no rate limit, no delay.

{
"from": "9bits",
"to": ["2348020000000"],
"content": "Your code is 481920.",
"delivery_report": true,
"callback_url": "https://example.com/webhooks/9bits/dlr"
}

Each receipt arrives as:

{
"reference_id": "fdab8f46df049e58b0af7c2d7",
"destination": "2348020000000",
"status": "delivered",
"operator": "mtn",
"message_id": "4f2a9c1b7e"
}
app.post('/webhooks/9bits/dlr', async (req, res) => {
const { message_id, destination, status, status_reason } = req.body;
// Acknowledge immediately — do the work off the request path.
res.sendStatus(200);
// You will receive duplicates. De-duplicate on message_id.
const { rowCount } = await db.query(
`INSERT INTO sms_receipts (message_id, destination, status, reason, received_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (message_id) DO NOTHING`,
[message_id, destination, status, status_reason ?? null],
);
if (rowCount === 0) return; // already seen
if (status === 'failed' || status === 'expired') {
await flagUndeliverable(destination, status_reason);
}
});

Return a 2xx and de-duplicate on message_id — retries mean the same receipt can arrive more than once. The retry schedule and the rules for acknowledging are in the Webhooks guide.

status_reason carries the operator’s explanation. The common ones:

ReasonWhat it meansWhat to do
absent subscriberPhone off or out of coverage.Transient. Retry later — often succeeds.
unknown subscriberThe number isn’t active on the network.Permanent. Stop sending to it.
rejectedThe operator blocked it — often sender ID or content policy.Check your sender ID and message content.

The status endpoint only knows about batches sent through this API. Messages sent over SMPP, or through the dashboard’s bulk tools, have no REST reference_id and return batch_not_found.