Message Status Lifecycle
Every outbound message in Orbit advances through a sequence of statuses on itsmessages.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, anddeletedare code-only DAG states — written by the runtime (the synchronous pre-queue send path and the operator soft-delete path) but never enumerated inMESSAGE_STATUS. See theMessageStatusDagStatetype inpackages/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”).bouncedis email-only and lives inMESSAGE_TERMINAL_STATUSES(the delivery-rate denominator) in the sameconstants.ts, not inMESSAGE_STATUS.cancelledandtest_sentare persisted terminal statuses that live in the Drizzle tenant enumMESSAGE_STATUSES(packages/database/src/schema/tenant/index.ts) but are not members of the sharedMESSAGE_STATUSobject — 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.cancelledis the operator scheduled-message cancel terminal;test_sentis 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).
History: This status was namedTwo distinct upstream conditions both promote the row todelivered_no_dlrbefore 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.
submitted_no_receipt. Analytics and observability code that needs to
distinguish them should branch on channel:
-
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 assentwithno_dlr_channel=TRUE, and the no-DLR transition scheduler flips it tosubmitted_no_receiptafter 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.” -
SMPP channels (
sms,mms,voice,fax,rcs). The SMSC / carrier returned anESME_ROKonsubmit_sm(submission accepted) but nodeliver_smDLR 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.
“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 thesubmitted_no_receiptrate as a separate KPI from thedeliveredrate. A highsubmitted_no_receiptrate signals either a non-cooperating destination or a broken DLR path on our side — both warrant investigation.
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 directUPDATEs 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 bypackages/messaging/src/message-status-dag.ts.
Highlights:
- Linear happy path:
pending → queued → sending → sent → delivered → read. sent → submitted_no_receiptis performed by the no-DLR transition scheduler, NOT by a provider DLR.- Carrier-correction (
delivered → undeliveredordelivered → failed) is allowed but logged loudly. Some Indian and Brazilian carriers emit a DELIVERED then re-emit UNDELIV minutes later. <pre-send> → cancelledis the operator scheduled-message cancel path (scheduled → cancelled, plus the other pre-terminal states).cancelledis terminal; its only out-edge iscancelled → deleted, so a late-arriving DLR cannot resurrect the row.test_sentis a terminal sandbox state reached from the pre-send states (pending/queued/accepted/sending); like every terminal status its only out-edge istest_sent → deleted.<terminal> → deletedlets 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 ofMESSAGE_STATUS.)
Webhook events
Most rows in the table below are carrier-driven: a DLR arrives, the row advances, anddispatchDlrWebhook 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.createdfires the moment the outbound row is inserted asqueued, before any provider call.message.sentfires once at the send-path the moment the provider accepts the payload —sentis deliberately absent fromSTATUS_TO_EVENT, so a later carrier DLR echoingstatus=sentre-fires nothing (see thesent§ footnote below).- the sandbox terminal
test_sentfiresmessage.sentfrom the send-path (see above).
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
EveryGET /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.