Skip to content
Sendozi

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 Sendozi took the request; delivered means a network confirmed the handset received it. The two are hours apart in the worst case, so consume webhooks rather than reading the status once and believing it.

By SendoziUpdated 4 min read

What is an SMS delivery report?

A delivery report - a DLR - is the status a mobile network returns for one message to one recipient. It travels back through the same chain the message went out on, so it arrives after the send, sometimes seconds later and sometimes much later.

The single most useful thing to internalise is that a send has at least two outcomes, not one. The first is whether the platform accepted your request. The second is whether the network delivered it. Systems that treat a 200 response as delivery are wrong in a way that only shows up when a customer says they never got the OTP.

What each status means

StatusMeaningWhat to do
acceptedSendozi validated the request and queued it. Nothing has reached a network yet.Record the message id. Wait for the next state.
submittedHanded to the delivery route. The network now owns it.Nothing. This is normal progress.
deliveredA network confirmed the handset received the message.Close the loop in your system.
failedThe route or the network rejected it, or it expired undelivered.Read the error code. Do not blindly retry.

Every message on Sendozi also carries a status timeline: the sequence of states with timestamps, rather than only the latest one. 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 delivery events to it as they arrive. Polling GET /v1/messages for a status that may take minutes wastes requests, delays your reaction and hits the rate limit on a large campaign.

Registering an endpoint and sending it a test event
cURL
# Register. The signing secret is shown once, at creation.
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/hooks/sendozi",
    "events": ["message.delivered", "message.failed"]
  }'

# Fire a test payload at it before you rely on it.
curl -X POST https://api.sendozi.com/v1/webhooks/endpoints/whk_example/test \
  -H "Authorization: Bearer $SENDOZI_API_KEY"

Writing a receiver that holds up

  1. 1

    Verify the signature before parsing anything

    Your endpoint is a public URL. Compute the expected signature over the raw body with your signing secret and compare in constant time. Reject anything that does not match, and do not log the secret.

  2. 2

    Respond 200 quickly, then do the work

    Acknowledge receipt, queue the event and return. A receiver that runs business logic inline before responding will time out under a campaign.

  3. 3

    Treat delivery as idempotent

    Retries mean the same event can arrive twice. Key on the message id and the event type so a duplicate is a no-op.

  4. 4

    Tolerate out-of-order events

    A delivered event can arrive before the submitted event that preceded it. Use the timestamps in the payload rather than arrival order.

  5. 5

    Never trust the payload as authorisation

    A verified signature proves the event came from Sendozi. It does not turn the payload into permission to act on an unrelated account - look the message up by id against your own records.

A minimal verified receiver. Read the raw body: re-serialised JSON will not match the signature.
Node.js (Express)
import express from "express";
import crypto from "node:crypto";

const app = express();
const SIGNING_SECRET = process.env.SENDOZI_WEBHOOK_SECRET;

// Raw body, not express.json(), so the bytes match what was signed.
app.post("/hooks/sendozi", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.get("x-sendozi-signature") ?? "";
  const expected = crypto.createHmac("sha256", SIGNING_SECRET).update(req.body).digest("hex");

  const provided = Buffer.from(signature);
  const computed = Buffer.from(expected);
  if (provided.length !== computed.length || !crypto.timingSafeEqual(provided, computed)) {
    return res.status(401).end();
  }

  const event = JSON.parse(req.body.toString("utf8"));

  // Acknowledge first; process out of band.
  res.status(200).end();
  queue.add("sendozi-delivery", event).catch((error) => console.error(error));
});
Python (FastAPI)
import hmac
import hashlib
import os
from fastapi import FastAPI, Request, Response

app = FastAPI()
SIGNING_SECRET = os.environ["SENDOZI_WEBHOOK_SECRET"].encode()


@app.post("/hooks/sendozi")
async def sendozi_webhook(request: Request):
    raw = await request.body()
    provided = request.headers.get("x-sendozi-signature", "")
    expected = hmac.new(SIGNING_SECRET, raw, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(provided, expected):
        return Response(status_code=401)

    event = await request.json()
    enqueue_delivery_event(event)  # do the work out of band
    return Response(status_code=200)

Reconciling a campaign

After a large send, the question is rarely how many were delivered. It is which ones were not, and why. Pull the batch, group failures by error code and by network, and act on the categories rather than the total.

Paging through a batch. limit defaults to 50 and is capped at 100.
cURL
curl "https://api.sendozi.com/v1/messages?limit=100" \
  -H "Authorization: Bearer $SENDOZI_API_KEY"

# Then follow next_cursor until has_more is false.
curl "https://api.sendozi.com/v1/messages?limit=100&cursor=2026-08-19T09:14:22.117Z" \
  -H "Authorization: Bearer $SENDOZI_API_KEY"
  • Group failures by error code first. invalid_recipient is a list problem; a network rejection is not.
  • Split by network. A failure rate concentrated on one network is a routing conversation, not a list problem.
  • Compare delivered against sent over time, not at one instant. Late deliveries are normal.
  • Feed hard failures back into the list. A number that fails validation twice should not be tried a third time.
  • Keep the request_id from the send: it is how support traces a specific failure through the platform.

Frequently asked questions

What is the difference between accepted and delivered?
Accepted means Sendozi validated and queued your request. Delivered means a mobile network confirmed the handset received the message. Only the second is proof the recipient could have read it.
How long does a delivery report take?
Usually seconds, but a handset that is off or out of coverage is retried by the network until the message expires, so a final status can take much longer. 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 events. Polling wastes requests, reacts late and consumes the 300-per-minute rate limit during a campaign.
How do I verify a Sendozi webhook is genuine?
Each endpoint has a signing secret, shown once at creation. Compute the signature over the raw request body, compare it in constant time with the value in the signature header, and reject mismatches. Use the endpoint's test call to confirm your receiver before relying on it.
Can the same webhook event arrive twice?
Yes. Retries exist so a temporary outage on your side does not lose an event. Make the handler idempotent by keying on the message id and event type.

Wire it up

The webhook reference lists the events, the payload shape and the test endpoint.