> ## 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 profiles: fallback chains, code templates, and MFA factors

> Configure a Verify profile with an ordered SMS → WhatsApp → voice fallback chain, per-channel code templates, code length and TTL, and pair it with push, TOTP, passkey, or voice-biometrics factors.

# Verify profiles: fallback chains and advanced factors

A **verify profile** is a reusable, tenant-owned configuration object that controls how OTP verifications behave: which channels the code travels on, how long the code is, how long it stays valid, how many wrong guesses are allowed, and what the message says. You create a profile once, then pass its `profile_id` on every `/verify/send` call that should follow those rules.

This guide walks through the profile lifecycle end to end — create a profile, attach an ordered fallback chain, customize code length, TTL, and templates, send and check a code against it — and then covers the factor suite (push, TOTP, passkey, voice biometrics) that runs alongside OTP profiles.

Everything below is under your control as the tenant. Profiles only apply to API keys and verifications in your own organization; nothing here is mandated by the platform.

## Before you start

* A live API key (`dv_live_sk_…`). Profiles are write-guarded: the key must carry the `verify:write` scope (dashboard callers need an owner, admin, or developer role).
* The channel providers you plan to use must be connected first. A profile that lists `whatsapp` is rejected with `WHATSAPP_NOT_CONNECTED` until a WhatsApp Business Account finishes onboarding; `voice`, `telegram`, `viber`, and `email` each require their own provider configuration. Keep the chain to channels your tenant can actually deliver on — the API rejects a profile that references an unconnected channel with a `422 VALIDATION_ERROR` naming the channel.

## 1. Create a verify profile

`POST /api/v1/verify/profiles` creates the profile. Minimum viable body:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/verify/profiles" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Login OTP",
    "channels": ["sms", "whatsapp", "voice"],
    "codeLength": 6,
    "expirySeconds": 600,
    "maxAttempts": 3,
    "rateLimitPerHour": 5
  }'
```

**Response — 201**

```json theme={null}
{
  "data": {
    "id": "vprof_7a1c0e2b…",
    "name": "Login OTP",
    "channels": ["sms", "whatsapp", "voice"],
    "codeLength": 6,
    "expirySeconds": 600,
    "maxAttempts": 3,
    "rateLimitPerHour": 5,
    "status": "active",
    "createdAt": "2026-08-23T12:00:00.000Z",
    "updatedAt": "2026-08-23T12:00:00.000Z"
  }
}
```

Store the returned `id` (format `vprof_<hex>`) — you pass it as `profile_id` on `/verify/send`.

### Profile fields

| Field              | Type                   | Notes                                                                                                                                                               |
| ------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`             | string                 | Required, 1–100 characters. Display label, shown in the dashboard.                                                                                                  |
| `channels`         | string\[]              | Required, min 1. The **ordered fallback chain** (see §2). Index 0 is the primary channel.                                                                           |
| `codeLength`       | 4 \| 6 \| 8            | Required. Digits in the generated OTP.                                                                                                                              |
| `expirySeconds`    | integer                | Required, 30–3600. Code TTL. `expires_at` on the verification row is derived from this.                                                                             |
| `maxAttempts`      | integer                | Required, 1–10. Wrong codes allowed before the verification hard-fails. Values above 10 are clamped to the platform ceiling of 10.                                  |
| `rateLimitPerHour` | integer                | Required, 1–1000. Per-recipient hourly send cap on this profile.                                                                                                    |
| `templates`        | object                 | Optional per-channel body overrides (`templates.sms`, `templates.voice`, …). See §3.                                                                                |
| `fraud`            | object                 | Optional velocity + geo + risk levers (`velocityPerHour`, `velocityPerDay`, `geoMode`/`geoCountries`, `mccmncBlocklist`, risk thresholds). Off unless you set them. |
| `status`           | `active` \| `inactive` | Optional on create; defaults to `active`. Create as `inactive` to park a profile until you flip it on.                                                              |
| `webhookUrl`       | string                 | Optional HTTPS endpoint that receives this profile's verification events.                                                                                           |

Update any subset of these later with `PATCH /api/v1/verify/profiles/{id}`; delete with `DELETE`. The `/configs` paths are an alias of `/profiles` with identical behavior — use whichever your integration already references.

<Note>
  Profile usage is optional everywhere. A `/verify/send` call with no `profile_id` falls back to platform defaults (6-digit code, 600s TTL, 3 attempts, channel chosen per send). Profiles exist so you can pin behavior once instead of repeating per-send parameters.
