DEV Community

SMSRoute Dev
SMSRoute Dev

Posted on • Originally published at smsroute-cc.github.io

Delivered is not delivered: SMS DLR status callbacks done right

If your app treats status=delivered as “the user got the code,” you have a silent failure mode. An SMS delivery receipt (DLR), status callback, or delivery webhook only reports how far a handoff chain got—not whether a human saw the message.

What the SMS handoff chain actually reports

your API call ──▶ provider accepted ──▶ carrier accepted ──▶ handset ack ──▶ human reads it
     │                   │                     │                  │              │
 2xx accept         "queued"/"sent"        "delivered"       (sometimes)     (never observable)
 (e.g. 201/200)
Enter fullscreen mode Exit fullscreen mode

Treat any 2xx accept (Twilio often returns 201 Created; many providers return 200 OK) as accepted-for-processing, not sent. The status you later see usually reflects the furthest hop that bothered to report back. Some carriers ack when the message enters their network, not when the handset confirms. Others send nothing and your provider synthesizes a value.

Provider-neutral map of raw statuses into three app states:

Provider-ish status Your state Meaning
queued / accepted pending Provider has the message; carrier unknown
sent pending Submitted downstream; not handset-confirmed
delivered confirmed Someone downstream said OK (may be aggregator, not handset)
undelivered / failed / rejected / expired failed Terminal failure when present
no callback pending → settle later Common on some international routes; unknown, not success

Rules of thumb:

  • sent / accepted / queued — your provider has it. Says nothing about the handset.
  • delivered — a downstream hop said OK. On some routes that hop is an aggregator.
  • undelivered / failed / rejected / expired — trustworthy when you get them; absence of failure is not success.
  • No callback — treat as unknown. Do not invent success.

SMS DLR statuses: three states, not two

Model delivery as pending | confirmed | failed. Make pending first-class with a deadline—not an optimistic “it’ll be fine.”

STATUS_MAP = {
    "delivered":   "confirmed",
    "undelivered": "failed",
    "failed":      "failed",
    "rejected":    "failed",
    "expired":     "failed",
    "sent":        "pending",
    "queued":      "pending",
    "accepted":    "pending",
}

# Higher wins when two events race. Terminal failed must not flip back.
STATUS_PRIORITY = {
    "pending": 1,
    "confirmed": 2,
    "failed": 3,
}

def map_status(raw: str) -> str:
    # Unknown provider strings stay pending — never auto-confirm.
    return STATUS_MAP.get(raw, "pending")
Enter fullscreen mode Exit fullscreen mode

Mapping unknown strings to pending (never confirmed) is the whole trick. Providers add statuses; a permissive default turns new failure modes into fake successes.

Idempotent SMS status webhooks

Delivery webhooks are public endpoints that assert things about billing and auth. Assume at-least-once delivery: duplicates are normal. Not every provider sends a stable event_id—if they don’t, derive one.

import hashlib

def event_key(evt) -> str:
    if getattr(evt, "event_id", None):
        return f"eid:{evt.event_id}"
    # Fallback when the provider omits event_id: hash stable fields.
    basis = f"{evt.message_id}|{evt.status}|{evt.timestamp}|{getattr(evt, 'error_code', '')}"
    return "h:" + hashlib.sha256(basis.encode()).hexdigest()

def handle(request):
    if not verify_signature(request.headers.get("X-Signature"), request.body, SECRET):
        return 403

    try:
        evt = parse(request.body)
    except ValueError:
        return 400  # poison payload: do not 2xx or the provider will retry forever

    key = event_key(evt)
    if store.seen(key):
        return 200  # authentic duplicate — ack only

    if not should_apply(evt):
        store.mark_seen(key)
        return 200  # stale / lower-priority — ack, do not regress

    apply(evt)  # prefer queue + worker; keep the HTTP path fast
    store.mark_seen(key)
    return 200
Enter fullscreen mode Exit fullscreen mode

Return 2xx only for authentic traffic you have fully absorbed (including duplicates and deliberate no-ops). 403 on bad signature. 400 on unparseable bodies so bad payloads do not retry forever. At-least-once means you will see the same logical event twice; without idempotency keys you double-apply side effects (notifications, analytics, state flips).

Out-of-order DLR callbacks without clock-skew bugs

A late delivered must not revive a message you already marked failed. Comparing timestamps alone is wrong: clock skew, equal timestamps, and provider sequence gaps all break it.

Prefer this order:

  1. Provider monotonic sequence / sid / event cursor when the API gives you one.
  2. Else (message_id, status priority) with queued/sent/accepted → pending < delivered → confirmed < undelivered/failed/rejected/expired → failed, and terminal failed sticks.
  3. Only then timestamp with skew tolerance (e.g. ignore a “newer” event if it is within a few seconds and lower priority).
SKEW_SECS = 30

