> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Send-side DLR model: the canonical vocabulary and absorbed-traffic receipts

> The one delivery-state vocabulary every outbound channel maps into — sent, delivered, read, failed{code}, expired, rejected{code} — the WhatsApp and SMS mappings into it, and the SMPP receipt contract your bind can now rely on when a submission is terminated on another channel.

# 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](/concepts/delivery-lifecycle) page covers what each
message status means on a row and who advances it; the [DLR and MO gateway
pipeline](/concepts/dlr-and-mo-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:

| Canonical state  | Meaning                                                                                      |
| ---------------- | -------------------------------------------------------------------------------------------- |
| `sent`           | The destination acknowledged the message; it is in flight, not yet confirmed on the handset. |
| `delivered`      | Final success — the handset confirmed receipt.                                               |
| `read`           | The recipient opened the message (read receipt).                                             |
| `failed{code}`   | Terminal failure, carrying one of the documented failure codes below.                        |
| `expired`        | The message's TTL or validity window lapsed before delivery.                                 |
| `rejected{code}` | Pre-send refusal — the platform declined the submission before it left.                      |

The `failed{code}` and `rejected{code}` arms carry a code from a small,
documented vocabulary:

| Failure code                 | Meaning                                                                                         |
| ---------------------------- | ----------------------------------------------------------------------------------------------- |
| `not_on_destination_channel` | The recipient is not reachable on the destination channel (e.g. has no WhatsApp account).       |
| `re_engagement_required`     | The channel requires a fresh recipient-initiated interaction before you can message them again. |
| `rate_limit`                 | A per-recipient or per-template send-rate limit stopped the message.                            |
| `provider_error`             | Any error outside the specific codes above — the catch-all.                                     |
| `ttl_expired`                | The validity period lapsed before the destination accepted the message.                         |

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:

| Meta event                                 | Canonical state                                                                                                      |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `sent`                                     | `sent`                                                                                                               |
| `delivered`                                | `delivered`                                                                                                          |
| `read`                                     | `read`                                                                                                               |
| `failed` with code `131026`                | `failed{not_on_destination_channel}` — the recipient has no WhatsApp account.                                        |
| `failed` with code `131047`                | `failed{re_engagement_required}` — the 24-hour customer-service window lapsed; the recipient must message you first. |
| `failed` with code `131049` or `131050`    | `failed{rate_limit}` — a per-recipient or per-template limit stopped the send.                                       |
| `failed` with any other code (or none)     | `failed{provider_error}`                                                                                             |
| TTL lapse before Meta accepted the message | `expired`                                                                                                            |

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](/concepts/dlr-and-mo-pipeline) describes that ingress path. Carrier
`stat` tokens map into the model like this:

| Carrier `stat` token                                                              | Canonical state                                                                                      |
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `DELIVRD`                                                                         | `delivered`                                                                                          |
| `ACCEPTD`, `ENROUTE`                                                              | `sent`                                                                                               |
| `SCHEDULD`                                                                        | `sent` (queued, progressing)                                                                         |
| `EXPIRED`                                                                         | `expired`                                                                                            |
| `UNDELIV`, `REJECTD`, `DELETED`, `UNKNOWN`, and the other terminal-failure tokens | `failed`                                                                                             |
| Any unrecognised token                                                            | dropped as a duplicate-safe intermediate — the receipt is acknowledged but does not move the message |

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:

| Canonical state  | SMPP receipt                                                      |
| ---------------- | ----------------------------------------------------------------- |
| `sent`           | `stat:ACCEPTD` — the destination acknowledged, message in flight. |
| `delivered`      | `stat:DELIVRD` — final success.                                   |
| `read`           | **No receipt — deliberately.**                                    |
| `failed{code}`   | `stat:UNDELIV` plus an `err:` token from the table below.         |
| `expired`        | `stat:EXPIRED`.                                                   |
| `rejected{code}` | `stat:REJECTD` from the pre-send refusal.                         |

The `err:` tokens on the failure arm are the documented vocabulary:

| Canonical failure code       | `stat`    | `err:` token | What it means                                                     |
| ---------------------------- | --------- | ------------ | ----------------------------------------------------------------- |
| `not_on_destination_channel` | `UNDELIV` | `81`         | Recipient is not on the destination channel.                      |
| `re_engagement_required`     | `UNDELIV` | `88`         | Re-engagement required before you can message the recipient.      |
| `rate_limit`                 | `UNDELIV` | `89`         | A per-recipient or per-template send-rate limit stopped the send. |
| `provider_error`             | `UNDELIV` | `00`         | Unspecified upstream error.                                       |
| `ttl_expired`                | `EXPIRED` | `00`         | The validity period lapsed.                                       |

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](/concepts/smpp-edge-model) 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

```text theme={null}
your SMPP client
      │  submit_sm (registered_delivery requests a receipt)
      ▼
Orbit SMPP edge
      │  preflight: does this terminate on SMS or absorb onto another channel?
      ▼
termination engine  ── absorbs ──▶  destination channel (WhatsApp / RCS / …)
                                          │  channel status events + error codes
                                          ▼
                              canonical delivery state
                             (sent / delivered / read /
                              failed{code} / expired /
                              rejected{code})
                                          │
              ┌───────────────────────────┴───────────────────────────┐
              ▼                                                       ▼
     SMPP encoder                                          webhook encoder
              │                                                       │
      deliver_sm to your bind                           JSON POST to your endpoint
   (stat/err vocabulary above)                   (same canonical fields verbatim)
```

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).

## Related

* [SMPP edge model](/concepts/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](/concepts/dlr-and-mo-pipeline) — how
  carrier-sourced receipts and inbound messages cross into Orbit.
* [Delivery lifecycle](/concepts/delivery-lifecycle) — what each message
  status means and who advances it.
* [Message status transition rules](/concepts/message-status-dag) — the
  allowed status transitions every consumer must agree on.
* [Connect via SMPP](/guides/smpp) — the client-side guide: ports,
  credentials, and choosing SMPP vs REST.
