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 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.

By SendoziUpdated 4 min read

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

StatusMeaningWhat to do
queuedSendozi accepted the request and queued the message. It has not reached a network yet.Keep the message_id. Wait.
acceptedHanded to the network. Waiting for its delivery report.Nothing. This is normal progress.
deliveredThe network confirmed the handset received the message.Close the loop in your system.
failedThe network refused or could not deliver it, or it could not be submitted.Read error.code. Do not blindly retry.
expiredNo delivery report arrived within 72 hours.Treat the outcome as unknown.
cancelledThe 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.

Registering an endpoint and sending it a test event
cURL
# 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. 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. 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. 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. 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.

A minimal verified receiver. Sign the raw bytes: re-serialised JSON will not match.
Node.js (Express)
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();
});
Python (FastAPI)
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.

A batch's counts, then every recipient with its outcome
cURL
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.code first. 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.

Wire it up

The webhook reference lists every event, its payload and the retry schedule.