def should_apply(evt) -> bool:
    cur = store.get(evt.message_id)
    if cur is None:
        return True

    # 1. Provider sequence / sid beats wall clock
    if evt.sequence is not None and cur.last_sequence is not None:
        if evt.sequence < cur.last_sequence:
            return False
        if evt.sequence == cur.last_sequence:
            return False  # already applied

    new_state = map_status(evt.status)
    cur_pri = STATUS_PRIORITY[cur.state]
    new_pri = STATUS_PRIORITY[new_state]

    # 2. Terminal failed sticks; never confirm after fail
    if cur.state == "failed" and new_state != "failed":
        return False

    # Same or lower priority without a newer sequence → no-op
    if new_pri < cur_pri:
        return False

    # 3. Timestamp only as tie-break, with skew tolerance
    if evt.sequence is None and new_pri == cur_pri:
        if evt.timestamp <= cur.last_timestamp + SKEW_SECS:
            return False

    return True

def apply(evt):
    new_state = map_status(evt.status)
    store.update(
        evt.message_id,
        state=new_state,
        raw=evt.status,
        last_timestamp=evt.timestamp,
        last_sequence=evt.sequence,
    )
Enter fullscreen mode Exit fullscreen mode

You will eventually see delivered after failed for the same message_id on messy routes. Priority + sticky failure keeps auth and UX honest.

OTP fallback when DLR stays pending

Do not wait forever for a failure callback that may never arrive. On send, start a timer. 30–60 seconds is a UX default for OTP, not a protocol constant—measure p95 DLR latency per route and country and tune from there.

# UX default for OTP; replace 45 with measured p95 DLR latency for the route.
schedule(check_in=timedelta(seconds=45), message_id=msg.id)

def on_timer(msg_id):
    msg = store.get(msg_id)
    if msg.state != "pending":
        return

    # Auth-critical: pending-past-deadline is not "maybe still ok".
    invalidate_or_rotate_challenge(msg.session_id, msg.otp_id)
    store.update(msg_id, state="failed", raw="timeout_pending")

    offer_alternative(
        msg.user_id,
        channels=["voice", "email", "authenticator"],
    )
Enter fullscreen mode Exit fullscreen mode

If the DLR later says failed, or the timer fires while still pending, invalidate or rotate the OTP / session challenge before you offer voice, email, or “resend.” Offering another channel while the original SMS code remains valid is an auth bug: the code can still arrive late on a delayed route and be used alongside the fallback. Same rule on terminal failed from the webhook—burn the challenge, then re-issue on the new channel.

Pending-with-deadline gives you a hook for that fallback. A two-state “sent / delivered” model does not.

Reconcile pending DLRs; do not trust webhooks alone

Webhooks get lost—dropped during deploys, blackholed by firewall rules, swallowed as 500s. Once a day (or hourly for OTP-heavy apps), query your own store and the provider API for stragglers.

-- Everything still pending past the UX/SLA window
SELECT message_id, provider_id, sent_at, raw_status
FROM sms_messages
WHERE state = 'pending'
  AND sent_at < NOW() - INTERVAL '15 minutes';
Enter fullscreen mode Exit fullscreen mode

Settle rule:

def reconcile_row(row, provider_truth):
    if provider_truth is None:
        # No record upstream after N days → unknown, not delivered
        if row.sent_at < days_ago(7):
            store.update(row.message_id, state="failed", raw="reconcile_unknown")
        return

    state = map_status(provider_truth.status)
    # Same priority rules as live webhooks; still no confirm-after-fail
    fake = type("E", (), {
        "message_id": row.message_id,
        "status": provider_truth.status,
        "timestamp": provider_truth.timestamp,
        "sequence": provider_truth.sequence,
        "event_id": f"reconcile:{row.message_id}:{provider_truth.status}",
    })()
    if should_apply(fake):
        apply(fake)
Enter fullscreen mode Exit fullscreen mode

Mark stubborn unknowns as failed (or a distinct unknown if you surface that in ops) after a bounded window—e.g. 7 days—not as confirmed. Reconciliation is a real query plus the same state machine, not a vague “thirty-line cron.”

Why SMS fails in ways no status explains

A chunk of international non-delivery is regulatory or policy, not a transport error. Many countries require pre-registration of alphanumeric sender IDs. On some routes and aggregators, DLRs can still look positive while the terminating network drops unregistered alphas. Other networks ban alphanumeric senders and rewrite them to short codes or generic numbers—users then ignore the message because the sender looks wrong. That is separate from encoding failures (UCS-2 vs GSM-7 truncating or mojibaking the body), which often show up as a different class of reject or a “delivered” message the user cannot read.

If delivery is fine in nine countries and terrible in the tenth, check sender-ID registration and encoding requirements for that destination before you rewrite retry logic. Soft signal: a clean upstream receipt does not prove the handset displayed your brand or your bytes.


PS. If you want a provider that exposes these statuses cleanly for API use, smsroute.cc is a no-KYC crypto-payment SMS API with free credits—useful for wiring the pending/confirmed/failed model above without a long procurement cycle. The state machine, webhook rules, and OTP invalidation apply regardless of vendor.

Top comments (0)