> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Message status transition rules: the DAG every consumer must agree on

> The allowed message-status transitions, the precedence rules that resolve out-of-order and late-arriving delivery receipts, and the floors that closed rows never leave — the merge contract your own status reconciliation must mirror.

# 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](/concepts/delivery-lifecycle) page explains what each status means and who writes it. The [message status lifecycle reference](/api-reference/messages-status-lifecycle) 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:

| From                   | Allowed next states                                                                                                                                                            |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `pending`              | `scheduled`, `queued`, `accepted`, `sending`, `sent`, `submitted_no_receipt`, `test_sent`, `delivered`, `failed`, `rejected`, `undelivered`, `expired`, `cancelled`, `deleted` |
| `scheduled`            | `queued`, `cancelled`, `expired`, `deleted`                                                                                                                                    |
| `queued`               | `accepted`, `sending`, `sent`, `submitted_no_receipt`, `test_sent`, `delivered`, `failed`, `rejected`, `undelivered`, `expired`, `cancelled`, `deleted`                        |
| `accepted`             | `sending`, `sent`, `submitted_no_receipt`, `test_sent`, `delivered`, `failed`, `rejected`, `undelivered`, `expired`, `cancelled`, `deleted`                                    |
| `sending`              | `sent`, `submitted_no_receipt`, `test_sent`, `delivered`, `failed`, `rejected`, `undelivered`, `expired`, `cancelled`, `deleted`                                               |
| `sent`                 | `delivered`, `read`, `submitted_no_receipt`, `failed`, `rejected`, `undelivered`, `expired`, `cancelled`, `deleted`                                                            |
| `submitted_no_receipt` | `delivered`, `read`, `failed`, `undelivered`, `rejected`, `expired`, `deleted`                                                                                                 |
| `test_sent`            | `deleted`                                                                                                                                                                      |
| `delivered`            | `read`, `undelivered`, `failed`, `expired`, `deleted` — the second and third are carrier corrections                                                                           |
| `read`                 | `deleted`                                                                                                                                                                      |
| `undelivered`          | `deleted`                                                                                                                                                                      |
| `rejected`             | `deleted`                                                                                                                                                                      |
| `failed`               | `deleted`                                                                                                                                                                      |
| `expired`              | `deleted`                                                                                                                                                                      |
| `cancelled`            | `deleted`                                                                                                                                                                      |
| `unknown`              | any concrete state — a defensive sentinel for unmapped provider statuses; a later recognizable callback enriches it forward                                                    |
| `deleted`              | *(none — hard terminal floor)*                                                                                                                                                 |

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](/concepts/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:

```json theme={null}
{"type":"message.created","data":{"message_id":"msg_9f2","status":"queued"}}
{"type":"message.sent","data":{"message_id":"msg_9f2","status":"sent","state_class":"intermediate","is_terminal":false}}
{"type":"message.failed","data":{"message_id":"msg_9f2","status":"submitted_no_receipt","state_class":"intermediate","is_terminal":false}}
{"type":"message.delivered","data":{"message_id":"msg_9f2","status":"delivered","state_class":"terminal","is_terminal":true}}
```

And a minimal JavaScript merge that implements the contract — weight plus the recoverable-transition table, floors first, dedupe by event id:

```js theme={null}
const WEIGHT = {
  queued: 0, sending: 1, sent: 2, submitted_no_receipt: 2.5,
  failed: 3, rejected: 3, undelivered: 3, expired: 3,
  delivered: 4, read: 5, deleted: 99, cancelled: 99,
};

// The only backward moves the platform accepts.
const RECOVERABLE = new Set([
  "delivered>undelivered", "delivered>failed",
  "submitted_no_receipt>delivered", "submitted_no_receipt>read",
  "submitted_no_receipt>failed", "submitted_no_receipt>undelivered",
  "submitted_no_receipt>rejected",
]);

const seen = new Set(); // event ids — at-least-once delivery
const rows = new Map(); // message_id -> current status

function apply(evt) {
  if (seen.has(evt.id)) return;            // dedupe before merging
  seen.add(evt.id);

  const { message_id, status } = evt.data;
  const current = rows.get(message_id);
  if (!current) { rows.set(message_id, status); return; }
  if (current === "deleted" || current === "cancelled") return; // floors
  if (RECOVERABLE.has(`${current}>${status}`)) {
    rows.set(message_id, status);          // carrier correction
    return;
  }
  const newW = WEIGHT[status];
  const curW = WEIGHT[current];
  if (newW !== undefined && (curW === undefined || newW > curW)) {
    rows.set(message_id, status);          // forward progression
  }
  // everything else — regressions, self-loops, unknown statuses — ignored
}
```

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

* [Delivery lifecycle](/concepts/delivery-lifecycle) — what each status means and who writes it
* [Message status lifecycle reference](/api-reference/messages-status-lifecycle) — per-status semantics and webhook-event map
* [Webhook event types](/reference/webhook-events) — payload shapes for every message event
* [Webhook delivery semantics](/concepts/webhook-delivery-semantics) — at-least-once delivery, retries, and dedupe windows
* [Operational sentinels](/concepts/operational-sentinels) — the settled-but-non-outcome statuses beside this DAG