</Note>

## 2. Channel fallback chains

The `channels` array **is** the fallback chain. Index 0 is the channel the first send attempt uses; the rest are tried in order when the previous channel cannot deliver.

```json theme={null}
"channels": ["sms", "whatsapp", "voice"]
```

reads as: try SMS first; if SMS rejects or never confirms delivery, move to WhatsApp; if that also fails, place a voice call that reads the code aloud.

### How attempts advance

Fallback advances are **asynchronous**. The initial `/verify/send` call fires only the first channel. A background engine then advances through the remaining channels when either condition trips:

* **Delivery failure** — the provider for the current channel rejected or failed the outbound message.
* **Timeout** — no successful delivery confirmation within `channel_timeout_seconds` (configurable per send via `fallback_config`, 10–600s, default 60s).

The same OTP is re-delivered on each channel — the platform never re-generates the code on a fallback advance, so a recipient who reads the SMS and the follow-up voice call hears and sees one identical code.

Only **delivery channels** make sense in a chain: `sms`, `whatsapp`, `email`, `voice`, `viber`, `telegram`, `rcs`, `flashcall`. Factor-style channels (`totp`, `push`, `passkey` ceremonies, `backup_code`, `sna`, `magic_link`) have their own endpoints (§4) and do not participate in the async chain.

### Voice in the chain requires a voice template

If `voice` appears anywhere in `channels`, the profile must carry a `templates.voice` script containing the `{{code}}` placeholder. The voice OTP is read aloud by a TTS greeting, so the template is the script the recipient hears. Saving a voice-bearing profile without one is rejected:

```bash theme={null}
-d '{
  "name": "Login OTP",
  "channels": ["sms", "voice"],
  "codeLength": 6,
  "expirySeconds": 600,
  "maxAttempts": 3,
  "rateLimitPerHour": 5,
  "templates": {
    "voice": "Your {{app_name}} security code is {{code}}. I repeat, {{code}}."
  }
}'
```

### Watching a chain run

