> ## 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.

# Verify

> OTP verification service for phone and email authentication

# Verify

Orbit Verify is a turnkey OTP (one-time password) verification service. Send verification codes via SMS, WhatsApp, Voice, or Email — Orbit handles code generation, delivery, expiration, and validation so you don't have to.

## Send a Verification Code

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/verify/send \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+14155552671",
    "channel": "sms",
    "code_length": 6
  }'
```

### Response

```json theme={null}
{
  "data": {
    "verification_id": "vrf_3f1c0b2a8e4d4f7a9c2b1e6d5a4c3b2a",
    "status": "pending",
    "channel": "sms",
    "expires_at": "2026-03-08T00:10:00Z"
  }
}
```

## Send in Bulk

Need to fan an OTP out to many recipients at once (login surges, password-reset campaigns)?
`POST /api/v1/verify/bulk` accepts an array of **up to 1000 recipients** that share one
`channel` and optional verify profile. Every recipient runs through the same per-recipient
send pipeline as `/verify/send`, and the call returns one result row per recipient plus batch
totals. The HTTP status reflects the batch outcome: **`201`** when every recipient was sent,
**`207`** (Multi-Status) on partial success, and **`400`** when every recipient failed. See the
[Verify API reference → Bulk send](/api-reference/verify#bulk-send) for the full request/response
shape.

## Check a Verification Code

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/verify/check \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "verification_id": "vrf_3f1c0b2a8e4d4f7a9c2b1e6d5a4c3b2a",
    "code": "482901"
  }'
```

### Response

```json theme={null}
{
  "data": {
    "verification_id": "vrf_3f1c0b2a8e4d4f7a9c2b1e6d5a4c3b2a",
    "status": "approved",
    "channel": "sms"
  }
}
```

## Supported Channels

