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.

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.

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.

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.