Subscribe to `verification.fallback_triggered` to observe each advance — one event per channel hop, carrying the channel just entered and its chain index. `verification.fallback_exhausted` fires when every channel in the chain failed without an approval; treat it as a hard failure and surface a "verify another way" prompt to the user. Both events, plus the rest of the `verification.*` set, are listed in the [webhook events reference](/reference/webhook-events#verification-events).

## 3. Code length, TTL, and templates

### Code length and TTL

`codeLength` accepts only **4, 6, or 8** digits; `expirySeconds` is **30–3600** seconds. Short codes with short TTLs fit high-traffic login flows where you want the verification row to expire fast; longer codes with longer TTLs fit email-delivered OTPs and lower-frequency flows where a recipient might take minutes to switch apps.

The TTL shows up on the send response as `expires_at`. After that moment, `/verify/check` returns `EXPIRED_TOKEN` (410) and the row can no longer be approved — create a fresh verification rather than re-checking.

### Per-channel templates

`templates` is a map of channel name → message body. Each body may reference these placeholders, resolved at send time:

| Placeholder          | Resolves to                                                               |
| -------------------- | ------------------------------------------------------------------------- |
| `{{code}}`           | The generated OTP digits. Required in a `voice` template.                 |
| `{{expiry_minutes}}` | `expirySeconds` rounded to minutes.                                       |
| `{{expiry}}`         | The expiry value in seconds.                                              |
| `{{app_name}}`       | Your organization's display name.                                         |
| `{{magic_link_url}}` | The signed single-use link — only on the `magic_link` channel's template. |

Any other `{{…}}` token is delivered verbatim (it is not substituted), so a typo in the placeholder name silently reaches the recipient.

```bash theme={null}
-d '{
  "templates": {
    "sms": "{{app_name}}: your code is {{code}}. It expires in {{expiry_minutes}} minutes.",
    "whatsapp": "{{code}} is your {{app_name}} verification code.",
    "email": "Use {{code}} to finish signing in. The code expires in {{expiry_minutes}} minutes.",
    "voice": "Your {{app_name}} verification code is {{code}}. I repeat, {{code}}."
  }
}'
```

## 4. Push factor, TOTP, and passkeys

Beyond OTP delivery, Verify ships factor endpoints for proof-of-possession MFA. These have **no carrier delivery and no wallet charge** — they validate a device-held secret instead of a code in transit. All factor endpoints are per-tenant and API-only by design (enroll, verify, and revoke from inside your own application where the end user is present).

| Factor                      | Base path                             | Lifecycle                                                                                                                        |
| --------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| TOTP (authenticator app)    | `/api/v1/verify/factors/totp`         | Create a factor (get the `otpauth://` URI + base32 secret once), verify 6-digit codes, regenerate the 10 recovery codes, delete. |
| Backup codes                | `/api/v1/verify/factors/backup-codes` | Mint 10 single-use codes (returned once, SHA-256 at rest), verify/consumes one code per call, delete.                            |
| Verify Push                 | `/api/v1/verify/push`                 | Register a device public key, issue a challenge, verify the signed nonce the device returns, revoke a device.                    |
| Passkeys (WebAuthn / FIDO2) | `/api/v1/verify/passkey`              | Issue a registration ceremony, verify the attestation, issue an authentication ceremony, verify the assertion, revoke.           |

TOTP and backup-code **write** operations require the `verify:write` scope. Backup codes are the natural recovery leg for users enrolled in any other factor who lose their device.

The [Verify overview](/verify/overview#mfa-factors) lists every endpoint in each factor suite with its method and path.

## 5. Voice biometrics (optional)

Voice biometrics is an optional, separate surface under `/api/v1/verify/voice-biometrics` that verifies a caller by their voiceprint rather than by a code. It is independent of OTP profiles — you use it alongside them, not inside a fallback chain.

The flow is two calls:

1. **Enroll** — `POST /api/v1/verify/voice-biometrics/enroll` captures a caller's voice samples and builds a voiceprint (id returned; poll `GET /enroll/{id}` for status).
2. **Verify** — on a later call, `POST /api/v1/verify/voice-biometrics/challenge` issues a challenge phrase, then `POST /api/v1/verify/voice-biometrics/verify` scores the fresh sample against the enrolled voiceprint.

Tenant-level behavior (score thresholds, attempt policy) is set on `PATCH /api/v1/verify/voice-biometrics/settings`; `GET /overview` summarizes fleet state. The full endpoint shapes are in the [voice biometrics API reference](/api-reference/endpoints/voice-biometrics).

## 6. Send and check against the profile

With a profile created, pass its id on send:

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

The send inherits the profile's chain, code length, TTL, max attempts, and templates. The `channel` you pass on the send body is tried first; on a delivery rejection the remaining profile chain entries are tried in profile order. Passing a `channel` that is not in the profile's chain tacks it on front of the chain rather than erroring — pass the chain's first entry to keep behavior aligned with the profile as designed.

Check the code exactly as you would without a profile:

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

`status: "approved"` means the profile's rules passed end to end. The full send/check error matrix (`attempts_remaining`, expiry, resend semantics) is covered in [Verify integration without our SDK](/guides/verify-no-sdk).

## Troubleshooting

| Symptom                                                | Cause                                                                                                            | Fix                                                                                                                        |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `422 WHATSAPP_NOT_CONNECTED` on profile create         | The chain lists `whatsapp` but no WABA is connected                                                              | Finish WhatsApp onboarding under Settings → Channels, or drop `whatsapp` from `channels`.                                  |
| `422` naming a `missing` provider on profile create    | A chain channel's provider isn't configured (voice gateway, Telegram Gateway key, Viber credentials, Resend key) | Configure the provider or remove the channel from the chain.                                                               |
| `422` "voice OTP template missing {{code}}"            | `voice` is in `channels` with no `templates.voice`, or the template dropped `{{code}}`                           | Set a voice script containing `{{code}}`.                                                                                  |
| Codes expire before users finish                       | `expirySeconds` too aggressive for the channel (email users switch apps slowly)                                  | Raise `expirySeconds` (up to 3600), or shorten the chain so the usable channel arrives sooner.                             |
| Recipient complains of duplicate codes across channels | Expected behavior on a fallback advance — the same OTP is re-delivered per channel                               | Treat it as a signal the earlier channel is failing; investigate delivery for the primary channel rather than the profile. |

## See also

* [Verify overview](/verify/overview) — channel catalog, send/check reference, MFA factor endpoint list.
* [Verify integration without our SDK](/guides/verify-no-sdk) — the plain-HTTP send/check flow, webhooks, rate limits, and error envelope.
* [Verify API reference](/api-reference/endpoints/verify) — every endpoint including profile CRUD.
* [Voice biometrics API reference](/api-reference/endpoints/voice-biometrics) — enroll / challenge / verify shapes.
