Skip to main content

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.

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 lifecycle scheduled → queued, and the carrier-rejection fan-out sent → 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 → undelivered and delivered → failed are 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 | rejected is 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 delivered for a row already at delivered is filtered as a duplicate, not applied as a no-op write. Regressions like delivered → sent or delivered → queued (stale duplicate receipts arriving after the row advanced) are rejected the same way.
The full allowed set per source status: Three rules your own merge logic must copy:
  1. Never move a row backward except through the named recoverable transitions. A webhook that reports sent for a message you already recorded as delivered is a duplicate, not new information.
  2. Never resurrect a row off a floor. deleted and cancelled have no path back to a carrier-driven state — floor rule number two below.
  3. 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_receipt sits between sent and delivered on purpose: the no-DLR sentinel parks the row at “accepted, outcome unknown,” and it reports is_terminal: false on webhooks precisely because a later genuine delivered, 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 treat submitted_no_receipt as pending, never as final.
  • Higher weight wins, later events included. A delivered that arrives after you recorded failed (weight 4 over 3) or submitted_no_receipt (4 over 2.5) is forward progression and must overwrite — that is how slow carrier receipts correct the record. The reverse (failed over a recorded delivered) 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. deleted and cancelled outrank everything and accept nothing except the operator-driven → deleted (for cancelled). 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.
A defensive corner: 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 promoted sent → 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.
Successful transitions clear any prior failure reason, so only 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 same message_id throughout and the intermediate flag on the reminder:
And a minimal JavaScript merge that implements the contract — weight plus the recoverable-transition table, floors first, dedupe by event id:
Feed it the timeline above and the row ends at 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