Developers
SMS delivery reports and webhooks
A delivery report is the network's answer to what happened to one message for one recipient. Accepted means the message was handed to the network; delivered means the network confirmed the handset received it. The gap can be seconds or hours, so consume webhooks rather than reading the status once and believing it.
What is an SMS delivery report?
A delivery report (DLR) is the status a mobile network returns for one message to one recipient. It travels back along the route the message went out on, so it arrives after the send: usually within seconds, sometimes much later.
A send has at least two outcomes, not one. The first is whether Sendozi accepted your request. The second is whether the network delivered the message. A system that treats the 202 response as delivery is wrong in a way that only shows up when a customer says they never got the code.
What each status means
| Status | Meaning | What to do |
|---|---|---|
| queued | Sendozi accepted the request and queued the message. It has not reached a network yet. | Keep the message_id. Wait. |
| accepted | Handed to the network. Waiting for its delivery report. | Nothing. This is normal progress. |
| delivered | The network confirmed the handset received the message. | Close the loop in your system. |
| failed | The network refused or could not deliver it, or it could not be submitted. | Read error.code. Do not blindly retry. |
| expired | No delivery report arrived within 72 hours. | Treat the outcome as unknown. |
| cancelled | The batch was cancelled before this message was sent. | Nothing was sent or charged. |
Each message also carries a status_timeline: every state it passed through, with timestamps. When someone asks why a message took four minutes, the timeline is the answer.
Webhooks instead of polling
Register an endpoint once and Sendozi posts each delivery report to it as message.delivered or message.failed. Polling a status that may take minutes to change wastes requests and reacts late.
# Register. The signing secret is in the response, once.
curl -X POST https://api.sendozi.com/v1/webhooks/endpoints \
-H "Authorization: Bearer $SENDOZI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/sendozi",
"events": ["message.delivered", "message.failed", "batch.completed"]
}'
# Send a signed test.ping to it, using the id from the response.
curl -X POST https://api.sendozi.com/v1/webhooks/endpoints/wh_6d8f0a2c4e6b8d0f/test \
-H "Authorization: Bearer $SENDOZI_API_KEY"Writing a receiver that holds up
- 1
Verify the signature before parsing anything
Compute HMAC-SHA256 of the x-sendozi-timestamp header, a full stop and the raw body with your signing secret, compare it in constant time with x-sendozi-signature, and reject anything older than five minutes.
- 2
Respond 200 quickly, then do the work
Queue the event and return. A receiver doing business logic before it responds will time out during a campaign, and a timeout is retried.
- 3
Deduplicate on the event id
A retried delivery can arrive twice. The event id is the same each time, so store it and ignore repeats.
- 4
Never move backwards
Events can arrive out of order. Treat delivered, failed and expired as final, and ignore an older state that arrives after one of them.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.SENDOZI_WEBHOOK_SECRET;
app.post("/webhooks/sendozi", express.raw({ type: "application/json" }), async (req, res) => {
const timestamp = req.get("x-sendozi-timestamp") ?? "";
const signature = req.get("x-sendozi-signature") ?? "";
const expected = crypto.createHmac("sha256", SECRET).update(`${timestamp}.${req.body}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
const valid = signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!fresh || !valid) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
await queue.add("sendozi", event, { jobId: event.id }); // jobId dedupes repeats
res.status(200).end();
});import hashlib, hmac, json, os, time
from fastapi import FastAPI, Request, Response
app = FastAPI()
SECRET = os.environ["SENDOZI_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/sendozi")
async def sendozi_webhook(request: Request):
raw = await request.body()
timestamp = request.headers.get("x-sendozi-timestamp", "")
signature = request.headers.get("x-sendozi-signature", "")
expected = hmac.new(SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return Response(status_code=401)
if not hmac.compare_digest(signature, expected):
return Response(status_code=401)
enqueue_event(json.loads(raw)) # do the work out of band
return Response(status_code=200)If your endpoint is down, Sendozi retries each event for about 46 hours. After three events in a row fail, the endpoint is paused and its events are held until you resume it. The webhook reference has the schedule and every payload.
Reconciling a campaign
After a large send the useful question is not how many were delivered but which were not, and why. Read the batch, then page through its recipients and group the ones that did not arrive by error code.
curl https://api.sendozi.com/v1/message-batches/bat_5f45b8c07bd844c8 \
-H "Authorization: Bearer $SENDOZI_API_KEY"
# 500 recipients a page; follow next_cursor until has_more is false.
curl "https://api.sendozi.com/v1/message-batches/bat_5f45b8c07bd844c8/recipients?limit=500" \
-H "Authorization: Bearer $SENDOZI_API_KEY"- Group failures by
error.codefirst. INVALID_RECIPIENT is a list problem; NETWORK_UNAVAILABLE is not, and a later resend may succeed. - Compare delivered against sent over time, not at one instant. Late reports are normal for the first hours.
- Feed hard failures back into your list. A number that fails as INVALID_RECIPIENT should not be tried again unchanged.
- Keep the batch_id and the send's request_id: they are how support traces a campaign.
Frequently asked questions
- What is the difference between accepted and delivered?
- Accepted means Sendozi handed the message to the mobile network. Delivered means the network confirmed the handset received it. Only the second is evidence the recipient could have read it.
- How long does a delivery report take?
- Usually seconds. A handset that is off or out of coverage can take much longer, and a message with no report after 72 hours is marked expired. Do not treat a missing report as a failure.
- Should I poll for status or use webhooks?
- Use webhooks. Register an endpoint for message.delivered and message.failed and let Sendozi push each outcome. Polling reacts late and wastes requests on a large campaign.
- How do I verify a Sendozi webhook is genuine?
- Compute HMAC-SHA256 of the x-sendozi-timestamp header, a full stop and the raw body, keyed with the endpoint's signing secret. Compare it in constant time with x-sendozi-signature and reject mismatches.
- Can the same webhook event arrive twice?
- Yes, when a delivery is retried. The event id is the same each time, so make the handler skip an id it has already processed.
Related reading
Developers
OTP SMS in Nigeria: designing verification that works
How to deliver one-time passwords by SMS in Nigeria: transactional routing, expiry and retry discipline, idempotency, rate limiting, message wording and a working API integration.
SMS
Bulk SMS in Nigeria: the complete guide
How bulk SMS works in Nigeria: sender IDs, transactional and promotional routes, the DND register, page-based pricing, delivery reports and consent. Written for teams sending their first campaign.
SMS
DND and SMS delivery in Nigeria
What Nigeria's Do Not Disturb register is, why promotional SMS fails on DND-active numbers, which traffic legitimately travels a transactional route, and how to diagnose a failed send.