Message status transition rules
Every outbound message advances through statuses according to a fixed set of allowed transitions — a directed acyclic graph (DAG) that the platform enforces centrally, so the delivery-receipt (DLR) pipeline, campaign counters, and your webhook subscribers all agree on one answer to “can the row move from X to Y?” When you mirror statuses into your own datastore, this page is the merge contract you need to reproduce; guessing it produces integrations that drop real corrections or let stale events resurrect closed rows. The delivery lifecycle page explains what each status means and who writes it. The message status lifecycle reference lists the per-status semantics and webhook-event map. This page is the rules layer underneath both: which transitions are legal, how the platform resolves competing updates, and what your own merge logic must never do.The status ladder, end to end
Readqueued, undelivered, and failed as one funnel, not as three unrelated statuses — and read the failure-queue fallback as the legitimate end of the failing leg:
queued— platform-side hold. The row has not been handed to a sender or provider yet, and the hold is on your side of the handoff, not carrier-side. It stays fully visible: the dashboard Delivery Log filters it withdirection=outbound&status=queued, andGET /api/v1/messages?status=queuedlists the same rows over the API. Diagnose the hold with the enqueued-empty-states checklist — a filter artifact, a queue with no worker draining it, or a gate you can clear.undelivered— carrier tried, handset not reached. The send escaped the queue, the carrier attempted delivery, and the handset could not take the message. On a cascaded send this terminal state (along withfailedandrejected) is exactly what triggers the legitimate fallback hop — a brand-new send on the next chain channel, which re-enters the queue rather than corrupting the failed row (Cascade failover policy). Without a chain,undeliveredcloses the row. The cause-to-fix decoder is on Message undelivered or failed.failed— retries exhausted, row closed. A dispatch-time or classified carrier failure: the row ran through its retry allowance, the failure fields (metadata.classified_error_code, plus the carrier’s rawerror_code/error_message) recorded what stopped it, and the settle rules in this page are what close it rather than leave it retrying forever. Map the class before retrying with the same Message undelivered or failed decoder — a permanent-cause class retries uselessly.- The fallback leg. If a cascade chain armed the send, a terminal failure is not the end: the fallback hop is a fresh message under the same
message_group_id, fully backed by sender re-resolution, consent re-checks, and its own queue entry. Distinguish that designed reroute from an undesigned stall — the ladder (queue visibility → cascade fallback → exhaustion) tells you which of the two you are looking at.
undelivered vs failed split is the diagnostic that decides what to fix: undelivered points at the recipient (handset unreachable, number hygiene) and failed points at the dispatch or the classified cause the failure fields name. The full decoder is on Message undelivered or failed, and late-arriving receipts — the fourth way rows close — are covered on DLR retry and late-receipt recovery.
Order-preserving vs. recoverable transitions
Status order is resolved per message id: every update targets one row, and each candidate transition is judged independently against the row’s current status. A transition lands in one of three classes:- Allowed (order-preserving). The row moves forward —
queued → sending → sent → delivered → read, the scheduled lifecyclescheduled → queued, and the carrier-rejection fan-outsent → failed | rejected | undelivered. These apply and fire the matching webhook event. - Recoverable (allowed backward corrections). A small set of backward moves is explicitly legal because carriers contradict themselves:
delivered → undeliveredanddelivered → failedare the carrier-correction allowance (some carriers — notably Indian and Brazilian routes — emit DELIVERED, then re-emit UNDELIV minutes later when a longer-tail failure surfaces). The platform accepts the correction and lets the later truth win.submitted_no_receipt → delivered | read | failed | undelivered | rejectedis the same idea: the no-DLR sentinel is provisional, so a genuine receipt that lands afterward still overwrites it. - Forbidden (ignored). Any transition the DAG does not list is dropped without touching the row. Self-loops never apply — a duplicate
deliveredfor a row already atdeliveredis filtered as a duplicate, not applied as a no-op write. Regressions likedelivered → sentordelivered → queued(stale duplicate receipts arriving after the row advanced) are rejected the same way.
Three rules your own merge logic must copy:
- Never move a row backward except through the named recoverable transitions. A webhook that reports
sentfor a message you already recorded asdeliveredis a duplicate, not new information. - Never resurrect a row off a floor.
deletedandcancelledhave no path back to a carrier-driven state — floor rule number two below. - Dedupe before you merge, not after. The same event redelivered (at-least-once delivery — see webhook delivery semantics) must not re-apply a transition you already recorded.
Precedence when updates compete
Webhooks arrive at-least-once and can be reordered by the network, so precedence is decided by the status position, not by arrival order. Think of each status as carrying a weight along the lifecycle; the row keeps the highest-weight legitimate update:queued(0) < sending(1) < sent(2) < submitted_no_receipt(2.5) < failed/rejected/undelivered/expired(3) < delivered(4) < read(5)
With three overlays:
- Pending vs. terminal.
submitted_no_receiptsits betweensentanddeliveredon purpose: the no-DLR sentinel parks the row at “accepted, outcome unknown,” and it reportsis_terminal: falseon webhooks precisely because a later genuinedelivered,read, or failure still supersedes it. If your integration freezes on the first terminal-looking event, you will mis-record these rows — merge only when the incoming event outranks what you hold, and treatsubmitted_no_receiptas pending, never as final. - Higher weight wins, later events included. A
deliveredthat arrives after you recordedfailed(weight 4 over 3) orsubmitted_no_receipt(4 over 2.5) is forward progression and must overwrite — that is how slow carrier receipts correct the record. The reverse (failedover a recordeddelivered) is not forward by weight; it applies only because it is one of the named carrier-correction transitions. Without the transition-table check beside the weight check, a weight-only merge lets any stale high-weight duplicate rewrite history and any stale low-weight duplicate regress it — you need both. - Two absolute floors.
deletedandcancelledoutrank everything and accept nothing except the operator-driven→ deleted(forcancelled). Once a row is on either floor, no provider callback — however fresh or high-weight — moves it. If you expose cancel or delete in your own UI, close the row locally at that moment and drop any later events for that message id.
unknown (an unmapped provider status string) carries no weight at all, so it never overwrites a real state — and every real state can overwrite it forward. Treat unknown rows in your mirror as awaiting mapping, not as outcomes.
Late terminal events
Two terminal classes regularly arrive after you would expect the book to be closed: The late receipt after the no-DLR reminder. On SMPP-backed channels (SMS, MMS, voice, fax, RCS) a row stuck past the 30-minute grace window is promotedsent → submitted_no_receipt and fanned out as a message.failed event with status: "submitted_no_receipt", state_class: "intermediate", is_terminal: false — a reminder that submission was accepted but no receipt came. It is not terminal: a genuine DELIVRD that lands afterward still moves the row to delivered, and your subscribers will see a second event for the same message id. Handle it by letting the later message.delivered supersede — exactly the merge rule above.
The receipt past the envelope-wide cutoff. Past the late-arrival window, an incoming DLR no longer rewrites the outcome — the row settles as expired, a terminal “receipt arrived too late to count” floor that fans out as message.failed. expired outranks only in-flight states; it never displaces a delivered or read the carrier already confirmed. One exception exists by design: in-flight email rows recover from a send-time transient failure, so on email a genuine later terminal failure (an async bounce or complaint) is allowed to overwrite a delivered-equivalent position — the platform treats that late bounce as the authoritative truth, with metadata markers on the row to prove it.
Carrier corrections (delivered → undelivered / delivered → failed) follow the same “later truth wins” policy — they are accepted, not rejected as regressions, so mirror them too rather than pinning the first terminal state you saw.
Compute from failure fields, not from status alone
Terminal failures carry two machine-readable fields you should compute on before alerting or retrying:metadata.classified_error_code— the normalized, machine-readable failure category, stable across providers. Branch on this to drive policy: route permanent-cause classes (invalid recipient, opt-out, content blocks) to suppression or recipient-cleanup flows, and let only transient-cause classes retry. Provider-specific codes belong in logs, not in branching logic.error_code/error_message— the carrier’s own raw code and human-readable text, mirrored from the provider payload and present only when the provider supplied a reason. Use them for support tickets and carrier escalations, where the original wording matters; never as your programmatic switch.
message.failed payloads ever carry these fields — and a message.failed with no provider reason omits them entirely, which is itself a signal (an expiry or a no-receipt promotion, not a carrier rejection).
Worked examples
A complete websocket-style timeline for one SMS that hits the slow-receipt case — note the samemessage_id throughout and the intermediate flag on the reminder:
delivered; reorder the last two events and it still ends at delivered; replay any event and nothing changes. That is the property to test your own receiver for.
See also
- The scheduled-sending model — the unified schedule-park-gate-fire-bill anchor these transitions fit into
- Delivery lifecycle — what each status means and who writes it
- Message status lifecycle reference — per-status semantics and webhook-event map
- Message status map — the four owners in one index, with a worked reconciliation walkthrough
- Webhook event types — payload shapes for every message event
- Webhook delivery semantics — at-least-once delivery, retries, and dedupe windows
- Operational sentinels — the settled-but-non-outcome statuses beside this DAG