Skip to main content

Operational sentinels

Not every status value on a message row describes a delivery. Besides the delivery outcomes (delivered, failed, undelivered, rejected, bounced) that the delivery lifecycle walks through, a message can settle at a sentinel that means something acted on the message before or instead of a carrier outcome: a sandbox send, a suppression, a deletion, an unparseable provider callback, or a complaint-side effect. These sentinels are settled — the row will not move on its own — but they are not delivery outcomes. Integrations that treat “settled” as “delivered or failed” silently mis-bucket them. This page enumerates the sentinel states, what produces each one, and how to keep them out of your delivery-rate math.

The sentinel set

A sentinel is one of: Four qualities separate them from delivery outcomes:
  1. No carrier asserted them. A sentinel is written by Orbit itself (or by an operator), never by a delivery receipt from the network.
  2. They settle outside the happy path. A test_sent or suppressed row never passed sending; a deleted row can start from any terminal state.
  3. They must not count as failures. test_sent, suppressed, and deleted are neutral — the message simply never went out — while complaint and genuine failures do count against failure rates.
  4. They must not stay in-flight. Every sentinel is a terminal signal for reconciliation purposes: a batch whose remaining rows are all sentinel-marked is drained, not stuck.

How sandbox and test mode resolve to test_sent

When test mode is active, the send pipeline returns success without ever calling a provider, and the row is written directly as test_sent — a dedicated terminal status, deliberately not sent followed by a simulated delivered. Test mode activates three ways: a dv_test_sk_* sandbox API key, an organization flagged as sandbox, or the dashboard’s Test mode toggle (which sends the X-Test-Mode: true header on API calls from the panel). Two flavors resolve differently:
  • Shared send pipeline (POST /messages/<channel>). The row is persisted with status: "test_sent", metadata stamped test_mode: true, no provider call, no balance deduction. Subscribers receive message.sent with status: "test_sent" and metadata.test_mode: true; message.delivered is intentionally not emitted — emitting it would lie at the webhook layer. The dashboard shows an amber Test sent badge so operators can tell a simulated send from a real one.
  • WhatsApp direct-send routes (send-flow, interactive, location/address request, catalog send, call initiate, marketing send). These bypass the shared pipeline and would otherwise hit Meta directly, so they short-circuit the same way: a synthesized test_… message id, the same response envelope, status: "test_sent", and a tamper-evident audit record that the simulated send never reached the provider. The response is not persisted to the messages table — it is purely the wire shape clients receive — and credits are never touched.
Sandbox delivery-receipt simulation (the magic-numbers trailing-digit convention) fires webhook events for scenarios like delivered or failed, but those receipts appear only in webhook payloads — the stored row keeps its honest test_sent status. Branch your webhook handling on metadata.test_mode to keep sandbox traffic out of production paths.

How suppression and complaint statuses are produced

Suppression produces two distinct signals depending on the layer that fires, and both end with the send never reaching a provider. Message-suppression policy (suppressed). When a tenant enables the duplicate-content policy on the message-suppression surface, the send pipeline claims a content hash for <channel, recipient, body> inside the configured window. A second send with identical content inside that window is dropped before it burns a frequency-cap slot: the pipeline returns success with status: "skipped", reason: "duplicate_content", refunds any quota it had pre-claimed, and — on channels whose conversation UI renders the attempt (Telegram today) — persists a status='suppressed' row so operators can see the blocked attempt in the timeline. Test-mode sends bypass the check so staging can replay identical bodies. Opt-out suppression (channel-level). Channel opt-out lists also block before dispatch (behavior varies by channel — the API skips the send; some channels persist the suppressed audit row). Manage the lists on the opt-outs reference. Complaint-side suppression. When a recipient reports an email as spam, the provider fires a complaint webhook. The row settles as failed (complaints are terminal failures for accounting purposes — no refund on a complaint), and the recipient’s address is written to the platform suppression list with reason complaint, which short-circuits every future send to that address. The same list carries hard_bounce, manual (ops-curated additions), and soft_bounce_exhausted (an address that soft-bounces on every send past the exhaustion threshold); all four are terminal reasons, and only the soft-bounce-exhausted class is expected to be lifted manually. A complaint therefore has two faces: the message row closes as failed, and the recipient becomes un-sendable until the suppression entry is removed.

unknown: the defensive callback sentinel

unknown is the one sentinel that is genuinely transient. The DLR pipeline maps each provider callback onto the status table; when a callback’s status string matches nothing (a new provider-side value, an unexpected payload shape), the row is parked at unknown rather than dropped on the floor. It is forward-enrichable by design: a later callback with a recognizable status can still advance the row to a concrete outcome, so unknown never blocks the lifecycle. Treat it as mapping gap, not message outcome — an unusual spike in unknown rows on one channel points at a provider contract change worth telling support about, not at a delivery problem.

Why analytics must key these off the right column

Reporting splits the status vocabulary three ways, and a sentinel that lands in the wrong bucket corrupts the number next to it:
  • Delivery-rate denominators use the curated terminal set (delivered, read, submitted_no_receipt, failed, undelivered, rejected, bounced). The operational sentinels are deliberately absent — test_sent traffic and deleted rows are neither successes nor failures, so including them would deflate the rate with messages that never attempted delivery.
  • Silent-route / SLO filters must classify a route as settled the same way the reconciliation scheduler does — keyed off the full settled set (the DLR-terminal statuses plus the operator sentinels), not the narrower delivery-rate set. Filtering on the delivery-rate set alone mis-classified routes with test-mode sends or deleted rows as permanently “silent”, firing false SLO-breach alerts.
  • Drained/in-progress detection uses the in-flight predicate (scheduled, queued, sending, sent), not the terminal set — so a row that settled at test_sent, suppressed, deleted, or unknown correctly reads as done rather than sticking a batch at “running” forever.
When you build your own reporting on top of the analytics endpoints or your warehouse, mirror the same discipline: exclude the operational sentinels from delivery-rate numerators and denominators, and treat them as settled for reconciliation.

Handling sentinels in your integration

  • Branch on data.status in webhook payloads, and read metadata.test_mode before treating a sent event as production traffic.
  • complaint arrives as message.failed; detect it via the metadata, not the status, if you need to reconcile it against a recipient-level block.
  • test_sent, suppressed, and deleted are operator-visible rows, not deliveries — exclude them when you compute your own delivery rates from GET /messages exports, and expect no webhook event for a delete.
  • If a row sits at unknown, wait for a follow-up callback before alerting — it resolves on its own when the provider sends a recognizable status.
  • Poll GET /messages/:id for the current sentinel when you expose operator delete/cancel in your own UI; these flows emit no webhook.

See also