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