Skip to main content

Delivery lifecycle

Every outbound message you send through Orbit carries a status 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.”
The reader’s rule: a receipt that names what a carrier returned only has meaning once it maps into the canonical vocabulary; a receipt that came from Orbit’s own termination path has no carrier leg to consult — it crosses directly on the send side. The full carrier-plane vs send-side-plane split is defined in The DLR model: two planes, one vocabulary; the canonical vocabulary both planes operate on and the send-side encoders are defined in Send-side DLR model. In source, the carrier-token → canonical mapper lives in 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 sent to submitted_no_receipt after 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 to test_sent, a terminal state reached before any provider dispatch. Deleting a row moves any terminal state to deleted, after which nothing can touch it.
A message scheduled for the future parks in 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:
  • delivered and read are carrier-confirmed. A real DLR arrived; the carrier itself asserted the outcome.
  • submitted_no_receipt is 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 genuine delivered, read, or failure DLR can still land afterward and overwrite it.
Treat 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, open GET /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_receipt renders on the timeline as its own event when the grace-window scheduler closes the no-receipt window.
  • submitted_no_receipt rows stall because of your subscriber, not the carrier, when the trace shows webhook_fanout hops with a failing http_code.
The full event vocabulary is on the route-trace concept page; the endpoint response schema is in the Messaging API reference.

Per-channel caveats

  • Meta DM channels (Instagram, Messenger). Meta’s Send API never emits delivery receipts. The send leaves Orbit as sent, is flagged no_dlr_channel on the message metadata, and flips to submitted_no_receipt after 5 minutes. For an opted-in recipient, Meta guarantees delivery on accept — so on these channels submitted_no_receipt behaves like a functional delivery signal, and the message metadata carries no_dlr_channel: true so you can tell that case apart.
  • SMPP-backed channels (SMS, MMS, voice, fax, RCS). The grace window is 30 minutes. Here submitted_no_receipt is 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 rising submitted_no_receipt share 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 like failed, but it is a distinct status so you can separate recipient-side rejects from provider-side ones.
  • Operator cancel. cancelled is reachable only by you — via POST /messages/:id/cancel on an unsent message. No carrier ever writes it, and as a consequence it fires no webhook event; nothing notifies a subscriber of a cancel. Poll GET /messages/:id if you expose cancel in your own UI and need to observe it.
  • Sandbox / test mode. Test sends resolve to test_sent before any provider dispatch. They fire message.sent with status: "test_sent" and metadata.test_mode: true, so a subscriber must branch on metadata.test_mode to 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 forget cancelled, test_sent, submitted_no_receipt, and bounced.
  • metadata.classified_error_code — present on terminal failures; the normalized, machine-readable failure category. Combine it with raw error_code / error_message on message.failed webhooks when you want the carrier’s own wording.
  • metadata.no_dlr_channel — present on Meta DM rows; tells you a submitted_no_receipt is the delivery-guaranteed Meta case, not the ambiguous SMPP case.
  • state_class / is_terminal — on lifecycle webhooks. is_terminal: true (equivalently state_class: "terminal") means the outcome is final; submitted_no_receipt reports is_terminal: false specifically so you do not close the book on it.

Common pitfalls

  1. Treating message.created as acceptance. message.created fires the moment the row is queued, before any provider call. A synchronous rejection (a 4xx on the send, or an immediate rejected/failed) still leaves that event delivered. Pair it with message.sent before concluding the message went out.
  2. Assuming delivered is 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 move delivered → undelivered or delivered → 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.
  3. 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 at cancelled until you delete it.
  4. Dropping submitted_no_receipt into the failed bucket. It arrives on the message.failed event type for transport reasons (there is no dedicated event), but its payload status is submitted_no_receipt with is_terminal: false. Branch on data.status, not the event type, and keep it out of your hard-failure metrics.
  5. Expecting one terminal event per message. A message can emit message.failed with status: "submitted_no_receipt" and later message.delivered, when a slow carrier receipt finally lands inside the late-arrival window. Dedupe and reconcile by message_id, letting the later event supersede.
Once the state machine is clear, the per-status webhook payloads are in the webhook events reference, and the endpoint-level status semantics are in the message status lifecycle reference. When a single row’s move through this state machine needs reconstructing end to end — the “delivered-but-never-received” case, a stuck receipt, or a failure-stage check — open its assembled per-message timeline on the route-trace concept page. To tell which DLR lane a given behaviour belongs to — the carrier-receipt path this page describes versus Orbit’s own send-side receipt encoding — start at The DLR model: two planes, one vocabulary. To read the three success statuses per group — terminal 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:
Branch on the response’s 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.