Relay docs

suiss Relay · Docs

Delivery status

Draft reference for Relay's planned delivery projection, internal state axes, channel evidence rules, unknown windows and reason codes.

This is a draft reference. States, fields, windows and reason codes may change before release.

Delivery state in Relay is derived from an append-only ledger. Ledger rows are never updated; every status shown by the API or in webhooks is a projection that can always be rebuilt from the ledger. A periodic rebuild test is planned to compare the projection with a fresh recomputation.

External projection

The API and webhooks show this projection, not the internal model. It is designed to stay stable when the internal model changes; adding a value to status follows the API versioning rules.

FieldValues
statusqueued, sent, delivered, unknown_pending, unknown, failed, skipped, expired, cancelled
engagementSeparate timestamps for seen, read, clicked and archived; clicks are split into human and bot
reason, rule_idRequired for skipped, failed, expired and cancelled; also present on deferrals
failurekind (bounced, failed, …), class (failure class), normalized code, retryable
statusMeaning
queuedAccepted and waiting to send, including scheduled, deferred and rate-limited work
sentThe provider accepted it; no conclusive delivery evidence yet
deliveredThe channel's delivery evidence arrived
unknown_pendingThe short window passed with no conclusive event; still waiting
unknownThe long window passed with no conclusive event; a late event can still raise it
failedA conclusive failure, including bounces; failure.kind tells them apart
skippedNot sent because of a Relay decision
expiredPassed its expires_at and was not sent
cancelledCancelled before sending

There is no separate notification-level delivery status. A notification's delivery view is the list of its deliveries with counts per status. The workflow's run state (waiting, completed, cancelled) is a separate field.

Derivation rules

  1. If the delivery axis is delivered and there is no valid post-delivery failure, status is delivered.
  2. If there is a valid conclusive failure, status is failed and delivered_at is kept (for example, an email bounce that arrives after delivery).
  3. With no conclusive event, the window rules apply: sent, then unknown_pending, then unknown.
  4. A late conclusive event always raises unknown_pending and unknown. unknown can become delivered or failed.
  5. skipped, expired and cancelled are final, because nothing was sent.

Internal axes

Internally a delivery is not squeezed into one state. Four axes answer separate questions:

AxisValuesWritten byMonotonic
Attemptpending, in_flight, succeeded, failed, unknownRelay onlyNo; each attempt has its own lifecycle
Delivery certaintynone < accepted < deliveredEvidence tableYes; never decreases
Failure kindskipped, failed, bounced, expired, cancelled, plus failure class, reason and rule_idEvidence table and policy decisionsPer channel rules
Engagementseen, read, clicked, archived, each with its own timestamp, source and (for clicks) human or bot classEvidence table, inbox actions, SDK receiptsEach on its own; independent of delivery

Example: an SMS attempt times out at the provider (attempt unknown), a second attempt is accepted (delivery accepted), then the first attempt's late delivery report says delivered. The delivery becomes delivered; if both attempts were delivered, duplicate_delivered is flagged and stays visible.

