The API response is not the delivery status
When your send request succeeds, you usually know one thing: the messaging platform accepted your request. Delivery is asynchronous. The carrier may accept, delay, filter, expire, reject, or report a status later. Your application needs SMS delivery tracking webhooks because the truth changes after the initial API response.
Twilio describes outbound status callbacks as a way to track status changes through the message lifecycle. Vonage notes that a successful SMS API response means a message was queued, not necessarily delivered, and that delivery receipts vary in reliability by market and receipt type.
If your database only has sent = true, your support team has no delivery tracking. It has a guess.
That same event stream can power dashboards for OTP success rates, delayed messages, fallback usage, and support timelines, as long as each callback is stored idempotently and tied back to the original send request.
Design the send side first
Delivery tracking is easier when the original transactional SMS request already carries a use case, template key, message metadata, idempotency key, and stable message ID.
Read the transactional SMS API guideSMS delivery tracking webhooks vs inbound SMS webhooks
SMS webhook searches often mix three related ideas: delivery tracking webhooks, inbound SMS receive webhooks, and OTP verification events. They should not all update the same field. A delivery callback explains what happened after your app sent a message. An inbound webhook captures a user reply. A verification event records whether the user completed, retried, expired, or abandoned an OTP flow.
| Webhook type | What it tells you | Where to store it |
|---|---|---|
| Delivery status callback | The provider or carrier changed the outbound message status, such as sent, delivered, failed, expired, or unknown. | Message delivery timeline. |
| Inbound receive webhook | A recipient replied to a number, short code, or sender that can receive messages. | Conversation, reply, opt-out, or support intake record. |
| OTP verification event | A user requested, retried, checked, completed, failed, or timed out a verification attempt. | Verification attempt timeline. |
| Retry event | Your platform or provider retried delivery, resent an OTP, or reprocessed a webhook callback. | Retry log linked to the original message or attempt. |
Keeping those timelines separate makes dashboards easier to trust. Support can see whether an OTP failed because the SMS was not delivered, because the user entered the wrong code, because a fallback path was used, or because an inbound reply belongs to a different workflow entirely.
What makes SMS webhooks and status callbacks reliable?
If you are comparing SMS APIs for reliable webhooks and status callbacks, do not judge reliability by the send response alone. Test how the provider reports accepted, sent, delivered, failed, expired, and unknown states, how quickly callbacks usually arrive, and whether retries or duplicate callbacks can be tied back to the original message ID.
Delivery receipts are provider and carrier signals, not proof that a human read the message. The practical goal is visibility: your product and support teams should know whether a message was queued, handed off, reported delivered, failed, delayed, or still missing a final state.
| Question | Why it matters for delivery | What to store |
|---|---|---|
| Which delivery states are exposed? | A useful callback feed should distinguish accepted, sent, delivered, failed, expired, rejected, and unknown states. | Normalized status, raw provider status, timestamp, and final-state flag. |
| How are retries reported? | Retries can create duplicate or delayed events, especially when providers retry webhook delivery after an endpoint error. | Idempotency key, provider message ID, retry count, and callback attempt timestamp. |
| How fast do callbacks arrive? | Status latency affects OTP fallbacks, support timelines, and dashboards for time-sensitive account alerts. | Send time, callback received time, provider event time, and callback latency. |
| Can dashboards filter by country? | Country-level views help you spot market-specific delivery changes without mixing them into global traffic. | Destination country, sender identity, template, route, provider, and final outcome. |
| What happens when no final receipt arrives? | Some messages may stay unknown, so the product needs a safe fallback and support explanation. | Unknown state, timeout window, fallback offered, and user outcome. |
A strong delivery dashboard should separate message delivery from user completion and still let teams filter by country. For OTPs, track whether the user verified after the message was sent. For account alerts, track whether the alert record has enough evidence for support to explain what happened without overclaiming that the recipient saw the SMS.
Normalize provider events into your own model
Every provider has its own payload names, status vocabulary, retry behavior, and error codes. Store the raw payload, but do not make the rest of your product depend on raw provider fields. Normalize events into a compact lifecycle that your app understands.
| Normalized status | Typical provider meaning | Final? |
|---|---|---|
| accepted | API request accepted or message queued. | No |
| sent | Message handed to the downstream network or messaging channel. | No |
| delivered | Provider or carrier received a successful delivery receipt. | Usually |
| undelivered | Delivery receipt says the handset or destination was not reached. | Yes |
| failed | Provider could not send, route, or process the message. | Yes |
| expired | Carrier retry window ended before delivery. | Yes |
| rejected | Carrier, provider, or policy rejected the message. | Yes |
| unknown | No useful final state is available. | Maybe |
Do not assume events arrive exactly once or in perfect order. Webhook systems retry, networks fail, and providers can add fields over time. Design your event processor to be idempotent and tolerant of extra payload data.
The records worth storing
You need two levels of storage: the current message summary for quick reads, and an append-only event timeline for audit and debugging. The summary powers dashboards and product state. The timeline explains how the message got there.
| Field | Where | Why |
|---|---|---|
| message_id | messages | Stable internal ID used by your app. |
| provider_message_id | messages | Lets you reconcile with provider logs and support. |
| recipient_hash | messages | Useful for debugging without exposing phone numbers broadly. |
| destination_country | messages | Delivery behavior and rules vary heavily by country. |
| sender_identity | messages | Separates 10DLC, toll-free, short code, sender ID, or route behavior. |
| template_key | messages | Helps detect template-specific filtering or copy problems. |
| current_status | messages | Fast product reads and support filtering. |
| event_id | message_events | Webhook deduplication when the provider supplies a unique ID. |
| status | message_events | The lifecycle state from each callback. |
| error_code | message_events | Debugging, alerting, and provider support escalation. |
| raw_payload | message_events | Future-proof audit trail when mappings change. |
| received_at | message_events | Your system time, separate from provider event time. |
create table sms_messages (
id text primary key,
provider_message_id text,
recipient_hash text not null,
destination_country text,
sender_identity text,
template_key text,
current_status text not null,
created_at timestamptz not null,
updated_at timestamptz not null
);
create table sms_message_events (
id text primary key,
message_id text not null references sms_messages(id),
provider_event_id text,
status text not null,
error_code text,
provider_occurred_at timestamptz,
raw_payload jsonb not null,
received_at timestamptz not null
);Build the webhook handler like an ingestion pipeline
Twilio's webhook security docs recommend HTTPS and signature validation, and warn that webhook parameters can evolve. That is the right shape for any provider integration: validate authenticity, preserve the raw request, map only the fields you understand, and do not break when new fields appear.
- Receive the raw request body before any middleware mutates it.
- Verify the provider signature using the exact URL, headers, and raw body or form parameters required by that provider.
- Reject invalid signatures before writing state.
- Map the provider message ID and status into your normalized lifecycle.
- Deduplicate by provider event ID when available, otherwise by provider message ID, status, and provider timestamp.
- Write the event and update the current summary in one transaction.
- Return 2xx only after the event is safely stored.
- Send unknown statuses to a dead-letter or review queue instead of dropping them.
async function handleSmsWebhook(request: Request) {
const rawBody = await request.text();
const signature = request.headers.get("x-provider-signature");
if (!verifyWebhookSignature({ rawBody, signature, url: request.url })) {
return new Response("invalid signature", { status: 401 });
}
const event = parseProviderPayload(rawBody);
const normalized = normalizeSmsEvent(event);
await db.transaction(async (tx) => {
await tx.smsMessageEvents.upsert({
providerEventId: normalized.providerEventId,
messageId: normalized.messageId,
status: normalized.status,
errorCode: normalized.errorCode,
rawPayload: event
});
await tx.smsMessages.updateCurrentStatus(normalized.messageId, normalized.status);
});
return new Response("ok", { status: 200 });
}Handle duplicates and out-of-order events
Webhook delivery is usually at-least-once. That means duplicate callbacks are normal. If your handler increments counters, sends user notifications, or triggers fallbacks on every callback without deduplication, one provider retry can become a product bug.
Out-of-order events are just as important. You might receive accepted after delivered, or a delayed intermediate state after a final state. Keep an explicit status precedence model so old intermediate events do not downgrade a message that already reached a final state.
| Problem | Bad behavior | Better behavior |
|---|---|---|
| Duplicate delivered event | Send the user two success notifications. | Upsert by event ID and make side effects idempotent. |
| Late sent event after failed | Change the message back to in progress. | Store the event but keep the final summary state. |
| Unknown provider status | Throw 500 forever or drop it silently. | Store raw payload, mark unknown, and alert engineering. |
| Provider outage | Lose callbacks during downtime. | Return non-2xx only when storage failed and rely on provider retries plus reconciliation jobs. |
Give support a real delivery timeline
The best delivery tracking work shows up in support. A support agent should be able to answer: when did the user request the SMS, what number was used, which sender identity sent it, what did the provider say, what did the carrier say, did we retry, and what should the user do next?
- Show timestamps in the user's local timezone and UTC.
- Mask phone numbers by default, with audited reveal access for trusted support roles.
- Translate provider errors into plain-language support notes.
- Link every message to the product action that triggered it.
- Expose country, sender identity, provider, and template so patterns are visible.
- Make it easy to copy a provider message ID for escalation.
This is also where product analytics becomes useful. If OTP completion drops in one country, your webhook data can show whether users are failing to request codes, carriers are rejecting messages, or receipts are delayed.
Use tracking to improve OTP completion
For verification flows, connect webhook events to resend timing, abuse checks, fallback choices, and the final verification outcome so OTP reliability is measured by completed logins, not accepted sends.
Read the OTP delivery guideSMS webhook FAQ
Which SMS API has reliable webhooks and status callbacks?
Choose an SMS API by testing its callback lifecycle, not by the send response alone. Look for clear accepted, sent, delivered, failed, expired, and unknown states; idempotent retry handling; provider message IDs; country filters; and raw payload storage for support review.
Should webhook handlers return 200 immediately?
Return 2xx only after you have validated and durably stored the event. If storage fails, a non-2xx response lets the sender retry instead of losing the event.
Do I need to store raw webhook payloads?
Yes. Store them with access controls. Raw payloads help when provider fields change, mappings are wrong, support escalates, or you need to reconcile with provider logs.
Can I trust delivered as a final truth?
Use delivered as the best available delivery signal, but avoid wording that guarantees a human saw the message. Providers document cases where delivery receipt certainty varies.
Should I poll instead of using webhooks?
Polling can be useful for reconciliation, but webhooks should be the primary path for timely delivery updates. A nightly reconciliation job can catch missed or inconsistent events.