Delivery lifecycle
Every outbound message you send through Orbit carries astatus field that advances as the message moves from your API call toward the recipient’s handset — or toward a failure outcome. This page explains that state machine at the concept level: what the states mean, what drives each transition, and where the carrier-specific edges are. Read it before you subscribe to your first webhook or branch your integration on message outcomes.
Per-status semantics, the full transition table, and the webhook-event map live in the message status lifecycle reference; the response schema for reading a message’s current state lives in the Messaging API reference. This page ties the two together at a higher level.
DLR: two planes
Before the state machine itself, one split decides what any given receipt actually means. A delivery receipt in Orbit travels on one of two planes:- The carrier-leg plane. A receipt crosses from the carrier into Orbit — a submission acknowledgement on the wire (an SMPP submit ACK, a provider message id, a Meta hand-off id), or a genuine carrier delivery report. This plane answers “what did the carrier say about my message.”
- The platform-internal plane. Orbit’s own send-side receipt path: for traffic Orbit terminated itself, or for an SMPP client receipt in general, Orbit encodes the resolved canonical state back out — onto your SMPP bind as a
deliver_sm, or to your endpoint as a webhook POST. This plane answers “what does Orbit report to me, and in what encoded form.”
packages/messaging/src/channels/sms/smpp-dlr-status.ts, and the send-side absorbed-traffic encoder lives in packages/messaging/src/channels/sms/absorbed-delivery-receipts.ts.
The happy path
A message that succeeds end-to-end passes through:pending → queued → sending → sent → delivered → read
Each transition is advanced by a different actor — no single party sees the whole arc:
Two actors sit beside this path and can move a row off it:
- The no-DLR scheduler. When a carrier never returns a delivery receipt, a scheduler promotes
senttosubmitted_no_receiptafter a per-channel grace window — see Carrier-confirmed vs. wire-intermediate. - You (the operator). Cancelling a still-unsent scheduled message moves it to
cancelled, a terminal state no provider call and no DLR ever touches. Sandbox sends resolve totest_sent, a terminal state reached before any provider dispatch. Deleting a row moves any terminal state todeleted, after which nothing can touch it.
scheduled until its fire time, then joins the queue. It can only leave scheduled three ways: promoted to queued at fire time, cancelled by you, or expired if its validity window lapses before dispatch.
Which plane advances which edge
Every edge in the happy path is advanced by exactly one plane — the carrier-leg plane for stages crossed before the wire, and the platform-internal plane for the receipt-fill stages after it. The walkthrough per plane: Carrier-leg plane (wire side).pending → queued → sending → sent. The row leaves your API call, enters the send queue, a worker dispatches it, and the provider’s wire-level acknowledgement — an SMPP submit ACK, a provider message id, a Meta hand-off id — marks it sent. On this plane sent is a submission acknowledgement, not a delivery claim: the wire said “taken aboard,” nothing more. The webhook that fires here is message.sent.
Platform-internal plane (receipt-fill). sent → delivered → read. Everything after the wire accept is filled in by receipts the platform resolves and maps — a carrier DLR arriving back through the gateway, or, for absorbed traffic, Orbit’s own termination-side resolution. delivered (on message.delivered) is a carrier-confirmed outcome; read (on message.read) holds only on channels that emit read receipts. The same plane resolves absence: the no-receipt scheduler promotes a still-open sent to submitted_no_receipt (fanned out as message.failed with is_terminal: false), and a late receipt inside the window can still overwrite it.
Operator-side edges —
cancelled, test_sent, deleted, the scheduled park-and-fire rules — sit outside both planes: either you advance them through the API, or the sandbox resolves them before the wire. cancelled fires no webhook, as noted above.
Carrier-confirmed vs. wire-intermediate
The distinction that matters most for reporting and reconciliation is whether a state is a carrier-confirmed outcome or a wire-intermediate sentinel:deliveredandreadare carrier-confirmed. A real DLR arrived; the carrier itself asserted the outcome.submitted_no_receiptis wire-intermediate. It means “the submission was accepted, and no receipt came back inside the grace window.” It is tagged intermediate (state_class: "intermediate",is_terminal: false) on every webhook, because a genuinedelivered,read, or failure DLR can still land afterward and overwrite it.
submitted_no_receipt as outcome unknown, not as a delivery. Whether “unknown” leans positive or genuinely ambiguous depends on the channel — see Per-channel caveats.
expired is the counterpart on the other side: a DLR arrived, but so late that the receipt window had already closed. The outcome is unknowable and the row is closed; expired is fanned out to subscribers as a message.failed event.
Common confusion — do not mix the planes. A
submitted_no_receipt produced on the wire plane (the carrier acknowledged the submission, nothing more) is a different thing from the platform-receipt plane’s own intermediate state — the no-receipt promotion Orbit applies when the grace window closes. A wire-plane sent is a submission ACK; a receipt-plane submitted_no_receipt is “no receipt came back.” Neither is a delivery claim either way. Conflate the two planes and you will misread one of them as a delivery guarantee it never was.Debugging a stuck message
When one message is stuck between two states, openGET /api/v1/messages/:id/trace. It returns the row’s full ordered timeline — submission, carrier, and receipt events alongside every webhook fan-out attempt — assembled on read from the persisted message and webhook-delivery rows.
The events line up with the branch states above:
submitted_no_receiptrenders on the timeline as its own event when the grace-window scheduler closes the no-receipt window.submitted_no_receiptrows stall because of your subscriber, not the carrier, when the trace showswebhook_fanouthops with a failinghttp_code.
Per-channel caveats
- Meta DM channels (Instagram, Messenger). Meta’s Send API never emits delivery receipts. The send leaves Orbit as
sent, is flaggedno_dlr_channelon the message metadata, and flips tosubmitted_no_receiptafter 5 minutes. For an opted-in recipient, Meta guarantees delivery on accept — so on these channelssubmitted_no_receiptbehaves like a functional delivery signal, and the message metadata carriesno_dlr_channel: trueso you can tell that case apart. - SMPP-backed channels (SMS, MMS, voice, fax, RCS). The grace window is 30 minutes. Here
submitted_no_receiptis genuinely ambiguous: the handset may have received the message with no receipt reported, the carrier may never send receipts on that route, or the receipt may have been dropped in transit. Track this rate separately from your delivered rate — a risingsubmitted_no_receiptshare on one destination points at a non-cooperating route or a broken receipt path, and is worth investigating either way. - Email. Adds one failure outcome other channels lack:
bounced, when the receiving mail server rejects the message. Bounced counts against your terminal-failure rate likefailed, but it is a distinct status so you can separate recipient-side rejects from provider-side ones. - Operator cancel.
cancelledis reachable only by you — viaPOST /messages/:id/cancelon an unsent message. No carrier ever writes it, and as a consequence it fires no webhook event; nothing notifies a subscriber of a cancel. PollGET /messages/:idif you expose cancel in your own UI and need to observe it. - Sandbox / test mode. Test sends resolve to
test_sentbefore any provider dispatch. They firemessage.sentwithstatus: "test_sent"andmetadata.test_mode: true, so a subscriber must branch onmetadata.test_modeto keep sandbox traffic out of production handling.
What to branch on
Integrations should switch on machine-readable fields only, never on display labels:status— the message’s current state. This is the primary branch point. Handle the full set: besides the happy-path and common failure states, do not forgetcancelled,test_sent,submitted_no_receipt, andbounced.metadata.classified_error_code— present on terminal failures; the normalized, machine-readable failure category. Combine it with rawerror_code/error_messageonmessage.failedwebhooks when you want the carrier’s own wording.metadata.no_dlr_channel— present on Meta DM rows; tells you asubmitted_no_receiptis the delivery-guaranteed Meta case, not the ambiguous SMPP case.state_class/is_terminal— on lifecycle webhooks.is_terminal: true(equivalentlystate_class: "terminal") means the outcome is final;submitted_no_receiptreportsis_terminal: falsespecifically so you do not close the book on it.
Common pitfalls
- Treating
message.createdas acceptance.message.createdfires the moment the row is queued, before any provider call. A synchronous rejection (a 4xx on the send, or an immediaterejected/failed) still leaves that event delivered. Pair it withmessage.sentbefore concluding the message went out. - Assuming
deliveredis unchangeable. Carriers on some routes emit a delivery receipt, then a correction minutes later — some Indian and Brazilian carriers do this. Orbit honors it: the row can movedelivered → undeliveredordelivered → failed. If you mirror statuses into your own datastore, apply updates idempotently by message id rather than ignoring transitions for a message you already marked delivered. - Waiting for a webhook on
cancelled. It will never come — cancel originates from your own API call, not a carrier callback, so the platform emits no event for it. The row simply sits atcancelleduntil you delete it. - Dropping
submitted_no_receiptinto the failed bucket. It arrives on themessage.failedevent type for transport reasons (there is no dedicated event), but its payloadstatusissubmitted_no_receiptwithis_terminal: false. Branch ondata.status, not the event type, and keep it out of your hard-failure metrics. - Expecting one terminal event per message. A message can emit
message.failedwithstatus: "submitted_no_receipt"and latermessage.delivered, when a slow carrier receipt finally lands inside the late-arrival window. Dedupe and reconcile bymessage_id, letting the later event supersede.
delivered vs wire-intermediate submitted_no_receipt vs earliest-origin sent — start at The three success statuses. For the four-owner index that routes you between this page, the transition rules, and the vocabulary page, open the message status map.
Worked example: polling a message’s current state
Webhooks are the primary notifications channel; when you also poll — a “check delivery” control in your own UI, or the only way to observe an operator-cancel, which fires no webhook —GET /api/v1/messages/{id} returns the row’s current state on every call:
status field, exactly as you would branch on the webhook payload’s data.status — the same canonical vocabulary drives both. Handle the full set, not just the happy path: submitted_no_receipt is outcome-unknown rather than a delivery, cancelled only ever arrives by your own call, test_sent marks sandbox traffic, and email adds bounced to the failure set. Poll with backoff on channels that carry genuine receipt lag instead of tight-looping the endpoint, and subscribe to message.delivered / message.failed webhooks wherever an event-driven handler fits — the per-language send-then-poll samples (Node, Python, Go, Ruby, PHP) are in the Messaging endpoints page; the REST escape hatch (orbit.request/client.request) covers any SDK without a typed helper for this read.