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

> OTP send + check across SMS, voice, email, and WhatsApp. Multi-channel verify with profile-based defaults.

# Verify API

One-time-password (OTP) issuance and validation across SMS, voice (TTS), email, and WhatsApp. Two patterns:

* **Direct** — issue an OTP to a phone / email; validate the code the user enters.
* **Profile-based** — define a verify profile (channel, code length, expiration, locale, sender), then reference it by name from your app. Lets you tune behavior without redeploying.

**Base path:** `/api/v1/verify`

**Authentication:** API key (`X-API-Key`) or session JWT.

## Send & check

| Method | Path                   | Purpose                                                                 |
| ------ | ---------------------- | ----------------------------------------------------------------------- |
| `POST` | `/api/v1/verify/send`  | Issue an OTP                                                            |
| `POST` | `/api/v1/verify/bulk`  | Issue OTPs to up to 1000 recipients in one call ([details](#bulk-send)) |
| `POST` | `/api/v1/verify/check` | Validate an OTP code                                                    |

## Bulk send

`POST /api/v1/verify/bulk` fans an OTP out to **up to 1000 recipients** that share one
`channel` and (optionally) one verify `profile_id` / `template_variables` bag — built for
login surges and password-reset campaigns. Each recipient flows through the **same
per-recipient pipeline** as `POST /verify/send` (E.164 / email validation, per-recipient
rate limit, fraud velocity, wallet deduct, MessageRouter dispatch), so no new outbound path
is introduced. Recipients are de-duplicated (first-occurrence order preserved) and dispatched
with a bounded-parallel worker pool; one recipient's failure never aborts the batch.

This endpoint is rate-limited to **2 requests / minute** per tenant (each call can mint up to
1000 OTPs) — operators can raise it with a per-org override.

**Request body**

| Field                | Type       | Default | Notes                                                                                                    |
| -------------------- | ---------- | ------- | -------------------------------------------------------------------------------------------------------- |
| `recipients`         | `string[]` | —       | **Required.** 1–1000 destinations (E.164 phone or email, per `channel`).                                 |
| `channel`            | string     | `sms`   | Shared channel: `sms`, `whatsapp`, `email`, `voice`, `viber`, `telegram`, `silent`, `sna`, `magic_link`. |
| `country`            | string     | —       | ISO 3166-1 alpha-2 hint applied to every recipient (national → E.164).                                   |
| `code_length`        | integer    | 6       | Digits per code (4–8).                                                                                   |
| `max_attempts`       | integer    | 3       | Max check attempts per code (1–10).                                                                      |
| `profile_id`         | string     | —       | Verify profile applied uniformly to the batch.                                                           |
| `locale`             | string     | —       | Shared locale for templated/TTS channels.                                                                |
| `template_variables` | object     | —       | Shared template-variable bag (≤32 keys) for templated channels.                                          |

**Response** — a single envelope with batch totals plus one row per recipient (returned in
submission order):

```json theme={null}
{
  "data": {
    "total": 3,
    "sent": 2,
    "failed": 1,
    "results": [
      { "recipient": "+14155552671", "status": "sent", "verification_id": "vrf_…", "channel": "sms" },
      { "recipient": "+14155550000", "status": "sent", "verification_id": "vrf_…", "channel": "sms" },
      { "recipient": "not-a-number", "status": "failed", "error": { "code": "INVALID_RECIPIENT", "message": "…" } }
    ]
  }
}
```

Each result row is one of:

* **sent** — `{ recipient, status: "sent", verification_id, channel }`
* **failed** — `{ recipient, status: "failed", error: { code, message } }`

**Status codes** — the HTTP status reflects the *batch* outcome so SDKs can branch on
`response.ok`:

| HTTP  | Meaning                                                                     |
| ----- | --------------------------------------------------------------------------- |
| `201` | Every recipient was sent (`failed === 0`).                                  |
| `207` | Partial success — at least one sent and at least one failed (Multi-Status). |
| `400` | Every recipient failed (`sent === 0`); inspect each row's `error`.          |

## Verifications

| Method | Path                                | Purpose                                                           |
| ------ | ----------------------------------- | ----------------------------------------------------------------- |
| `GET`  | `/api/v1/verify/`                   | List verifications                                                |
| `GET`  | `/api/v1/verify/analytics`          | Aggregate verification totals + channel breakdown                 |
| `GET`  | `/api/v1/verify/conversion-anomaly` | Per-destination OTP conversion-anomaly (AIT / SMS-pumping) report |
| `GET`  | `/api/v1/verify/{id}`               | Get a verification                                                |
| `POST` | `/api/v1/verify/{id}/cancel`        | Cancel an in-flight verification                                  |

`GET /api/v1/verify/analytics` aggregates verifications over a rolling look-back window.
Pass an optional `window_days` query parameter to size the window; the applied value is
echoed back in the response.

**Query parameters**

| Field         | Type    | Default | Notes                                                  |
| ------------- | ------- | ------- | ------------------------------------------------------ |
| `window_days` | integer | `30`    | Rolling look-back window in days. Clamped to `1`–`90`. |

**Response**

| Field                | Type           | Notes                                                                                                           |
| -------------------- | -------------- | --------------------------------------------------------------------------------------------------------------- |
| `total`              | integer        | Verification count within the window.                                                                           |
| `by_status`          | object         | Per-status breakdown, `{ status: count }`. Codes that lapsed before a check are reported as `expired`.          |
| `by_channel`         | object         | Per-channel breakdown, `{ channel: count }`.                                                                    |
| `avg_verify_seconds` | number \| null | Mean time from send to approval, in seconds, over verified codes in the window. `null` when none were verified. |
| `window_days`        | integer        | The look-back window actually applied (after clamping).                                                         |

`GET /api/v1/verify/conversion-anomaly` is the Verify Fraud Shield discovery signal for
artificially-inflated traffic (AIT / SMS-pumping). It scores each destination prefix by its
OTP **completion** rate (verified ÷ sent) over an optional `window_days` look-back (1–90,
default 30): destinations converting far below the tenant baseline against a material send
volume are flagged, and the response headlines an estimated carrier **spend-at-risk** (USD).
The payload is `{ window_days, total_sent, total_verified, baseline_conversion_rate,
flagged_destinations, estimated_spend_at_risk_usd, destinations[] }`, where each destination
carries `{ prefix, sent, verified, conversion_rate, score, band, anomaly,
estimated_spend_at_risk_usd }`. Read-only and tenant-scoped.

## Profiles

| Method   | Path                           | Purpose          |
| -------- | ------------------------------ | ---------------- |
| `GET`    | `/api/v1/verify/profiles`      | List profiles    |
| `POST`   | `/api/v1/verify/profiles`      | Create a profile |
| `GET`    | `/api/v1/verify/profiles/{id}` | Get a profile    |
| `PATCH`  | `/api/v1/verify/profiles/{id}` | Update           |
| `DELETE` | `/api/v1/verify/profiles/{id}` | Delete           |

### Configs (alias of profiles)

`/api/v1/verify/configs*` is a frontend-facing alias of `/profiles*` — same controller,
identical payloads and behaviour. It exists because the dashboard consumes the `/configs`
naming; either path may be used interchangeably.

| Method   | Path                          | Purpose         |
| -------- | ----------------------------- | --------------- |
| `GET`    | `/api/v1/verify/configs`      | List configs    |
| `POST`   | `/api/v1/verify/configs`      | Create a config |
| `GET`    | `/api/v1/verify/configs/{id}` | Get a config    |
| `PATCH`  | `/api/v1/verify/configs/{id}` | Update          |
| `DELETE` | `/api/v1/verify/configs/{id}` | Delete          |

## Channels

`POST /api/v1/verify/send` accepts `channel`: `sms`, `whatsapp`, `voice`, `email`, `viber`,
`telegram`, `rcs`, `flashcall` (delivery channels) plus the non-delivery / factor channels
`silent`, `sna`, `totp`, `push`, `magic_link`, `backup_code`. `rcs` delivers the code through
your tenant's verified RCS Business Messaging agent (branded logo + checkmark, higher trust
than plain SMS), failing closed with `503 PROVIDER_NOT_WIRED` when no RCS provider is
registered — so it slots cleanly into a fallback chain such as `rcs → sms`. `flashcall`
delivers the code as the trailing digits of a missed call's caller ID; the recipient submits
those digits to `/check`. `sna` (Silent Network
Authentication) verifies SIM possession through the CAMARA broker when you pass a device-bound
`device_token`, returning `status: "verified"` with no OTP; without one it fails closed with
`503 PROVIDER_NOT_WIRED`. `totp`/`push`/`backup_code` short-circuit and steer you to
the matching factor endpoints below. See the [Verify overview](/verify/overview) for the full
channel reference.

## MFA factors

Possession-of-secret and phishing-resistant factors for end-tenant users. These factors carry
no carrier delivery and no wallet deduct. Create/verify/delete require the `verify:write` scope.

<Note>
  **These factors are API-only by design — there is no dashboard screen for them.** TOTP, backup
  codes, passkeys (WebAuthn / FIDO2), and push are enrolled, verified, and revoked from inside
  *your own* application, where your end user is present to scan a QR code, complete 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)

