Verification session lifecycle
Verification is a first-class pillar in Orbit: OTP sessions that prove a recipient controls a phone number, email address, or device. Every verification you create is a session — a single row with its own id, its own expiry, and its own outcome — and this page is the concept-level map of how that session behaves from dispatch to terminal state. Endpoint parameters and request/response shapes live in the Verify API reference and are deliberately not repeated here. This page explains the model those endpoints operate on, so you know what a session is before you branch your integration on its outcome.What a verification session is
A verification session is created byPOST /verify/send (or a bulk send) and represents one attempt to prove possession of one recipient. Its properties:
vrf_id prefix. Every session id starts withvrf_(for examplevrf_3f1c0b2a8e4d4f7a9c2b1e6d5a4c3b2a). The prefix tells you at a glance which pillar an id belongs to — the same convention asmsg_for messages andnum_for numbers. When the fallback engine fires an extra delivery attempt, that attempt gets its ownvatt_id, linked back to the parent session.- One recipient, one code. A session binds a generated code (4–8 digits) to one
tovalue — a phone number in E.164 or an email address. The code is never re-used across sessions. - Delivery vs. factor channels. Delivery channels carry the code to the recipient: SMS, WhatsApp, voice (read aloud), email, Viber, Telegram, RCS, and flashcall. Factor channels — TOTP, backup codes, push, passkeys — never carry a code over a carrier; they validate a server-side secret or device key pair through their own endpoints and lifecycle.
- Bounded lifetime and guess budget. A session expires after a TTL (default 600s, up to 3600s via a profile) and allows a bounded number of wrong guesses (default 3, up to 10). Hitting either bound closes the session.
- Tenant-owned controls. Everything about a session’s behavior is your organization’s configuration, not a platform mandate: the channel list, the fallback chain, code length, TTL, attempt limits, per-recipient rate caps, per-channel templates, and fraud levers. Pin them once on a verify profile and pass its
profile_id, or leave a send profile-less and get platform defaults.
The lifecycle
A session moves through a small state machine.pending → approved and pending → failed are the terminal arcs; expired is the third terminal outcome when the TTL lapses first.
A wrong guess that still has attempts remaining is non-terminal: the session stays
pending, the attempt counter increments, and the check emits verification.checked with outcome: "pending". Only the outcomes above close the session.
Two pre-send refusals sit outside this machine entirely — no session is created, so none of its states or terminal events apply:
- Fraud Guard block. A pre-send fraud verdict can refuse the send with a
403and fireverification.fraud_blocked. That event deliberately carries noverification_id: it signals a policy refusal, not a session failure — do not feed it into your lockout or step-up counters. - SIM-swap pre-flight. If SIM-swap screening rejects the send, you get a synchronous
403 SIM_SWAP_DETECTEDand no session row at all. Wire failure/step-up logic to that response, not to a webhook that never comes.
The async fallback engine
When a send carries an ordered channel chain (channels: ["sms", "whatsapp", "voice"], min 1, max 4), the session is not tied to a single delivery. A background fallback engine watches the session after dispatch:
- The primary channel (index 0) gets the first attempt. The engine waits
channel_timeout_seconds(default 60, configurable 10–600) for the session to resolve. - If the session is still unresolved — or the delivery failed — the engine advances to the next channel in the chain, persists the new
current_channel_index, and firesverification.fallback_triggered. Each advance is its own delivery attempt with its ownvatt_id, so your audit trail can distinguish the parent session from each channel hop. - If every channel in the chain is tried without a successful check, the engine closes the session as
failedand firesverification.fallback_exhausted.
approved, and the engine stops. Because advancement is asynchronous, the event stream for one session can legitimately be verification.sent → verification.fallback_triggered → (recipient finally enters the code) → verification.checked → verification.approved — reconcile by verification_id, not by assuming one dispatch per session.
max_attempts_per_channel (default 1, up to 3) controls how many dispatch tries a channel gets before the engine moves on. The full configuration walkthrough is in the fallback chains guide.
The events a session emits
Subscribe to these to observe the lifecycle without polling:
The per-event payload fields are catalogued in the webhook events reference. The branch points that matter:
verification.checked.outcome for per-attempt handling, and verification.approved / verification.failed as the terminal outcomes your application logic should key on.
Where the how-to lives
- Verify overview — send and check calls, supported channels, configuration options, and the MFA factor suites (TOTP, backup codes, push, passkeys).
- Verify profiles: fallback chains and advanced factors — build the ordered channel chain, set code length/TTL/attempt budgets, and pair a profile with factor authentication.
- Verify integration without our SDK — plain-HTTP send/check with the full error matrix and webhook walkthrough.
- Verify API reference — every endpoint, including profile CRUD and the factor suites.