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:
- 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.
- Every ingress encodes out. Each way a receipt can reach you — an
SMPP
deliver_smon 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 samestatanderrtokens verbatim, because both start from the same model.
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— neverfailed{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. Carrierstat 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. Yoursubmit_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 atDELIVRD. If a read receipt arrives afterward, the event is recorded on the message row, but no furtherdeliver_smis emitted on the bind —DELIVRDalready 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,DELIVRDis 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 thesubmit_sm_respitself, on the bind, inside the submission hold. The out-of-band receipt encoders never run for that submission, and nodeliver_smarrives later to contradict the synchronous answer. - Receipts only when you ask for one. The encoder honors the
registered_deliveryflag on yoursubmit_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.)
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
What this changes for existing SMPP integrations
If you already hold an SMPP bind, four things are newly true:- 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_smfor every absorbed submission that requested one, in the same envelope as carrier-sourced receipts. - “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
UNDELIVwitherr:81— it is never silent either way. deliveredis reported distinctly. A successful termination closes withstat:DELIVRD, the same token a carrier receipt would carry, so your existing parse already handles it.- 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.
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).
Related
- SMPP edge model — the bind, credential, and DLR-mode model the receipts ride on, plus the reconciliation loop behind it.
- DLR and MO gateway pipeline — how carrier-sourced receipts and inbound messages cross into Orbit.
- Delivery lifecycle — what each message status means and who advances it.
- Message status transition rules — the allowed status transitions every consumer must agree on.
- Connect via SMPP — the client-side guide: ports, credentials, and choosing SMPP vs REST.