Skip to main content

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 under MESSAGE_STATUS and is mirrored in the Drizzle schema, every provider’s getStatus() return type, and the FE MessageStatusBadge. 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 (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) 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

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

Terminal-positive statuses

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).
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) 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.
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

Operator & sandbox terminal statuses

These two statuses are written by direct UPDATEs 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. 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. 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. § 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.