Skip to main content

The per-message route-trace timeline

A flat status on the message row answers what a message is. It does not answer where it is or how long it spent getting there — and those are the questions an operator asks the moment a recipient says “I never got it.” The route trace is the per-message answer: one ordered timeline of every routing, delivery, and webhook fan-out event the platform knows about for a single message ID, fetched in one API call. The Delivery lifecycle page defines the state machine a row moves through, and the DLR model: two planes page defines which plane a receipt belongs to. This page defines the diagnostic surface you open when an individual row’s story needs to be reconstructed end to end.

Section 1 — What a route trace is

A route trace is a per-message, ordered event timeline assembled on read from rows the platform already persists. When you call GET /api/v1/messages/:id/trace, Orbit reads two things it stores for every message anyway — the message row itself, and the per-message webhook delivery log (one row per delivery attempt to any of your webhook endpoints) — and returns them as one sorted event list plus an aggregate latency summary. Nothing is written on this path, and no new storage exists for it: the endpoint is a pure read-path assembly over the same rows the Delivery Log renders. Two constraints follow from that design, and they shape how you should use the endpoint:
  • A trace reflects history, not a live carrier query. Calling it re-reads the persisted rows; it never re-interrogates the provider or the destination carrier. If a carrier receipt has not arrived yet, the trace shows the state at the last persisted transition — call it again in a minute and it changes only if a new receipt actually landed in the meantime.
  • Do not confuse the trace with per-status polling. The trace assembles from the same persisted row state that GET /api/v1/messages/:id already returns, so polling it buys you nothing over polling the row. The live event path is your webhook subscription; the trace is the diagnostic you open when a specific row needs explaining.

Section 2 — The canonical event vocabulary

Every event in a trace is one of these names. Each carries at (the ISO-8601 timestamp the event was observed, null for stage markers with no timestamp), a hop label naming who performed it (platform, provider:<name>, carrier:<MCCMNC>, or webhook:<endpoint-id>), and a detail payload. On a failure the response also carries a failed_stage field on the message header — validation (a pre-send gate refused the send), submission (routing was resolved but the provider never accepted), or carrier (the provider accepted and the carrier reported the failure back) — so you read where it stopped from one field instead of decoding timestamps. Outbound failures additionally render the stages the message never reached as skeleton rows: at: null with detail.stage_state: "skipped", so the whole expected lifecycle is visible and the stop point is unambiguous. Untimed-but-reached stages (a provider that emits one terminal DLR and never a sent acknowledgement) render the same way with stage_state: "reached" and no fabricated timestamp. The summary block closes the “where did the time go” question in one round trip:
  • accept_to_sent_ms — ingestion to provider acknowledgement.
  • sent_to_delivered_ms — provider submission to carrier-confirmed delivery (the number you quote when a receipt is late).
  • accept_to_terminal_ms — ingestion to the terminal state, whatever it was.

Section 3 — Reading a trace for “delivered-but-never-received”

This is the canonical symptom the endpoint exists for: the recipient swears nothing arrived, and the old industry answer — raise a support ticket, wait for a carrier-side trace — took days. Read the trace bottom-up:
  1. If the terminal event is delivered: the carrier reported handset receipt. The destination-side issue (handset filter, SMS firewall, anti-spam on the destination carrier) now has a name: take the provider and mccmnc from the routed event and the external_id from the sent event — those three fields are exactly what a carrier-side trace request asks for, and you now hand them over without a support round-trip.
  2. If the row holds at sent or submitted_no_receipt: no carrier answered at all. The same routed + sent hop names the provider and MCC/MNC carrier to trace against; compare the destination’s history (per the sent-but-no-receipt troubleshooting page) to tell a slow route from a broken one.
  3. If the row is a failure: failed_stage tells you whose side to fix — a validation failure is your gate to adjust, a submission failure is the provider’s accept side, a carrier failure came back from the destination network.
In all three cases the two fields that replaced the ticket loop are the provider name and the carrier code on the routed event — the hop the carrier-side trace request needs to name.

Section 4 — The webhook fan-out section

The same endpoint proves the other half of a “nothing happened” report: whether Orbit notified your endpoints about the row. Every webhook_fanout event is one delivery to one subscriber endpoint, and its tells are direct:
  • Rising attempts with a non-2xx http_code and a queued next_retry_at — your downstream subscriber is the stuck party. Fix the endpoint (timeouts, 5xx, TLS) and re-check; the receipt path to the carrier is fine.
  • A single 2xx fan-out with no carrier-side terminal event on the row — the subscriber is healthy; the carrier leg is the one to escalate.
  • Latency outlierslatency_ms per attempt distinguishes a slow subscriber from a failing one; a 200 with multi-second latency still points at your endpoint.
Fan-out hops interleave with lifecycle events at their true timestamps, so a trace reads as one timeline, not two.

Section 5 — Access

GET /api/v1/messages/:id/trace is gated by the same scope as the message-detail endpoint: an API key with messages:read or messages:write works — read-only keys keep working, write-only keys work too. A message ID that does not exist returns 404; a malformed ID returns 422 with an actionable error instead of an opaque schema rejection. Full request shape is in the messaging endpoint reference.

Section 6 — Worked example

One stuck SMPP send, traced end to end. The row accepted validation, routed to a provider on a US carrier, was acknowledged in under a second, and then no receipt ever arrived — the submitted_no_receipt sentinel closed the grace window, and one subscriber endpoint has been failing with 503s since the first attempt:
Read it: the carrier leg is the stuck party (single acknowledge, no receipt), and one subscriber endpoint is stuck (three attempts, 503, retry queued). Two different owners, visible in one call. The Meta no-DLR variant is shorter by policy. Meta’s Send API never emits delivery receipts, so after the 5-minute no-DLR grace the trace terminates on submitted_no_receipt — and that is the terminal line, not a stuck row:
On an opted-in recipient, Meta guarantees delivery on accept, so this terminal line is a functional delivery signal — confirm the sent/routed hops exist, and treat the carrier as delivered. The same rule the sent-but-no-receipt troubleshooting page documents for Meta DM channels, confirmed by the trace rather than assumed.