Skip to main content

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; if a terminal failure has already landed, decode it on Troubleshooting: message undelivered or 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.
  • 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.

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 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, and the Validity Period glossary entry). 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.

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