`POST /verify/send` accepts the channel values below. The first group **delivers** an OTP
to the recipient; the second group are **non-delivery / factor channels** that don't carry
a code over a carrier and are handled by dedicated factor endpoints (see
[MFA Factors](#mfa-factors) below).

### Delivery channels

| Channel     | Delivery Method                                                                                                                                                                                                                                                                                                                             |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sms`       | SMS text message with the code                                                                                                                                                                                                                                                                                                              |
| `whatsapp`  | WhatsApp message with the code                                                                                                                                                                                                                                                                                                              |
| `voice`     | Automated voice call that reads the code aloud                                                                                                                                                                                                                                                                                              |
| `email`     | Email with the code and branded template                                                                                                                                                                                                                                                                                                    |
| `viber`     | Viber message with the code                                                                                                                                                                                                                                                                                                                 |
| `telegram`  | Telegram message with the code                                                                                                                                                                                                                                                                                                              |
| `rcs`       | RCS Business Message with the code, delivered through a branded verified-sender agent (logo + checkmark)                                                                                                                                                                                                                                    |
| `flashcall` | Missed-call OTP — a short outbound call from a rotating caller-ID pool, dropped before pickup, whose trailing caller-ID digits are the code. The call originates over the Devotel softswitch; until a CLI pool is provisioned it fails closed with `503 SERVICE_UNAVAILABLE` (`details.reason: "PROVIDER_NOT_WIRED"`, same caveat as `sna`) |

#### Telegram delivery configuration

The `telegram` channel delivers codes through Telegram's
[Gateway API](https://gatewayapi.telegram.org) (`sendVerificationMessage`) — the
verification gateway, which is separate from the Telegram **Bot API** that powers the
[Telegram messaging channel](/channels/telegram). To enable it, set these on the API
service:

| Variable                           | Required | Notes                                                                                                                     |
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------- |
| `DEVOTEL_TELEGRAM_GATEWAY_API_KEY` | yes      | API token from your Telegram Gateway account. **Secret** — provision via your secret store, never commit it.              |
| `DEVOTEL_TELEGRAM_GATEWAY_URL`     | no       | Gateway base URL. Defaults to `https://gatewayapi.telegram.org`; override only to route through a proxy or a test double. |

Until `DEVOTEL_TELEGRAM_GATEWAY_API_KEY` is set, `POST /verify/send` with
`channel: "telegram"` fails closed with `503 SERVICE_UNAVAILABLE`.

### Non-delivery / factor channels

| Channel       | Behaviour                                                                                                                                                                                                                                                                                                            |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `silent`      | Network-based silent verification (no code shown to the user)                                                                                                                                                                                                                                                        |
| `sna`         | Silent Network Authentication — possession-proof MNO verification via the CAMARA / GSMA Open Gateway broker. Pass a device-bound `device_token`; returns `status: "verified"` on a confirmed match (no OTP). Without a token it fails closed with `503 SERVICE_UNAVAILABLE` (`details.reason: "PROVIDER_NOT_WIRED"`) |
| `magic_link`  | Email-delivered single-use HTTPS link instead of a typed code (see [Magic Link](#magic-link))                                                                                                                                                                                                                        |
| `totp`        | Authenticator-app (RFC 6238) factor — managed via [`/verify/factors/totp`](#totp-authenticator-app)                                                                                                                                                                                                                  |
| `push`        | Push-factor MFA — managed via [`/verify/push`](#verify-push)                                                                                                                                                                                                                                                         |
| `backup_code` | Single-use recovery codes — managed via [`/verify/factors/backup-codes`](#backup-codes)                                                                                                                                                                                                                              |

<Note>
  Sending `channel: "totp"`, `"push"`, or `"backup_code"` to `POST /verify/send` does **not**
  issue an OTP — these are possession-of-secret factors with their own lifecycle. The send
  endpoint short-circuits (`422` / `400`) and steers you to the matching factor endpoint below.
</Note>

## MFA Factors

Beyond OTP send/check, Verify ships phishing-resistant and possession-of-secret MFA factor
surfaces for end-tenant users. These factors carry **no carrier delivery and no wallet
deduct** — they validate proof-of-possession against a server-side secret or device key pair.
Every factor endpoint requires an authenticated request and is isolated to your organization —
a factor created by one tenant is never visible to another. The scope requirement depends on the
factor type:

* **TOTP** and **backup-code** write operations (create, verify, delete, and recovery-code
  regeneration) require the `verify:write` scope. Dashboard (session) callers must also hold an
  owner, admin, or developer role.
* **TOTP** and **backup-code** list (read) endpoints, along with every **Verify Push** and
  **Passkey** endpoint, are gated by authentication and per-tenant rate limiting only. They do
  **not** require an additional `verify:write` or `verify:read` scope, so any authenticated key
  scoped to the tenant can call them — restrict which keys reach these flows accordingly.

<Note>
  **The factor suite is API-only by design — it has no dashboard screen.** TOTP, backup codes,
  passkeys (WebAuthn / FIDO2), and push are enrolled, verified, and revoked from inside *your own*
  application, where the end user is present to scan a QR code, run a WebAuthn ceremony, or approve
  a push prompt on their device. Drive them straight from the endpoints below or the SDK. (The
  Verify dashboard covers OTP send/check, profiles, and analytics; the **2FA** control under
  Settings → Security protects *your* Orbit login and is separate from these end-user factors.)
</Note>

### TOTP (authenticator app)

RFC 6238 authenticator-app factors (Google Authenticator, Authy, 1Password, etc.). Creating a
factor returns the `otpauth://` URI for QR rendering plus the base32 secret **once** —
subsequent reads return metadata only. Ten single-use recovery codes are minted on creation
and surfaced once; only their SHA-256 hashes are persisted.

| Method   | Path                                                  | Purpose                                                  |
| -------- | ----------------------------------------------------- | -------------------------------------------------------- |
| `GET`    | `/verify/factors/totp`                                | List TOTP factors                                        |
| `POST`   | `/verify/factors/totp`                                | Create a factor (returns `otpauth://` URI + secret once) |
| `POST`   | `/verify/factors/totp/{id}/verify`                    | Validate a 6-digit code (30s step, ±1 step skew)         |
| `DELETE` | `/verify/factors/totp/{id}`                           | Delete a factor                                          |
| `POST`   | `/verify/factors/totp/{id}/recovery-codes/regenerate` | Invalidate and re-mint 10 recovery codes                 |

### Backup codes

Single-use, hashed-at-rest recovery codes for users who have lost their device + SMS access.
Creating a factor mints 10 plaintext codes returned **once**; the server stores SHA-256 hashes
only. Each code can succeed at most once (enforced by an `UPDATE ... WHERE consumed_at IS NULL`
gate), and verify responses return `remaining_count` so your UI can prompt regeneration.

| Method   | Path                                       | Purpose                                      |
| -------- | ------------------------------------------ | -------------------------------------------- |
| `GET`    | `/verify/factors/backup-codes`             | List backup-code factors                     |
| `POST`   | `/verify/factors/backup-codes`             | Mint 10 single-use codes (returned once)     |
| `POST`   | `/verify/factors/backup-codes/{id}/verify` | Consume one code (returns `remaining_count`) |
| `DELETE` | `/verify/factors/backup-codes/{id}`        | Delete a factor (idempotent)                 |

### Verify Push

Phishing-resistant MFA via a device public-key + signed-challenge flow (mirrors Twilio Verify
`factor.type="push"`). Register a device public key, issue a challenge, then verify the signed
nonce returned by the device.

| Method | Path                                           | Purpose                      |
| ------ | ---------------------------------------------- | ---------------------------- |
| `POST` | `/verify/push/factors`                         | Register a device public key |
| `POST` | `/verify/push/factors/{factorId}/challenges`   | Issue a new challenge        |
| `POST` | `/verify/push/challenges/{challengeId}/verify` | Submit a signed nonce        |
| `POST` | `/verify/push/factors/{factorId}/revoke`       | Revoke a paired device       |

### Passkeys (WebAuthn / FIDO2)

Phishing-resistant passkey factors. The registration and authentication ceremonies follow the
WebAuthn spec; attestation/assertion verification (CBOR / COSE / signature) runs server-side.

| Method | Path                                        | Purpose                                  |
| ------ | ------------------------------------------- | ---------------------------------------- |
| `POST` | `/verify/passkey/registration/options`      | Issue a WebAuthn registration ceremony   |
| `POST` | `/verify/passkey/registration/verify`       | Verify attestation, create the factor    |
| `POST` | `/verify/passkey/authentication/options`    | Issue a WebAuthn authentication ceremony |
| `POST` | `/verify/passkey/authentication/verify`     | Verify the assertion, bump the counter   |
| `POST` | `/verify/passkey/factors/{factorId}/revoke` | Revoke a paired passkey                  |

### Magic Link

Email-delivered single-use HTTPS link (Stytch / WorkOS / Auth0 parity) instead of a typed code.
Send with `channel: "magic_link"` and an email `to`; Orbit emails a signed link. The user's
click is consumed by the **public, unauthenticated** endpoint:

| Method | Path                                              | Purpose                                       |
| ------ | ------------------------------------------------- | --------------------------------------------- |
| `POST` | `/verify/send` (`channel: "magic_link"`)          | Email a signed single-use link                |
| `GET`  | `/public/verify/magic-link/consume?token={token}` | Consume the link and approve the verification |

The token is a constant-time HMAC-signed payload; the consume endpoint approves the underlying
verification on success and rejects tampered, expired, or already-used tokens.

## Features

* **Auto-generated codes** — secure random codes (4–8 digits)
* **Multi-channel delivery** — SMS, WhatsApp, Voice, Email
* **Channel fallback** — automatic retry on a different channel if the first fails
* **Rate limiting** — built-in protection against brute-force attacks
* **Expiration** — configurable TTL (default 10 minutes / 600s, max 60 minutes / 3600s)
* **Attempt limits** — configurable max verification attempts per code (1–10, default 3)
* **Locale support** — localized message templates in 25 languages
* **Fraud detection** — optional VoIP/high-risk blocking, configurable per tenant (off by default)

## Configuration Options

These are the body parameters accepted by `POST /api/v1/verify/send` (`sendVerificationSchema`):

| Parameter         | Type      | Default | Description                                                                                                                                          |
| ----------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `to`              | string    | —       | **Required.** Recipient phone number (E.164) or email address.                                                                                       |
| `channel`         | string    | `sms`   | Delivery channel: `sms`, `whatsapp`, `email`, `voice`, `viber`, `telegram`, `rcs`, `flashcall`, `silent`.                                            |
| `code_length`     | integer   | 6       | Number of digits (4–8).                                                                                                                              |
| `max_attempts`    | integer   | 3       | Maximum verification attempts allowed for this code (1–10).                                                                                          |
| `country`         | string    | —       | ISO 3166-1 alpha-2 hint used to parse national-format numbers into E.164 (e.g. `TR`).                                                                |
| `profile_id`      | string    | —       | Verify profile to apply (templates, `expirySeconds`, fallback config, branding).                                                                     |
| `channels`        | string\[] | —       | Ordered fallback chain (min 1, max 4). Index 0 is the primary channel; the rest are tried in order.                                                  |
| `fallback_config` | object    | —       | Async fallback engine config — `{ channel_timeout_seconds: 10–600 (default 60), max_attempts_per_channel: 1–3 (default 1) }`. Pairs with `channels`. |
| `locale`          | string    | —       | 2-letter language code for the localized fallback message/TTS (e.g. `en`, `de`, `es`). Region tags (`es-MX`) are rejected.                           |
| `custom_code`     | string    | —       | **Sandbox/test-mode only** — fixed OTP digits (must equal `code_length`). Rejected with live keys.                                                   |

> **Code expiry is not a `/send` parameter.** Direct sends expire after the default **600s (10 minutes)**. To change the TTL, set `expirySeconds` on a verify profile and pass its `profile_id` — there is no `expiry` field on the send body.

## Webhook Events

| Event                             | Description                                                                          |
| --------------------------------- | ------------------------------------------------------------------------------------ |
| `verification.sent`               | The code was successfully handed to the provider                                     |
| `verification.approved`           | A `/check` call matched the code — fires exactly once per verification (CAS-guarded) |
| `verification.failed`             | `max_attempts` exhausted with no match                                               |
| `verification.checked`            | Any `/check` attempt landed — successful or not (useful for audit / fraud scoring)   |
| `verification.fallback_triggered` | The async fallback engine advanced to the next channel in the chain                  |
| `verification.fallback_exhausted` | Every channel in the fallback chain was tried without an `approved` status           |

> **A SIM-swap pre-flight block does not emit a webhook.** When the pre-flight check rejects a send, `/send` returns a synchronous `403 SIM_SWAP_DETECTED` with `last_swap_date` and `block_window_hours` in the response `details`. No verification row is created and no `verification.failed` event fires, so wire failure/step-up triggers to the `403` response rather than the webhook.
