SMSRoute engineering notes

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?]

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.

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:

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:

  1. Verify signature (next section).
  2. Enqueue the raw payload.
  3. Return 200 in under two seconds.
  4. 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:

  1. 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.
  2. Compare with hmac.compare_digest, not == (timing-safe).
  3. 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:

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


From the SMSRoute engineering blog. More notes at /writing.