Skip to main content

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.

Languages

The mirror-every-call pattern below — one <Tabs> strip, curl plus one tab per typed SDK — recurs on every worked sample on this page, so the languages stay parallel as the page evolves. The core resources on this page have typed helpers in the SDKs, so each language calls into verify.send / verify.check (Python), Verify().Send / Verify().Check (Go), and verify.send / verify.check (Node) — only reach for the generic request()/Request() escape hatch on a page whose endpoint has no helper yet.
The same shape works for the rest of this page — verify.check folds in wherever a worked call happens. Raw curl in the body of this page works identically. Full SDK index at SDK quickstart.

Send & check

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 Response — a single envelope with batch totals plus one row per recipient (returned in submission order):
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:

Verifications

List verifications

GET /api/v1/verify/?limit=&cursor=&status=&channel= returns verification rows newest-first. limit is capped at 100; cursor resumes the next page. Filter on status (pending / approved / failed / expired — the cancelled outcome is produced only by POST /{id}/cancel and shows up in list/get rows but is not a filter value) and/or channel. Read the status enum from the response rows.
Follow pagination.next_cursor until has_next is false. An empty filter page (e.g. channel=totp, against which send rows never exist) returns data: [] — non-delivery factor lifecycles live in sibling factor tables, not the verifications table.

Get one verification

GET /api/v1/verify/{id} returns the full row — including attempts (checks consumed) and expires_at — scoped to the caller’s organization (a cross-tenant id resolves to 404).

Cancel a pending verification

POST /api/v1/verify/{id}/cancel flips a pending row to cancelled via a compare-and-set, ends any fallback chain, and returns the updated row. A row already in a terminal state (approved / failed / expired) fails the CAS and returns 409 — so the cancel is safe to issue once; don’t retry it after a success in a fallback race.

Aggregates

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 Response 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. Query parameters Response Each destinations[] row carries:

Reading the signal

Here the tenant baseline is a healthy 47%, but +2519 (Ethiopia) completes 1.6% of 620 sends and +447 (UK mobile) completes 3.9% of 1240 — both well inside the critical band (score ≥ 80). +2519 is flagged on a volume many tenants would call small, because the gates ask for 20+ sends, not a large absolute count. +447 dominates the headline: its 1192 wasted sends account for 59.60ofthe59.60 of the 90.10 at risk, so block that prefix first and let +2519 follow. Blocking is a per-organization setting you own — add the prefix to your org’s blocked_prefixes override and subsequent sends to it are rejected; the report stays read-only.

Profiles

Profiles bundle the delivery rules for a send (allowed channels, code length, expiry, rate limit) under a single profileId. Reference a profile on /send with the profile’s id and the caller’s to/channel stay thin.

Create a profile

Update one field (PATCH)

List profiles

Get / delete

Configs (alias of profiles)

/api/v1/verify/configs* is a frontend-facing alias of /profiles* — same controller, identical payloads and behaviour. The dashboard consumes /configs; either path may be used interchangeably from API or SDK callers.

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

TOTP (authenticator app)

Enroll a factor, render the otpauth:// URI as a QR code (or hand the secret to users who can’t scan), then verify its 6-digit codes. The create response also carries 10 single-use recovery codes — minted alongside the factor — so you can remediate a lost device on the spot.

Enroll

The secret and recovery_codes are surfaced once in this create response — subsequent GET /factors/totp returns metadata only, and plaintext codes are unrecoverable later (only their SHA-256 hashes are stored).

Verify a code

A wrong code returns 400 INVALID_CODE. A consumed recovery code returns the same error — no enumeration.

Regenerate recovery codes / delete a factor

Node.js snippet — enroll then verify

Backup codes

Mint 10 single-use recovery codes when a user lost SMS / their authenticator device (the API broadcasts them once — the server stores SHA-256 hashes only and can’t re-read plaintext).
Consume one:
remaining_count lets your app prompt regeneration when fewer than 3 codes remain. A consumed / wrong code returns 400 INVALID_CODE. Delete the factor with DELETE /factors/backup-codes/{id} (204 — idempotent).

Verify Push

Register the device’s public key (ECDSA-P256 or Ed25519). The API returns a binding challenge the device must sign to activate the factor; without that signature the factor stays unverified and accepts no MFA challenges.

1. Register the device public key

2. Issue a challenge

Dispatch the challenge to the device (FCM / APNs / Web Push — a dispatcher on the server picks the transport registered at factor creation). The response also carries signingString — the canonical <factorId>.<challengeId>.<nonce> concatenation — so naive clients don’t recompute it. The device signs that payload and posts back the signature.

3. Verify the signed nonce

Revoke a paired device

Revocation is irreversible — the factor never accepts another challenge. Issued challenges and issued tokens already in flight keep their TTL.

Passkeys (WebAuthn / FIDO2)

Two ceremony pairs: registration (options → verify) and authentication (options → verify), plus revoke. The browser runs navigator.credentials / @simplewebauthn/browser; your app posts the resulting attestation or assertion. For the full browser-ceremony walkthrough — including the origin / rpId configuration — see Verify Passkey endpoint reference.

Registration — options

The response is a PublicKeyCredentialCreationOptionsJSON payload plus the challengeId you’ll echo on verify. Hand it to startRegistration() in the Browser.

Registration — verify

Authentication — options

allowCredentials in the response carries this identity’s registered passkeys with transport hints — hand it to startAuthentication().

Authentication — verify

On success this bumps the factor’s signCount / lastUsedAt. A counter regression (cloned authenticator indicator) auto-revokes the factor. POST /verify/passkey/factors/{factorId}/revoke sets status: "revoked" and is idempotent. Email a signed, single-use link via channel: "magic_link". The public consume endpoint validates the HMAC + expiry, re-checks the recipient binding, and approves the underlying verification row.

Send

Consume (public, unauthenticated)

A malformed / expired / tampered token collapses to 400 INVALID_TOKEN with no reason differentiation (bad_HMAC vs expired look identical to the client), so an attacker can’t distinguish “wrong signature” from “elapsed” via the response.

Example — send and validate an SMS OTP

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: 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 for the full status model.

See also