Skip to main content

Note de langue

Lorsqu’une traduction n’est pas disponible, le contenu anglais est affiché comme solution de repli. Conservez les codes d’erreur, les chemins d’API et les blocs de code.

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