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
sentfor a per-channel grace window (30 minutes on SMPP-backed channels). If no receipt lands inside the window, a scheduler promotes the row tosubmitted_no_receipt— a sentinel that means “submission accepted, outcome unknown,” not a failure.
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 todelivered(or a terminal failure likeundelivered) and fires a fresh lifecycle webhook. The same reconciliation covers a correction — some carriers emitdeliveredand then a failure receipt minutes later (common on some Indian and Brazilian SMS routes), and Orbit honors the correction, so a row can movedelivered → undeliveredordelivered → 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 statusexpired, which fans out on themessage.failedevent with a payloadstatusofexpired. The underlying message may in fact have delivered —expiredmeans the outcome is unknowable, record closed.
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:- 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 thedelivered → undeliveredcorrection. - Treat
submitted_no_receiptas 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 likeexpired. - Accept corrections. If you mirrored
deliveredand Orbit later sendsundeliveredorfailedfor the same id, apply the update — the carrier’s last receipt is the verdict, and the platform is relaying it. - 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.
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:- Read the row.
GET /api/v1/messages/:idreturns the authoritative status. If it still showssent, the grace window has not closed yet — wait. If it showssubmitted_no_receipt, the window has closed and you are deciding between: - Accept
expired. For informational traffic you can let the row age out.expiredonly means the receipt outlived the reconciliation window; exclude it from failure metrics and move on. - 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_periodso 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. - Never retry the same row while intermediate.
POST /api/v1/messages/:id/retryis for terminal failures; hammering it against asent/submitted_no_receiptrow 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-
deliveredcorrections 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 → readcallbacks with Meta-side timestamps; there is no SMSC validity period, and a blocked recipient or a recipient with no WhatsApp account simply never emitsdelivered— the row ages atsent, not through the SMSC retry loop. Meta’serror_code/error_messageonmessage.failedtells 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 atsubmitted_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_receiptwell 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.
- Your tenant ID (shown in the dashboard under Settings →
Organization, and returned by the
GET /api/v1/meresponse asorganizationId). - The message ID (
msg_…) of one representative stuck row.
See also
- Troubleshooting: message sent but no delivery receipt — the provisional-state sibling this page recovers from
- Troubleshooting: message undelivered or failed — the terminal-failure decoder a reconciled row may land in
- Webhook events reference — payload shape
and the
submitted_no_receipt/expiredevent mapping - Delivery lifecycle — the concept-level state machine
- Glossary — DLR, SMSC, and Validity Period definitions