> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Verification session lifecycle: sent, checked, approved or failed

> How a Verify session (vrf_ id) moves from code dispatch through check attempts to an approved or failed terminal outcome — including the async fallback chain and the events each transition emits.

# 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](/api-reference/endpoints/verify) 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](/guides/verify-fallback-chains) 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.

| Transition             | Meaning                                                                           | What advances it                                                                                                                               |
| ---------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| *(create) → `pending`* | The session row exists and the code has been generated.                           | Your `POST /verify/send` call.                                                                                                                 |
| `pending → pending`    | The code was handed to the provider for delivery. Still awaiting a check.         | The send path, once the provider accepts the dispatch — this is when `verification.sent` fires.                                                |
| `pending → approved`   | A `/verify/check` call submitted the correct code. **Terminal.**                  | The check path, the moment the submitted code matches. CAS-guarded so `verification.approved` fires exactly once per session.                  |
| `pending → failed`     | The guess budget ran out: `max_attempts` wrong codes with no match. **Terminal.** | The check path, on the attempt that exhausts the budget.                                                                                       |
| `pending → expired`    | The TTL lapsed before any successful check. **Terminal.**                         | Time. A `/check` against an already-expired session is consumed as an attempt, persists the `expired` status, and returns `410 EXPIRED_TOKEN`. |

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.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](/guides/verify-fallback-chains).

## The events a session emits

Subscribe to these to observe the lifecycle without polling:

| Event                             | Fires when                                                                                                                                                                                                                     |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `verification.sent`               | The code was handed to the provider for the current channel. Can fire once per fallback hop.                                                                                                                                   |
| `verification.checked`            | **Every** `/verify/check` attempt — terminal (`approved` / `failed` / `expired`) and non-terminal (`pending`, wrong code with attempts left). The payload's `outcome` field carries which. Use it for audit and fraud scoring. |
| `verification.approved`           | A check matched the code. Exactly once per session.                                                                                                                                                                            |
| `verification.failed`             | `max_attempts` exhausted with no match. Terminal.                                                                                                                                                                              |
| `verification.fallback_triggered` | The async engine advanced to the next channel in the chain.                                                                                                                                                                    |
| `verification.fallback_exhausted` | Every channel in the chain was tried without a successful check.                                                                                                                                                               |
| `verification.fraud_blocked`      | A pre-send fraud refusal — no session was created. Keep it out of terminal-failure counters.                                                                                                                                   |

The per-event payload fields are catalogued in the [webhook events reference](/webhooks/events). 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](/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](/guides/verify-fallback-chains) — build the ordered channel chain, set code length/TTL/attempt budgets, and pair a profile with factor authentication.
* [Verify integration without our SDK](/guides/verify-no-sdk) — plain-HTTP send/check with the full error matrix and webhook walkthrough.
* [Verify API reference](/api-reference/endpoints/verify) — every endpoint, including profile CRUD and the factor suites.