Ordering and late events

  • The projection only moves forward. An update applies only if the new value ranks higher; otherwise the event is counted as late and still written to the ledger.
  • Events for the same delivery are reduced in this order: attempt number (an old attempt's late event cannot overwrite a newer attempt), then the provider's sequence number, then occurred_at and received_at, then source priority for the same attempt and event type (provider webhook, then provider API, then Relay).
  • Timestamps cannot be in the future: occurred_at must be no later than received_at plus one hour.
  • Conflicting evidence sets has_conflict.

Failure after delivery

Whether a failure can follow delivered is defined per channel (after_delivered):

ValueEffectExample
validThe failure applies; delivery stays delivered but the projection becomes failedAsynchronous email bounce; a WhatsApp number that later becomes invalid
staleWritten to the ledger; projection unchangedAn expiry that arrives after delivery
conflictWritten; the delivery is flagged has_conflictA permanent failure for a message already read

Ledger event sources

The actor of a ledger event is one of relay, provider_api, provider_webhook, client_sdk or user. client_sdk is a receipt sent by the app on the device (displayed, tapped); user is a human action. They are never mixed.

Channel evidence

Each channel declares which events count as delivery evidence. Events are mapped through a versioned evidence table; an undefined event code is rejected, and an unmapped provider code leaves the projection unchanged, writes an unknown_provider_code event and raises an alarm. The raw provider code is always kept.

ChannelWhat sent meansdelivered evidenceEngagement evidenceSignals that can drive decisions
EmailThe provider or mail server accepted itThe provider's delivery event or a success DSN; a human clickOpens (unreliable), clicks (human or bot)Human clicks only
SMSThe provider accepted itDelivery report deliveredNoneNone
WhatsAppThe provider accepted itThe delivered eventread (the recipient may turn it off; its absence means nothing), interactive repliesread
PushThe provider accepted itOnly a "displayed" receipt from the mobile or web SDKSDK "displayed" and "tapped" receiptsSDK receipts
In-app—The inbox item was committedseen, read, clicked, archivedseen, read, clicked
Webhook channel—The destination's success responseNoneNone
VoiceThe provider started the callThe provider's answered or completed eventKey-press reply, if anyNone

Rules:

  • Push. Provider acceptance is never reported as delivered. Without an SDK receipt, a push stays sent and is shown as "accepted by provider". Push never becomes unknown, because there is no delivery report to wait for. Reports show acceptance rate and SDK-confirmed delivery rate separately.
  • Reads prove only their own channel. Reading in the inbox does not prove the SMS or push for the same notification was delivered.
  • A human click proves email delivery, even without a provider delivery event. Bot clicks and opens never count as delivery.
  • Opens are never used in any decision or as delivery evidence. Engagement events carry a machine_open flag for opens made by privacy proxies, caches and security scanners.
  • Clicks are classified as human or likely_bot using signals such as clicks seconds after delivery, all links clicked at once, known scanner IP ranges, user agent and request shape. Reports show human and bot clicks separately.
  • Tracking defaults. Open pixels and link wrapping are off by default and can never be turned on for security messages or code and verification emails.

Unknown windows

For channels that wait for a conclusive delivery event, two windows follow sent: when the short window passes the delivery becomes unknown_pending; when the long window passes it becomes unknown. Windows are data per channel and can be tightened or extended per channel, provider and tenant within limits.

ChannelShort windowLong window
SMS6 hours72 hours
Email24 hours72 hours
Push, in-app, webhook channelNot appliedNot applied

Other channels, such as WhatsApp and voice, have their own planned values.

  • A late conclusive event (delivered or failed) is accepted whenever it arrives, written to the ledger and raises the projection.
  • unknown, or a reconciliation result of "not delivered", never triggers an automatic resend. Resending is a human or tenant decision, made with a new request.
  • unknown does not trigger fallback; fallback moves only on permanent failure. Time-based escalation runs independently and stops only on reliable signals.
  • Periodic reconciliation pulls the state of open deliveries from the provider and writes ledger events under the same rules.

Attempt and failure classes

Attempt states: pending, in_flight, succeeded, failed, unknown. succeeded, failed and unknown are terminal for an attempt, and a terminal attempt state is never overwritten.

Failure classes: transient, permanent_target, permanent_content, policy, quota, auth, sender_config, unknown. Retry and fallback behavior per class is described in Architecture.

Reason codes

Every skip, deferral and block comes from a single, versioned rule_id dictionary. Each rule ID maps to a reason code and to the place the rule is defined.

reason familyExample ruleExtra detail
preference_offThe user turned it offThe level that blocked it: category × channel, notification type, single object (scope_key) or weekly schedule
consent_missing, consent_revoked, iys_rejectedNo consent, consent withdrawn, no İYS consent or a stale İYS syncReference to the consent record
suppressed_addressSuppression list: hard bounce, complaint, opt-outReference to the suppression record
quiet_hours_droppedDrop-during-quiet-hours policyRecipient local time and time zone source
frequency_cappedFrequency capThe cap rule
duplicate_contentContent deduplication windowWindow and matching notification
scope_mismatchUndefined or mismatched sub-tenantRequested scope
no_channel_variantNo template variant for the channelTemplate version
no_addressNo channel address or active device—
no_permissionNo OS notification permission on the deviceDevice permission state
country_ruleA compliance table ruleCountry × channel × class row
channel_policyA class and channel rule, for example otp_oob never goes to email—
tenant_suspended, sender_unverifiedTenant or sender suspended, sender identity not approved—
expiredexpires_at passed—
cancelledA cancellation command or tombstoneThe cancellation request

An operator stop control also produces a skip with its own reason code and the stop record's reason.

Specific rule IDs mentioned elsewhere in these docs include iys_not_configured, iys_stale, otp_channel_forbidden, send_mode_off, next_break and timing_ignored.

Every skip is visible in three places: the console, the notification.suppressed webhook event and metrics.

Deferrals

A deferral (quiet hours, a defer frequency policy, a rate limit, a time window) is not a skip. The ledger records a deferred event with reason, rule_id and deferred_until; the projection stays queued and carries deferred_until.

"Why didn't I get it?"

For a recipient and a time range, the console and the API are planned to show each notification's decision chain: which gates it passed, which rule stopped or deferred it at which level, and which channels were tried with which result.

Always-visible metrics

These metrics are always produced and never hidden: duplicate_delivered and conflict counts, late events, unknown_pending and unknown rates per channel and provider, unknown_provider_code counts, funnel gaps, reconciliation delay, delivery report delay, and bot click rate.

The delivery funnel runs: accepted, queued, sent to provider, accepted by provider, delivered, engaged. The primary delivery health metric is funnel completeness rather than error rate.