Skip to main content

Send-side DLR model

When you send a message through Orbit, the outcome is reported in one vocabulary no matter which channel carried it — SMS, WhatsApp, RCS, or a channel the platform terminated the message on instead of the one you submitted to. This page defines that vocabulary, shows how each channel’s native statuses map into it, and pins down the one contract an SMPP client has for traffic that no carrier receipt could ever describe: submissions absorbed onto another channel. The delivery lifecycle page covers what each message status means on a row and who advances it; the DLR and MO gateway pipeline page covers how a carrier’s receipt crosses into Orbit. This page is the model both operate on — the shared vocabulary, and the receipt your SMPP bind receives when there is no carrier to send one.

The canonical state vocabulary

Every delivery event, from any outbound channel, resolves to exactly one of six canonical states: The failed{code} and rejected{code} arms carry a code from a small, documented vocabulary: The rule that keeps this model honest works in both directions:
  1. Every egress provider maps in. WhatsApp status events, SMS carrier receipts, RCS and Viber statuses — each adapter translates its native event into exactly one canonical state before anything else sees it.
  2. Every ingress encodes out. Each way a receipt can reach you — an SMPP deliver_sm on your bind, a JSON POST to your webhook — is encoded from the canonical state by one encoder per surface. The bind leg and the webhook leg carry the same canonical status, the same mapped Orbit status, and (on the SMPP side) the same stat and err tokens verbatim, because both start from the same model.
The practical consequence: whatever you integrate against — the SMPP bind, the delivery webhooks, the message status on the row — is a projection of one event, not three parallel guesses.

WhatsApp into canonical

Meta reports a WhatsApp message’s progress as status events (sent, delivered, read, failed) and, on failure, a numeric error code. The mapping into the canonical vocabulary: Two points pin the edges of the table:
  • TTL lapse is its own arm. If the message’s validity window runs out before the destination channel accepts it, the canonical outcome is expired — never failed{provider_error}. An expiry tells you to re-send with fresh content; an unspecified failure tells you to look at the channel.
  • Unknown codes degrade safely. A Meta error code the vocabulary does not specifically name lands in provider_error, so a new upstream code never wedges a receipt in an unmapped state.

The SMS arm: one table, both directions

For SMS, the canonical mapping is the same status table Orbit already uses to interpret inbound carrier receipts — the DLR and MO gateway pipeline describes that ingress path. Carrier stat tokens map into the model like this: Because outbound encode and inbound decode share this one table, the vocabulary round-trips: a token a carrier sends in means the same thing as the token Orbit sends back out on your bind. There is no second translation layer where REJECTD on one leg and REJECTD on the other could quietly diverge.

The SMPP encoder for absorbed traffic

A submission absorbed onto another channel has a receipt problem by construction. Your submit_sm arrives on your SMPP bind; Orbit’s termination engine decides the message travels as, say, a WhatsApp message; the carrier leg never exists. A traditional SMPP receipt is a deliver_sm that arrives from the carrier connector that carried the submission — and for absorbed traffic there is no such connector, so older platforms simply go silent. Your bind sees the submit accepted and then nothing, forever. The SMPP encoder closes that hole: when the absorbed message resolves, the resolved canonical state is injected back onto your bind as a deliver_sm receipt, exactly as if a carrier had produced it. The encoding: The err: tokens on the failure arm are the documented vocabulary: The receipt’s receipted_message_id is the message id your original submit_sm was answered with — not an id from the channel the message actually travelled on. Your existing matcher correlates absorbed receipts with no special-casing. Three edges of the contract are deliberate, not gaps:
  • No second receipt on read. A successful absorbed submission ends at DELIVRD. If a read receipt arrives afterward, the event is recorded on the message row, but no further deliver_sm is emitted on the bind — DELIVRD already closed the receipt stream for that submission, and a later receipt would be a duplicate of a terminal state. This is a documented product decision: on absorbed traffic, DELIVRD is the only receipt a successful termination produces.
  • Exactly one receipt per submission. The correlation between an absorbed submission and its channel-side progress is consumed when the first receipt is emitted. Events that arrive afterward are recorded, but they produce no second receipt.
  • A synchronously rejected submit never produces a returned receipt. If the pre-send checks reject your submit_sm — for example because the recipient has no WhatsApp account and the submission’s whole premise is termination on that channel — the error travels back in the submit_sm_resp itself, on the bind, inside the submission hold. The out-of-band receipt encoders never run for that submission, and no deliver_sm arrives later to contradict the synchronous answer.
  • Receipts only when you ask for one. The encoder honors the registered_delivery flag on your submit_sm; a submission that did not request a receipt is not given one. (SMPP 3.4 requests a receipt with bit 1 of the flag; the check is on that bit, not on the whole value, so combining it with the intermediate-receipt bit works as the spec intends.)
If your credential’s DLR delivery mode is webhook or both, the same resolved event is POSTed as JSON to your configured endpoint with the same canonical status, the mapped Orbit status, and — where they exist — the same stat and err tokens carried verbatim. The webhook leg and the bind leg describe one event identically.

The path end to end

The canonical state is the only hand-off between the channel and either surface, which is why both surfaces always agree.

What this changes for existing SMPP integrations

If you already hold an SMPP bind, four things are newly true:
  1. Absorbed submissions now return receipts. Submissions Orbit terminated on another channel previously produced no receipt at all — an accepted submit with a permanently silent outcome. Your bind now receives a deliver_sm for every absorbed submission that requested one, in the same envelope as carrier-sourced receipts.
  2. “No WhatsApp” is now a reportable outcome. A recipient who cannot be reached on the destination channel either rejects your submission synchronously on the bind or arrives back as UNDELIV with err:81 — it is never silent either way.
  3. delivered is reported distinctly. A successful termination closes with stat:DELIVRD, the same token a carrier receipt would carry, so your existing parse already handles it.
  4. Nothing about carrier-routed SMS changed. Submissions that genuinely travel as SMS keep their existing carrier-sourced receipts on the same vocabulary — the absorbed-traffic encoder fills a hole rather than replacing a path.
The failure-side tokens your matcher should handle for absorbed traffic are err:81 (not on destination channel), err:88 (re-engagement required), err:89 (rate limit), and err:00 (unspecified provider error, also the token on expired submissions).