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

# Troubleshooting: DLR retry and late-receipt recovery

> Recover from a late or failed delivery receipt — how the grace window promotes sent to submitted_no_receipt, how a late DLR reconciles or closes as expired, what a carrier-side SMSC retry does before that, and the idempotent webhook consumption pattern to follow on every one of those transitions.

# Troubleshooting: DLR retry and late-receipt recovery

A delivery receipt (DLR) can arrive minutes, hours, or even days after
a send — or never arrive at all. This page covers the runtime side of
that: what a carrier-side retry does while the message is in flight, how
a late receipt closes or flips the row, and how to consume every
transition idempotently in your integration. If you are diagnosing a row
that simply has no receipt yet, start with
[Troubleshooting: message sent but no delivery receipt](/troubleshooting/submitted-no-receipt);
if a terminal failure has already landed, decode it on
[Troubleshooting: message undelivered or failed](/troubleshooting/message-undelivered-failed).

## 1. What a DLR retry is, and when it trips

Receipts pass through two independent retry layers before Orbit ever
sees them:

* **Carrier-side retry (SMSC store-and-forward).** On SMPP-backed
  channels the destination carrier's **SMSC** accepts the message and
  keeps re-attempting delivery to a temporarily unreachable handset
  until it either succeeds or the message's **Validity Period**
  (commonly 24–72 hours) runs out — only then does it emit a final
  receipt. A "slow" receipt on one of these channels is usually the
  SMSC still retrying, not a lost receipt. See the glossary entries for
  [SMSC and Validity Period](/reference/glossary).
* **Platform-side grace window.** Orbit holds `sent` for a per-channel
  grace window (30 minutes on SMPP-backed channels). If no receipt
  lands inside the window, a scheduler promotes the row to
  `submitted_no_receipt` — a sentinel that means "submission accepted,
  outcome unknown," not a failure.

When the carrier finally exhausts its retries (or reports a hard
failure first), or when the receipt simply arrives late, the row
leaves that sentinel state in one of two ways — the next section.

## 2. When a late DLR lands and flips the row

Every receipt Orbit accepts inserts into the row, whatever the current
status is. The two recovery shapes:

* **Late success or late failure — the row reconciles.** A receipt
  arriving after the grace window promoted the row to
  `submitted_no_receipt`, but before the late-arrival window closed,
  overwrites the sentinel with the carrier's verdict: the row flips to
  `delivered` (or a terminal failure like `undelivered`) and fires a
  fresh lifecycle webhook. The same reconciliation covers a correction —
  some carriers emit `delivered` and then a failure receipt minutes
  later (common on some Indian and Brazilian SMS routes), and Orbit
  honors the correction, so a row can move `delivered → undelivered` or
  `delivered → failed`.
* **Receipt past the late-arrival window — the row closes as
  `expired`.** Receipts reconcile only inside the platform's
  late-arrival window. A receipt arriving after it closes cannot be
  reconciled; the row resolves to the terminal status `expired`, which
  fans out on the `message.failed` event with a payload `status` of
  `expired`. The underlying message may in fact have delivered —
  `expired` means the outcome is unknowable, record closed.

Branch your integration on the payload's `status` field, not the event
type: `submitted_no_receipt` and `expired` both arrive on the
`message.failed` event because there is no dedicated event for them. See
[the delivery-semantics note in the webhook events reference](/reference/webhook-events).

## 3. Idempotent webhook consumption (the recovery pattern)

Late and corrected receipts are normal traffic, so consuming statuses
safely is a requirement, not a tuning detail:

1. **Key every update by message id.** Apply webhook statuses keyed by
   `data.message_id`, never by event order. A later event supersedes an
   earlier one for the same id — including the sentinel overwrite and
   the `delivered → undelivered` correction.
2. **Treat `submitted_no_receipt` as provisional.** Do not log it to
   your datastore as a final answer; expect it to be overwritten. Keep
   it out of hard-failure rates exactly like `expired`.
3. **Accept corrections.** If you mirrored `delivered` and Orbit later
   sends `undelivered` or `failed` for the same id, apply the update —
   the carrier's last receipt is the verdict, and the platform is
   relaying it.
4. **Close on `expired`.** Move the row to a closed-with-unknown-outcome
   bucket. If the content mattered (an OTP is the classic example), that
   is your cue to re-send on a new message id — the old row does not
   re-open.

