"Delivered" doesn't mean delivered: reading SMS status callbacks honestly
If your application logs status=delivered and you treat that as "the human read the code," you have a silent failure mode. SMS delivery receipts (DLRs) are a chain of handoffs, and every link can report success while the
If your application logs status=delivered and you treat that as "the human read the code," you
have a silent failure mode. SMS delivery receipts (DLRs) are a chain of handoffs, and every link
can report success while the message never lands on a handset.
What the chain actually looks like
your API call ──▶ provider accepted ──▶ carrier accepted ──▶ handset ack ──▶ human reads it
│ │ │ │ │
201 Created "queued"/"sent" "delivered" (sometimes) (never observable)
The status you receive usually reflects the furthest point that bothered to report back. Several
carriers return a positive receipt when the message enters their system, not when the phone
acknowledges it. Some return nothing at all and your provider synthesises a value. So:
-
sent/accepted— your provider has it. Says nothing about the carrier. -
delivered— someone downstream said OK. Depending on the route, that "someone" may be an aggregator, not the handset. -
undelivered/failed— reliable when present, but absence of failure is not success. - no callback at all — extremely common on some international routes. Treat as unknown, not good.
Design rule: three states, not two
Model status as pending | confirmed | failed, and make pending a first-class state with a
deadline rather than an optimistic assumption.
STATUS_MAP = {
"delivered": "confirmed",
"undelivered": "failed",
"failed": "failed",
"rejected": "failed",
"expired": "failed",
"sent": "pending",
"queued": "pending",
"accepted": "pending",
}
def on_callback(evt):
state = STATUS_MAP.get(evt.status, "pending") # unknown status → pending, never confirmed
store.update(evt.message_id, state=state, raw=evt.status, at=evt.timestamp)
Mapping unknown strings to pending rather than confirmed is the entire trick. Providers add new
status values; a permissive default silently turns new failure modes into fake successes.
Handle callbacks like the hostile input they are
Delivery webhooks are public endpoints that assert things about your money and your auth flow.
def handle(request):
if not verify_signature(request.headers["X-Signature"], request.body, SECRET):
return 403 # 1. authenticate
evt = parse(request.body)
if store.seen(evt.event_id):
return 200 # 2. idempotent: retries are guaranteed
if evt.timestamp < store.last_timestamp(evt.message_id):
return 200 # 3. out-of-order: don't regress state
apply(evt)
return 200 # 4. ack fast, process async
At-least-once delivery is the norm — you will get the same event twice, and you will get a
delivered after a failed for the same message. Without the ordering guard, a late callback can
flip a correctly-failed message back to confirmed.
The fallback that actually helps users
Don't wait for a failure callback that may never come. Set a timer at send time:
schedule(check_in=timedelta(seconds=45), message_id=msg.id)
def on_timer(msg_id):
if store.get(msg_id).state == "pending":
offer_alternative(...) # voice call, email, authenticator app, "try again"
Forty-five seconds of a spinner is the ceiling before people leave. A pending-with-deadline model
gives you a place to hang that fallback; a two-state model doesn't.
Reconcile, don't trust
Once a day, pull the provider's own record for anything still pending past its window and settle
it. Webhooks get lost — dropped by your load balancer during a deploy, blackholed by a firewall
rule, silently 500'd during an incident. A reconciliation job is thirty lines and turns "we think
it worked" into "we checked."
Why messages fail in ways no status explains
A meaningful share of international non-delivery isn't a technical failure at all — it's a
regulatory one. Many countries require pre-registration of alphanumeric sender IDs before
traffic is accepted; unregistered senders get filtered, often with a positive-looking receipt
upstream. Others ban alphanumeric senders entirely and rewrite them.
We publish what we verify about those rules as an open dataset — regime, registration requirement,
regulatory basis, last-verified date, plus the encoding class per country:
github.com/SMSRoute-cc/sms-sender-id-regulations (CC-BY-4.0, JSON + CSV)
If your delivery rate is fine in nine countries and terrible in the tenth, check the registration
column before you rewrite your retry logic.
I work on smsroute.cc — the dataset above comes out of operating these
routes. The patterns here are provider-agnostic; MIT-licensed clients if useful:
Python, Node.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.