Your dashboard says sent. Your logs say accepted. Support is forwarding screenshots of empty inboxes. You re-send the same payload three times, the provider accepts all three, and still nothing lands on the handset. This is the gap between "the SMSC took your message" and "the phone actually displayed it" — and almost every SMS debugging session that goes sideways starts by collapsing those two states into one.
"Sent" is not "delivered": the four-hop lifecycle
An outbound SMS crosses four hops. Each hop can only honestly report what it controls:
app → provider API → SMSC → carrier → handset
[accepted] [sent] [DLR...] [DELIVRD?]
- queued — your side enqueued the request; nothing has left your process yet.
- accepted / sent — the SMSC acknowledged
submit_sm. The message is in the carrier network's hands. This is the last status many dashboards ever show. - delivered — a delivery report (DLR) came back saying the handset accepted it.
- undelivered / expired / rejected — a DLR (or a terminal error) says it will not arrive.
The operational rule: treat accepted/sent as "handed off," not "done." Anything you build on top of handoff-as-success — confirmation emails, unlock codes, "we texted you" UI — will lie to users the first time a carrier filters the traffic.
State machine you actually want:
queued → sent → delivered
→ undelivered
→ expired
→ rejected
Only move forward. Never overwrite delivered with a late sent.
Reading raw DLR states
Providers that speak SMPP under the hood (most of them, even over HTTP) surface a stat field on the DLR webhook. These are the values that matter and what each one implies you should do:
stat |
Where it died | What to do |
|---|---|---|
DELIVRD |
Nowhere — handset got it | Mark success. Stop. |
EXPIRED |
Handset unreachable for the validity period (off, airplane mode, no signal) | Transient device issue. One delayed retry later is fine; do not hammer. |
UNDELIV |
Carrier could not deliver | Often number/porting/route. Check destination, try alternate route if you have one. |
REJECTD |
Carrier-side policy rejected it | Do not retry identical content. Inspect sender ID, template, content. |
ACCEPTD |
Intermediate ack only | Not terminal. Wait for a final DLR. |
UNKNOWN |
Carrier will not say | Common on some routes. Design UI/ops for "we don't know." Don't invent a status. |
EXPIRED is about reachability over time, not content. REJECTD is almost always policy — content, sender reputation, or destination rules. UNKNOWN is not a bug in your parser; some carriers simply never return a final disposition. Store the raw stat string. Normalizing everything to a three-color enum before it hits the database is how you lose the only signal that explains a route failure three weeks later.
SMPP command_status errors worth knowing even over HTTP
HTTP APIs frequently re-expose SMPP command_status values in an error_code / error_message field when submit_sm itself fails. These fire before any DLR exists — the SMSC refused the submit.
ESME_RTHROTTLED(0x58) — you exceeded the bind-level submit rate. Back off and retry with jitter. Fix: lower sustained send rate or request a higher window. Do not open more binds to "get around" it; you will make throttling worse.ESME_RINVDSTADR(0x0B) — destination address invalid (format, length, or obviously not a mobile number). Drop. Fix the input. Retrying the same MSISDN will fail the same way.ESME_RMSGQFUL(0x14) — SMSC message queue is full. Transient. Retry with exponential backoff. If it persists across minutes, the route is unhealthy — page ops, don't keep dumping traffic into it.ESME_RSUBMITFAIL(0x45) — generic submit failure. Treat as retryable a small number of times, then dead-letter. Correlate with provider status; this one often rides along with upstream carrier outages.
Map on ingest:
0x58 → retry (slow down)
0x0B → drop (fix input)
0x14 → retry (transient)
0x45 → retry then dead-letter
Do not collapse all non-zero command_status values into one provider_error bucket. The retry policy is different for each.
HTTP-era equivalents
Twilio-style error codes show up across the industry as either direct codes or documented equivalents:
30007— Carrier violated / filtered. The carrier rejected the message on content or sender-reputation grounds. This is not a connectivity problem. Retrying the identical body from the same sender ID is worse than useless: it reinforces the reputation hit that got you filtered. Change content, review sender registration, or stop.30003— Unreachable handset. Phone off, out of coverage, or otherwise not accepting traffic right now. Comparable toEXPIRED/UNDELIVterritory. One later retry is reasonable.30005— Unknown destination. Number is not a valid reachable mobile subscriber (landline, decommissioned, wrong country code). Drop and cleanse the list.429— Throttled. Same class asESME_RTHROTTLED. HonorRetry-Afterif present; otherwise exponential backoff with jitter.
The key operational distinction: connectivity failures (30003, EXPIRED) tolerate a patient retry. Filter failures (30007, REJECTD) demand a content/sender change. Automating retries on 30007 is how teams dig the hole deeper.
Building the DLR webhook receiver correctly
Providers retry DLR webhooks that do not return 2xx quickly. If you do heavy work inline — DB joins, downstream HTTP, template rendering — you will double-process. Pattern that holds up:
- Verify signature (next section).
- Enqueue the raw payload.
- Return
200in under two seconds. - Process async with idempotency on
(message_id, stat).
DLRs arrive twice. DLRs arrive out of order. A DELIVRD before your local sent transition is real and common under load. Your state machine only moves forward:
import json
import hashlib
import hmac
import time
from flask import Flask, request, abort
from queue import Queue
app = Flask(__name__)
work = Queue()
# message_id -> terminal-ish status rank
RANK = {
"queued": 0,
"ACCEPTD": 1,
"sent": 1,
"DELIVRD": 5,
"EXPIRED": 5,
"UNDELIV": 5,
"REJECTD": 5,
"UNKNOWN": 4,
}
def advance(current: str | None, incoming: str) -> str:
if current is None:
return incoming
return incoming if RANK.get(incoming, 0) >= RANK.get(current, 0) else current
# stand-in for durable store
STATE: dict[str, str] = {}
SEEN: set[tuple[str, str]] = set()
def process_dlr(payload: dict) -> None:
mid = payload["message_id"]
stat = payload["stat"]
key = (mid, stat)
if key in SEEN:
return
SEEN.add(key)
STATE[mid] = advance(STATE.get(mid), stat)
# fan out to metrics, user-visible status, etc.
@app.post("/webhooks/dlr")
def dlr_webhook():
raw = request.get_data() # raw bytes — keep for HMAC
if not verify_signature(raw, request.headers):
abort(401)
payload = json.loads(raw)
work.put(payload) # async worker calls process_dlr
return {"ok": True}, 200
The receiver pattern above is what I run against a provider I use for transactional sends, whose DLR callbacks include the raw stat value — keeping the original SMPP state in your database beats normalizing it away when you're debugging a route weeks later.
Worker side is a single consumer loop over work. Swap Queue for Redis/SQS in production; the contract stays the same: ack HTTP fast, dedupe on (message_id, stat), only advance.
Verifying the webhook is really from your provider
Unauthenticated DLR endpoints get garbage, and eventually get spoofed statuses that flip user state. Verify HMAC-SHA256 over the raw body bytes with a shared secret. Three hard requirements:
- Use the raw request body, not
json.dumps(parsed)— re-serialization changes key order and whitespace and breaks valid signatures. This is the classic bug. - Compare with
hmac.compare_digest, not==(timing-safe). - Enforce a timestamp tolerance window against replay.
import hmac
import hashlib
import time
import os
SECRET = os.environ["DLR_WEBHOOK_SECRET"].encode()
MAX_SKEW_SEC = 300 # 5 minutes
def verify_signature(raw_body: bytes, headers) -> bool:
ts = headers.get("X-Signature-Timestamp", "")
sig = headers.get("X-Signature", "")
if not ts or not sig:
return False
try:
ts_int = int(ts)
except ValueError:
return False
if abs(time.time() - ts_int) > MAX_SKEW_SEC:
return False
# Many providers: hex(hmac_sha256(secret, f"{ts}." + body))
message = ts.encode() + b"." + raw_body
expected = hmac.new(SECRET, message, hashlib.sha256).hexdigest()
# strip a leading "sha256=" if the provider uses that form
given = sig.removeprefix("sha256=")
return hmac.compare_digest(expected, given)
Reject missing headers, reject skew, reject bad digests — all as 401, before enqueue. Log the failure reason internally; do not echo it to the caller.
What to do with the data: delivery-rate observability
Store each terminal DLR with country, route/sender, stat, and timestamp. Build two ratios, not raw counts:
delivered / (delivered + undelivered + expired + rejected)per country- same ratio per route/sender
A sudden UNDELIV spike isolated to one carrier or route is a route problem — shift traffic, open a ticket with the provider, leave content alone. A uniform drop across routes that coincides with a template or sender-ID change is a content/reputation problem — stop the send, fix the template, do not retry the filtered body.
Alert on the ratio crossing a floor (for example, delivered ratio < 0.92 over a 15-minute window, minimum volume threshold so low-traffic countries do not page you). Alerting on absolute undelivered count fires every time you send more volume.
UNKNOWN should be tracked as its own bucket. Spiking UNKNOWN is a visibility regression, not necessarily a delivery regression — treat it differently in dashboards or you will chase ghosts.
The triage flowchart
"sent" in dashboard, user got nothing
│
├─ Do you have a DLR at all?
│ ├─ NO → check submit errors
│ │ ├─ 0x0B / 30005 → fix MSISDN, drop
│ │ ├─ 0x58 / 429 → slow down, retry
│ │ ├─ 0x14 / 0x45 → retry w/ backoff; if persistent, route down
│ │ └─ none, just "sent"→ DLR path broken (webhook 4xx/slow, sig fail)
│ │
│ └─ YES → read stat / error
│ ├─ DELIVRD → not an SMS issue; check handset/UI
│ ├─ EXPIRED / 30003 → handset unreachable; one delayed retry
│ ├─ UNDELIV → number/route; try alt route, validate porting
│ ├─ REJECTD / 30007 → carrier filter; change content/sender, NO retry
│ └─ UNKNOWN → carrier won't say; check ratio by route, don't invent status
│
└─ Ratios: one-route UNDELIV spike → route
uniform drop after template change → content/sender
Cheat-sheet
accepted/sent= SMSC took it. Not delivered.- Store raw
stat:DELIVRD,EXPIRED,UNDELIV,REJECTD,ACCEPTD,UNKNOWN. EXPIRED/30003→ patient retry.REJECTD/30007→ fix content/sender, never identical retry.0x58throttle,0x0Bbad dest (drop),0x14queue full (retry),0x45submit fail (retry then dead-letter).- Webhook: verify HMAC on raw bytes with
compare_digest, enqueue,200in <2s. - Dedupe
(message_id, stat); state machine only advances. - Alert on delivered ratio per country and per route, not absolute counts.
- One-route spike → route. Uniform drop after template change → content.