> ## 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 Lifecycle

> Every status a message row passes through, what each state means, and which transitions are guaranteed by the platform vs. the carrier.

# Message Status Lifecycle

Every outbound message in Orbit advances through a sequence of statuses
on its `messages.status` column. This page is the authoritative
reference for what each status means, who writes it, and how the row
transitions between them.

The canonical set of **persisted** statuses lives in
[`packages/shared/src/constants.ts`](https://github.com/Devotel/cpaas/blob/main/packages/shared/src/constants.ts)
under `MESSAGE_STATUS` and is mirrored in the Drizzle schema, every
provider's `getStatus()` return type, and the FE
[`MessageStatusBadge`](https://github.com/Devotel/cpaas/blob/main/packages/ui/src/components/message-status-badge.tsx).
That enum contains exactly: `queued`, `scheduled`, `sending`, `sent`,
`submitted_no_receipt`, `delivered`, `read`, `failed`, `rejected`,
`undelivered`, `expired`, and `unknown`.

A handful of statuses documented below are **not** members of
`MESSAGE_STATUS` and are flagged in the tables with a † dagger:

* `pending`, `accepted`, and `deleted` are **code-only DAG states** —
  written by the runtime (the synchronous pre-queue send path and the
  operator soft-delete path) but never enumerated in `MESSAGE_STATUS`.
  See the `MessageStatusDagState` type in
  [`packages/messaging/src/message-status-dag.ts`](https://github.com/Devotel/cpaas/blob/main/packages/messaging/src/message-status-dag.ts)
  (its comment calls them out as states "that never appear in the
  schema list but are actively written by the runtime").
* `bounced` is **email-only** and lives in `MESSAGE_TERMINAL_STATUSES`
  (the delivery-rate denominator) in the same `constants.ts`, not in
  `MESSAGE_STATUS`.
* `cancelled` and `test_sent` are **persisted terminal statuses** that
  live in the Drizzle tenant enum `MESSAGE_STATUSES`
  ([`packages/database/src/schema/tenant/index.ts`](https://github.com/Devotel/cpaas/blob/main/packages/database/src/schema/tenant/index.ts))
  but are **not** members of the shared `MESSAGE_STATUS` object — code
  that imports the shared enum for type-narrowing won't see them, so an
  integrator branching on the documented set above must handle them
  explicitly. `cancelled` is the operator scheduled-message cancel
  terminal; `test_sent` is the sandbox/test-mode terminal (USER-BUG-014).
  See "Operator & sandbox terminal statuses" below.

## In-flight statuses

| Status       | Meaning                                                                                                                 | Who writes it                                  |
| ------------ | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| `pending` †  | Row created, not yet enqueued for send. (Code-only DAG state — not in `MESSAGE_STATUS`.)                                | Send-path persistence layer.                   |
| `queued`     | Enqueued onto BullMQ, awaiting worker pickup.                                                                           | `messages.service.ts#sendMessage`.             |
| `scheduled`  | Persisted with `scheduled_at` in the future; not yet eligible to send.                                                  | Send-path; promoted by scheduler at fire time. |
| `sending`    | Worker has dequeued the row and is currently dispatching to the provider.                                               | Webhook-worker / send-path.                    |
| `accepted` † | Provider responded with a synchronous 2xx but no wire-level ACK yet. (Code-only DAG state — not in `MESSAGE_STATUS`.)   | Provider-specific paths (e.g. Meta Cloud API). |
| `sent`       | Provider returned a wire-level acknowledgement (SMPP `submit_sm_resp` ESME\_ROK, Telnyx message\_id, Meta wamid, etc.). | Send-path on synchronous ACK.                  |

In-flight rows do NOT count toward the delivery-rate denominator.

## Terminal-positive statuses

| Status      | Meaning                                                                                                                                                                      | Who writes it                                           |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `delivered` | Carrier-confirmed delivery via a wire-level DLR (SMPP `deliver_sm` MESSAGE\_STATE=DELIVERED, Telnyx `message.delivered` webhook, Meta `messages.statuses.status=delivered`). | DLR handlers in `apps/api/src/routes/webhooks/dlr*.ts`. |
| `read`      | WhatsApp / RCS / email read-receipt — implies the row was delivered first.                                                                                                   | DLR handlers when the carrier emits a read event.       |

`submitted_no_receipt` is **not** a terminal-positive status despite
membership in `MESSAGE_DELIVERED_STATUSES` — it is an intermediate
sentinel that a later genuine `delivered`/`read`/`failed` can overwrite.
See its own section directly below.

## `submitted_no_receipt` — an intermediate sentinel status

This status is written by
`apps/webhook-worker/src/scheduler-no-dlr-transition.ts` after the
per-channel grace window elapses. It collapses **two semantically
different cases** onto one sentinel (detailed below).

<Warning>
  Although `submitted_no_receipt` is a member of
  `MESSAGE_DELIVERED_STATUSES` (so it counts toward analytics delivered
  numerators for backward compatibility), it is **wire-classified as
  intermediate**, not terminal. The canonical DAG
  ([`message-status-dag.ts`](https://github.com/Devotel/cpaas/blob/main/packages/messaging/src/message-status-dag.ts))
  omits it from `TERMINAL_STATES`, so `classifyDlrState` returns
  `intermediate` and `isTerminalStatus` returns `false`. Every webhook
  dispatched for this status carries `state_class: "intermediate"` and
  `is_terminal: false`, and a later genuine `delivered`, `read`, or
  `failed` DLR can still overwrite the row. Do not treat it as a final
  outcome — see [Webhook event types](/reference/webhook-events).
</Warning>

> **History:** This status was named `delivered_no_dlr` before
> 2026-05-14 (W1-023). Operators read the prior name as a success state
> when (for SMPP channels) it actually means "submission ACK'd but no
> delivery receipt received within the grace window — outcome unknown".
> The rename surfaces the honest semantics. A backfill migration
> (`208_messages_status_rename_delivered_no_dlr_to_submitted_no_receipt`)
> renames every existing row on every tenant schema.

Two distinct upstream conditions both promote the row to
`submitted_no_receipt`. Analytics and observability code that needs to
distinguish them should branch on `channel`:

1. **Meta DM channels (`instagram`, `messenger`).** Meta's Send API for
   Instagram and Messenger never emits a wire-level delivery receipt.
   The send path persists the row as `sent` with `no_dlr_channel=TRUE`,
   and the no-DLR transition scheduler flips it to
   `submitted_no_receipt` after 5 minutes. For this case Meta
   guarantees delivery on accept when the recipient is still opted in
   — analytics treat this as functionally delivered, and the FE
   tooltip reads "Meta does not emit delivery receipts for Instagram
   or Messenger. The message was accepted; delivery is guaranteed when
   the recipient is still opted in."

2. **SMPP channels (`sms`, `mms`, `voice`, `fax`, `rcs`).** The SMSC /
   carrier returned an `ESME_ROK` on `submit_sm` (submission accepted)
   but no `deliver_sm` DLR landed within the 30-minute grace window.
   This can happen because:

   * The carrier never sent a DLR (some destination countries / routes
     are non-cooperating).
   * The DLR was dropped in transit (Jasmin restart, SMPP route flap,
     misconfigured webhook).
   * The handset received the SMS but the carrier never reported it.

   For this case **the outcome is genuinely unknown** — the message
   may have reached the handset, or it may have been silently dropped.
   The FE tooltip reads:

   > "SMPP submission ACK'd but no delivery receipt received within
   > the 30-min grace window — outcome unknown. Don't confuse with
   > delivered (carrier-confirmed)."

   Operators investigating delivery quality on a route should treat
   the `submitted_no_receipt` rate as a separate KPI from the
   `delivered` rate. A high `submitted_no_receipt` rate signals
   either a non-cooperating destination or a broken DLR path on our
   side — both warrant investigation.

The `MESSAGE_DELIVERED_STATUSES` set in
`packages/shared/src/constants.ts` includes `submitted_no_receipt`
alongside `delivered` and `read` for backward compatibility with
existing analytics dashboards. Use that constant when computing a
delivered numerator; if you need to exclude the unknown-outcome SMPP
variant, filter on `channel NOT IN ('sms','mms','voice','fax','rcs')`
in addition.

## Terminal-failure statuses

| Status        | Meaning                                                                                                             |
| ------------- | ------------------------------------------------------------------------------------------------------------------- |
| `failed`      | Provider returned a terminal error on send OR the DLR reported `MESSAGE_STATE=UNDELIVERABLE`.                       |
| `rejected`    | Provider rejected the request synchronously (invalid recipient, throttled, policy violation).                       |
| `undelivered` | Carrier confirmed via DLR that the message did not reach the recipient (e.g. unreachable).                          |
| `bounced` †   | Email-only — receiving mail server returned a bounce. (Lives in `MESSAGE_TERMINAL_STATUSES`, not `MESSAGE_STATUS`.) |
| `expired`     | DLR arrived after `DEVOTEL_DLR_LATE_ARRIVAL_MAX_AGE_DAYS` — treated as functionally expired.                        |
| `unknown`     | Defensive fallback only — should never persist for long; investigate if seen in dashboards.                         |

## Operator & sandbox terminal statuses

These two statuses are written by direct `UPDATE`s **outside** the DLR
pipeline, so no carrier callback can resurrect a row once it lands in
either state — the DAG permits only `→ deleted` from both (see
`message-status-dag.ts`). They are persisted in the Drizzle tenant enum
`MESSAGE_STATUSES` but are not members of the shared `MESSAGE_STATUS`
object, so they carry the † dagger.

| Status        | Meaning                                                                                                                                                            | Who writes it                                                                                                                                                    |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cancelled` † | Operator cancelled a still-unsent (typically `scheduled`) message before it was dispatched. Terminal — no provider call was made and no DLR will arrive.           | Scheduled-message cancel paths: `POST /messages/:id/cancel`, `bulkCancelScheduled`, and `cancelScheduledMessage(s)` in `messages.repository.ts` (direct UPDATE). |
| `test_sent` † | Sandbox / test-mode terminal — the send was simulated, never handed to a provider, so no DLR ever arrives (USER-BUG-014). Surfaces as the amber "Test sent" badge. | Test/sandbox send path (direct UPDATE).                                                                                                                          |

`cancelled` fires no message webhook event — it is absent from
`STATUS_TO_EVENT` in `apps/api/src/routes/webhooks/dlr-common.ts`
because it originates from an operator action rather than a carrier
callback.

`test_sent`, however, **does** fire a `message.sent` webhook from the
send-path (not the DLR path) with `status="test_sent"` and
`test_mode=true`, so subscribers to `message.sent` will receive the
webhook for sandbox/test-mode sends.

## Transition rules

Valid status transitions are enforced by
[`packages/messaging/src/message-status-dag.ts`](https://github.com/Devotel/cpaas/blob/main/packages/messaging/src/message-status-dag.ts).
Highlights:

* Linear happy path: `pending → queued → sending → sent → delivered → read`.
* `sent → submitted_no_receipt` is performed by the no-DLR transition
  scheduler, NOT by a provider DLR.
* Carrier-correction (`delivered → undelivered` or `delivered → failed`)
  is allowed but logged loudly. Some Indian and Brazilian carriers emit
  a DELIVERED then re-emit UNDELIV minutes later.
* `<pre-send> → cancelled` is the operator scheduled-message cancel
  path (`scheduled → cancelled`, plus the other pre-terminal states).
  `cancelled` is terminal; its only out-edge is `cancelled → deleted`,
  so a late-arriving DLR cannot resurrect the row.
* `test_sent` is a terminal sandbox state reached from the pre-send
  states (`pending`/`queued`/`accepted`/`sending`); like every terminal
  status its only out-edge is `test_sent → deleted`.
* `<terminal> → deleted` lets operators retire stuck rows; once deleted
  nothing else can touch the row. (`deleted` † is a code-only DAG state
  written by the operator soft-delete path — it is not a member of
  `MESSAGE_STATUS`.)

## Webhook events

Most rows in the table below are **carrier-driven**: a DLR arrives, the row
advances, and `dispatchDlrWebhook` fans the event out through the
`STATUS_TO_EVENT` map in `apps/api/src/routes/webhooks/dlr-common.ts` (the
same map cited for `submitted_no_receipt`/`expired` below). Three rows are
instead **send-path-driven** and are NOT members of that map:

* `message.created` fires the moment the outbound row is inserted as
  `queued`, before any provider call.
* `message.sent` fires once at the send-path the moment the provider
  accepts the payload — `sent` is deliberately absent from `STATUS_TO_EVENT`,
  so a later carrier DLR echoing `status=sent` re-fires nothing (see the
  `sent` § footnote below).
* the sandbox terminal `test_sent` fires `message.sent` from the send-path
  (see above).

The operator terminal `cancelled` is the exception in the other direction —
it fires no message webhook event. See
[Webhook event types](/webhooks/events).

| Status                 | Webhook event       |
| ---------------------- | ------------------- |
| `queued`               | `message.created`   |
| `sent` §               | `message.sent`      |
| `test_sent` ‡          | `message.sent`      |
| `delivered`            | `message.delivered` |
| `read`                 | `message.read`      |
| `failed`               | `message.failed`    |
| `rejected`             | `message.failed`    |
| `undelivered`          | `message.failed`    |
| `expired`              | `message.failed`    |
| `submitted_no_receipt` | `message.failed`    |

§ `message.sent` is emitted **once at the send-path** the moment the provider
accepts the payload (SMPP `submit_sm_resp` ESME\_ROK, Telnyx `message_id`, Meta
`wamid`, …) — see the provider-accept enqueue site in
`apps/api/src/routes/messages/messages.service.ts`. It is **not** driven by the
DLR `STATUS_TO_EVENT` map: `sent` is intentionally omitted from that map so the
normal post-accept `status=sent` echo that several carriers (notably Twilio and
Meta) send back as a DLR re-fires nothing — preserving the invariant "exactly
one `message.sent` per message id". The `sent` row appears in this table only
because `sent` is the status that pairs with the event, not because a
`sent`-status DLR emits it.

‡ `test_sent` fires `message.sent` from the send-path on a successful
sandbox/test-mode send (NOT from the carrier-DLR `STATUS_TO_EVENT` map). The
payload carries `status="test_sent"` and `metadata.test_mode=true`, so a
`message.sent` subscriber must branch on `metadata.test_mode` to tell a
sandbox send apart from a live one. A failed test send persists `failed` and
fires no `message.sent`.

`message.created` is fired by the send path the moment the outbound row
is inserted with `status="queued"` — before any provider dispatch — so it
is the signal a subscriber receives for the `queued` state. Its payload
carries `status: "queued"` (not `"created"`); branch on the event type, not
the status field. Because it precedes the provider call, a later 4xx on the
same send still leaves this event delivered, so do not treat
`message.created` as a guarantee that the message was accepted for
transmission.

`submitted_no_receipt` and `expired` do NOT fire dedicated
`message.submitted_no_receipt` / `message.expired` webhook events — by
design, because the upstream condition (timeout waiting for a DLR, or a
DLR arriving past the late-arrival window) is not a discrete carrier
event. They are instead fanned out on `message.failed` (per
`STATUS_TO_EVENT` in `apps/api/src/routes/webhooks/dlr-common.ts`);
branch on the payload's `status` field to distinguish them from a true
carrier `failed`. Subscribe to the per-channel quality alerts if you need
to react programmatically to a tenant accumulating
`submitted_no_receipt` rows.

## Reading the status from the API

Every `GET /messages/:id` response includes `status` plus
`metadata.classified_error_code` for terminal failures and
`metadata.no_dlr_channel` for Meta-DM rows. Consumers should never
parse the operator-facing label strings — always branch on the
machine-readable status field.

```bash theme={null}
curl https://api.orbit.devotel.io/api/v1/messages/msg_01ABCDEF \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

```json theme={null}
{
  "id": "msg_01ABCDEF",
  "channel": "sms",
  "status": "submitted_no_receipt",
  "sent_at": "2026-05-14T10:23:04.501Z",
  "metadata": {
    "no_dlr_channel": false,
    "submitted_at": "2026-05-14T10:23:04.501Z"
  }
}
```
