Skip to main content

Queued to sent: the outbound status primer

Read this one page and you own the whole vocabulary — the five deeper pages linked under See also become optional detail, not required reading. When you need the full legal-transition/merge graph rather than the arc, that lives on the message-status transition rules (DAG) page, and this primer references it rather than re-deriving it.

The canonical vocabulary, stated inline

An outbound message moves through a forward arc of wire states, and it can end in one of the outcome classes. The two groups are different kinds of states: the forward arc describes where the row sits in the pipeline; the outcome classes describe how the row closed. The forward arc (in order):
  • pending — the API accepted your send request; the row is not yet on the delivery queue. This state exists between acceptance and queueing and is usually sub-second.
  • queued — the row is on the delivery queue, waiting to be claimed by the dispatcher. A row parked with send_at sits in the hold state scheduled instead and joins queued when its fire time arrives.
  • sending — the dispatcher claimed the batch and is handing it to the routed provider. Another sub-second state in a healthy pipeline; a persistent sending cluster means the provider endpoint is stalling.
  • sent — the provider acknowledged the payload on the wire. This is a wire state, not an outcome: on a receipt-capable channel it means “the carrier may still report a delivery receipt”; on a no-DLR channel it means “accepted, and no receipt will ever come.”
  • delivered / read — the terminal success outcomes, resolved from the provider’s delivery receipt (DLR). read implies delivered and outranks it.
  • failed / rejected / undelivered — the terminal failure outcomes. failed is a send-time or classified failure; rejected is a carrier policy refusal; undelivered is “the carrier tried and the handset could not take it” — or, on a receipt-capable channel whose receipt never arrived within its grace window, “sent, never confirmed.” A genuinely-no-DLR row ages there; it does not masquerade as delivered.
  • expired — a receipt arrived past the late-arrival window, or a scheduled row outlived its validity window before fire time. Terminal “too late to count,” never a fabricated outcome.
  • submitted_no_receipt — the provisional sentinel: accepted by the provider, receipt not (or never will be) expected. It is deliberately not terminal — a later genuine receipt overwrites it. A section below is devoted to it.
The outcome classes the send pipeline never passes through:
  • cancelled — you called the cancel endpoint while the row was still unsent.
  • expired, above.
  • deleted — operator redaction; the terminal floor every status can exit to.
  • test_sent — sandbox/test sends resolved before any provider dispatch.
The dashboard and API may also show accepted (a synonym for admission used by some providers) and unknown (a defensive placeholder for an unmapped provider status string — it never overwrites a real state and any real state overwrites it).

The four actors that move a row

Every status change is written by exactly one of four logical actors. When a row is stuck, the actor is what you debug; the status is what you read.
  1. The send pipeline (API → queue → dispatcher). Owns pending → queued → sending → sent, and writes the provider id, the provider’s message reference, and the carrier hop onto the row at handoff. A pre-send gate failure (validation, consent, quota) closes the row as failed here, before any provider touch.
  2. The DLR pipeline (incoming provider webhooks). Owns every real receipt: sent → delivered / read, and the carrier rejections sent → failed / rejected / undelivered. It also enforces the legal-transition/merge rules — precedence weight, floors, the carrier-correction allowances — so out-of-order receipts resolve to the same answer the DAG page publishes.
  3. The no-DLR scheduler (a background safety net). Sweeps rows stuck in sent past their channel’s grace window and closes them honestly: infers delivered/read from stamped receipt markers on the row, promotes no-DLR-channel rows to submitted_no_receipt, ages genuinely-silent reportable channels to undelivered, and heals mislabeled sentinels back to their true receipt. Everything the scheduler writes is provisional — a later genuine DLR still wins.
  4. You, the operator (API calls). Owns cancel against a still-pending row (which lands it on cancelled, a floor no provider event can reopen) and delete for redaction (deleted — the absolute floor every status can exit to and nothing exits from).
The first three actors are what the delivery-lifecycle page calls “owners”; the fourth is the only one you invoke directly. Wire states are the dispute space between actor 2 and actor 3 — which brings us to the distinction that decides everything.

Wire states vs terminal states — and the exit-edge table

A wire state (pending, queued, sending, sent, and accepted where it appears) describes progress, not outcome. A terminal state describes outcome. The sixth message-log column (sent / delivered / failed / pending, depending on the surface) is a projection, so two rows in the same column can sit in different dispute states — “Sent” can be a healthy SMS awaiting its DLR, or a Meta DM message that will never get one. An exit edge is a legal transition out of a state. This table is the arc’s own edges; the full legal-transition graph (weights, floors, carrier corrections) is on the message-status DAG page — it is referenced here, not re-derived. The forward arc plus the outcome classes above is the entire vocabulary. Everything else in your status stream is one of the alias states or a provider-specific string the platform maps into this list.