| Method   | Path                                                         | Purpose                                                  |
| -------- | ------------------------------------------------------------ | -------------------------------------------------------- |
| `GET`    | `/api/v1/verify/factors/totp`                                | List TOTP factors                                        |
| `POST`   | `/api/v1/verify/factors/totp`                                | Create a factor (returns `otpauth://` URI + secret once) |
| `POST`   | `/api/v1/verify/factors/totp/{id}/verify`                    | Validate a 6-digit code                                  |
| `DELETE` | `/api/v1/verify/factors/totp/{id}`                           | Delete a factor                                          |
| `POST`   | `/api/v1/verify/factors/totp/{id}/recovery-codes/regenerate` | Re-mint 10 recovery codes                                |

### Backup codes

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

### Verify Push

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

### Passkeys (WebAuthn / FIDO2)

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

### Magic link

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

## Example — send and validate an SMS OTP

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

# Response: { data: { verification_id: "vrf_3f1c0b2a8e4d4f7a9c2b1e6d5a4c3b2a" } }

# 2. Check
curl -X POST https://api.orbit.devotel.io/api/v1/verify/check \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "verification_id": "vrf_3f1c0b2a8e4d4f7a9c2b1e6d5a4c3b2a", "code": "482517" }'
```

On a matching code the check endpoint returns `200` with `{ data: { verification_id: "vrf_3f1c0b2a8e4d4f7a9c2b1e6d5a4c3b2a", status: "approved" } }`. Failures are **not** `200` bodies — they surface in the error envelope, where `details.attempts_remaining` counts the tries left:

| Outcome                        | HTTP  | Error code         | Notes                                                    |
| ------------------------------ | ----- | ------------------ | -------------------------------------------------------- |
| Wrong code, attempts remaining | `422` | `VALIDATION_ERROR` | `details.attempts_remaining` counts the tries left       |
| Max attempts exhausted         | `422` | `VALIDATION_ERROR` | message `Maximum attempts exceeded`; the row is terminal |
| Code expired                   | `410` | `EXPIRED_TOKEN`    | request landed after `expires_at`; the row is terminal   |

Codes have a small fixed number of attempts (default 3) before they're exhausted. The check status enum is `["approved", "failed", "expired"]` — `failed` and `expired` only ever appear inside the error envelope (or via `GET /api/v1/verify/{id}`), never as a `200` body. See [Verify without an SDK](/guides/verify-no-sdk) for the full status model.

## See also

* [Channels](/channels/sms) — pick the right channel for your audience
* [Webhooks → verify events](/webhooks/events)