The [webhook-event-dedup](/troubleshooting/webhook-event-dedup) page
covers the adjacent at-least-once subscription discipline; the two
patterns pair: dedupe events by `body.id`, apply them by
`data.message_id`.

## 4. Recover a row stuck on submitted\_no\_receipt past the grace window

Work the row in order:

1. **Read the row.** `GET /api/v1/messages/:id` returns the
   authoritative status. If it still shows `sent`, the grace window has
   not closed yet — wait. If it shows `submitted_no_receipt`, the window
   has closed and you are deciding between:
2. **Accept `expired`.** For informational traffic you can let the row
   age out. `expired` only means the receipt outlived the reconciliation
   window; exclude it from failure metrics and move on.
3. **Re-send with a longer validity period.** If the receipt (or the
   content) matters, send a fresh message — and on SMS set a wider
   **`validity_period`** so the SMSC keeps its store-and-forward retry
   open longer (a **Messaging Service** can bind this as the sender-level
   default instead of per send — see the
   [sender-resolution concept](/concepts/sender-resolution), and the
   [Validity Period glossary entry](/reference/glossary)). Re-sending
   always mints a new message id; the old row stays closed.
4. **Never retry the same row while intermediate.** `POST
   /api/v1/messages/:id/retry` is for terminal failures; hammering it
   against a `sent`/`submitted_no_receipt` row risks a duplicate
   delivery when the pending receipt resolves.

## 5. Per-channel differences

* **SMS (SMPP-backed, incl. MMS/fax/voice receipts).** The full
  store-and-forward + late-arrival machinery above applies. Late DLRs
  and post-`delivered` corrections are most common on handsets that
  roam or poll, and on some IN/BR routes. A none-returning route
  concentrated in one destination is a routing problem, not a retry
  problem.
* **WhatsApp.** Meta pushes `sent → delivered → read` callbacks with
  Meta-side timestamps; there is no SMSC validity period, and a blocked
  recipient or a recipient with no WhatsApp account simply never emits
  `delivered` — the row ages at `sent`, not through the SMSC retry
  loop. Meta's `error_code`/`error_message` on `message.failed` tells
  you the refusal; the late-receipt mechanics above do not govern.
* **RCS.** Receipts ride the RCS platform's callback path; delivery
  depends on the recipient's RCS capability and the Google/Jibe
  backend, so receipt timing differs per handset capability rather than
  per SMSC. RCS-specific decode hints are on
  [Troubleshooting: RCS undelivered](/troubleshooting/rcs-undelivered).

## 6. When a stuck row is a provider signal, not a per-row fault

One row lingering at `submitted_no_receipt` is carrier behavior. A
wide spread of destinations, or your whole SMS fleet, flipping to
receipt-less at the same time is a **provider-connectivity signal** —
an edge or pipeline gap on the receipt path — and it warrants an
escalation, not more row-level recovery. The same true signal rule sits
behind the `/reference/troubleshooting` queue-side page: fix the layer
that is broken, not the symptom you can see.

## When to escalate

Open a support ticket when one of these holds:

* A row sits at `submitted_no_receipt` well past the grace window and
  no terminal event has reconciled it.
* Many destinations stopped returning receipts at the same time (the
  provider-connectivity signal above).
* You need the provider-side trace: include the **Provider Reference**
  from the Delivery Log row.

Include both of these so support can pull the DLR pipeline trace
without a back-and-forth:

* Your **tenant ID** (shown in the dashboard under Settings →
  Organization, and returned by the `GET /api/v1/me` response as
  `organizationId`).
* The **message ID** (`msg_…`) of one representative stuck row.

## See also

* [Troubleshooting: message sent but no delivery receipt](/troubleshooting/submitted-no-receipt)
  — the provisional-state sibling this page recovers from
* [Troubleshooting: message undelivered or failed](/troubleshooting/message-undelivered-failed)
  — the terminal-failure decoder a reconciled row may land in
* [Webhook events reference](/reference/webhook-events) — payload shape
  and the `submitted_no_receipt`/`expired` event mapping
* [Delivery lifecycle](/concepts/delivery-lifecycle) — the concept-level
  state machine
* [Glossary](/reference/glossary) — DLR, SMSC, and Validity Period
  definitions
