Skip to main content

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 by POST /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 with vrf_ (for example vrf_3f1c0b2a8e4d4f7a9c2b1e6d5a4c3b2a). The prefix tells you at a glance which pillar an id belongs to — the same convention as msg_ for messages and num_ for numbers. When the fallback engine fires an extra delivery attempt, that attempt gets its own vatt_ id, linked back to the parent session.
  • One recipient, one code. A session binds a generated code (4–8 digits) to one to value — 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 403 and fire verification.fraud_blocked. That event deliberately carries no verification_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_DETECTED and 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:
  1. 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.
  2. 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 fires verification.fallback_triggered. Each advance is its own delivery attempt with its own vatt_ id, so your audit trail can distinguish the parent session from each channel hop.
  3. If every channel in the chain is tried without a successful check, the engine closes the session as failed and fires verification.fallback_exhausted.
Fallback never changes the code — the same session, the same OTP, delivered another way. A check that succeeds on any hop still resolves the whole session to approved, and the engine stops. Because advancement is asynchronous, the event stream for one session can legitimately be verification.sentverification.fallback_triggered → (recipient finally enters the code) → verification.checkedverification.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