No-DLR channels and their grace windows

Some channels never emit a delivery receipt at all. Meta DM channels (Instagram, Messenger) are the canonical case: the Send API acknowledges submission and guarantees delivery while the recipient stays opted in, but it provides no carrier-style DLR — so sent is the best answer the provider will ever give. The send path marks those rows with a channel-level “no receipt expected” flag, and the no-DLR scheduler resolves them after a short grace — 5 minutes for Instagram/Messenger — into the submitted_no_receipt sentinel. Email gets a longer, 60-minute window to absorb the provider webhook’s retry envelope before the same sentinel applies. Receipt-capable channels behave differently by design. SMS/MMS/voice/fax/RCS rows are supposed to be confirmed by a carrier DLR; when none arrives within their 30-minute grace, the scheduler ages them to the honest terminal undelivered (“sent, never confirmed”) rather than crediting them toward delivered. Viber/WhatsApp rows get a 24-hour window before the same undelivered verdict. So the single scheduled closing actor reads each channel’s grace off the row itself: 5 minutes for Meta DM, 60 minutes for email, 30 minutes for the SMPP-backed family, 24 hours for viber/whatsapp. The messages log shows these four as the same column, but the rates landing in your analytics differ on purpose — the sentinel counts differently from an aged failure. Split your own numbers by that grace class and the two sentences of reconciliation (“route never reports receipts” vs “route aged its window”) resolve themselves.

submitted_no_receipt, precisely

submitted_no_receipt means: the provider accepted the send; no receipt ever said what happened next. It is a statement about evidence, not about the handset. Three things follow from that definition:
  1. It is provisional by design. The status deliberately reports as non-terminal (is_terminal: false) on your webhook stream, and the legal-transition graph lets a later genuine delivered, read, or failure DLR overwrite it. Freeze your integration on the first terminal-looking event and you misrecord exactly these rows.
  2. It is KPI-counted as delivered in the platform’s delivery-rate math, because on the channels that produce it (Meta DM especially) acceptance is the strongest guarantee available. That is a reporting decision, not an assertion about the handset.
  3. It is not a failure. Your own webhooks receive the sentinel as a status-classification event (the platform maps it onto the failure-event type for subscription parity), so treat it in your merge logic as an intermediate state, and in your analytics as a delivered-equivalent — and never as a reason to retry.
When it appears on a receipt-capable channel — an SMS stuck past its grace — read it as a defect the platform already healed, not as a verdict: reportable rows now age to undelivered, the scheduler heals a mislabeled sentinel back to a real receipt whenever the receipt markers exist on the row, and any genuine receipt that arrives late still moves the row forward.

A worked walk: POST to submitted_no_receipt

Walk one Meta Messenger send end to end and watch each actor write its slice:
  1. POST /api/v1/messages/messenger accepts the send; the actor-1 send pipeline writes the row as pending, then queued — a message.created event fans out to your webhooks.
  2. The dispatcher claims it: queued → sending as the pipeline resolves the route.
  3. Meta acknowledges on the wire: sending → sent, with the provider’s message reference stamped on the row. Your subscribers see a message.sent event marked intermediate.
  4. Meta DM emits no DLR, so the DLR pipeline never touches the row. After the 5-minute grace on sent, the no-DLR scheduler promotes it: sent → submitted_no_receipt, and fans out the terminal-style event with is_terminal: false.
  5. If a read webhook arrives later, the DLR pipeline writes read (implying delivered), weighting higher than the sentinel — the row has converged.
  6. If a read webhook never arrives, the row rests at the sentinel: accepted, resolved, honestly labelled “no receipt expected.”
Swap Messenger for SMS in the same walk and step 4 becomes “the DLR pipeline usually writes delivered”; when no receipt arrives within 30 minutes, step 4 becomes the scheduler’s aging to undelivered (terminal, with refund handling where a charge applied); a genuine late DELIVRD still flips the row to delivered at any point. Swap the request for a scheduled send and the row starts in scheduled and joins queued only at fire time. For one specific message, the same arc is assembled on read as an ordered timeline — the accept, route, sent, terminal, and webhook fan-out hops a GET /api/v1/messages/:id/trace returns — so “where did this row actually go” never needs a carrier ticket.

See also

Resolve the vocabulary above and you rarely need these; they stay authoritative for the slices they own: