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

This page carries more than 15 operations, so per the SDK language policy the full six-language group shows here for the operation you reach first — send + check — and every operation after that renders the cURL + Node.js (TypeScript) pair inside a <CodeGroup>, the floor the policy promises on every request sample. The pair recurs verbatim on every worked sample on this page — there is no bash-only fallback anywhere below. The core resources have typed SDK helpers (shown in the hero group here), so each language calls into verify.send / verify.check (Python, Node, Ruby, PHP) and Verify().Send / Verify().Check (Go); every later Node.js tab rides the untyped orbit.request() escape hatch with the same auth/retries and envelope, which is why it pairs with the cURL form instead of the wrap-everything <Tabs> strip.
Every worked sample below this hero group pairs its cURL form with the Node.js orbit.request() escape hatch in one <CodeGroup> — the two most-used forms, side by side per operation. verify.check folds in wherever a worked call happens. Full SDK index at SDK quickstart.

Usage patterns — full six-language groups

The floor rule above (cURL + Node.js per operation) is never less than two languages. The headline operations every Verify integration must wire — Send, Check, Cancel, Get, List, and the aggregates — additionally each render the full six-language group below, one sample per operation. Trim as needed; nothing here is merely additive.

Send — full six-language group

The response is { "data": { "verification_id": "vrf_…", "channel": "sms" } } — see response shapes below for retry_count and error variants.

Check — full six-language group

Cancel — full six-language group

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.

Get — full six-language group

List — full six-language group

Aggregates — full six-language group

Swap analytics for conversion-anomaly in the path to reach the fraud report — the envelopes differ, the request shape is identical.

Response shapes

Every terminal send / check / cancel / get / list row carries the same vrf_… envelope; the shape below lists the fields a reader can rely on.

Success envelope — get / list rows

The send response carries the same envelope shape with the fallback chain embedded when one ran:
The send result carries fallback_attempted / fallback_attempts only when a fallback chain ran (a chain over more than one channel is the gate); a single-channel send is wire-shape-identical to a chain-less send. Each fallback_attempts entry is { channel, index, ok, errCode?, errMessage?, reason? } — ok: false rows also carry reason (provider_rejected, fraud_blocked, transient_skip, internal_fault, …) and errCode naming the channel’s refusal. retry_count for a fully-worked chain is fallback_attempts.length — each entry burned one fallback hop before the row settled on the channel that delivers.

Error variants

Failures do not return 200 bodies — they surface in the error envelope, where error.code is the stable machine-readable discriminator: Treat EXPIRED and EXPIRED_TOKEN as interchangeable. The check and magic-link consume paths emit 410 EXPIRED_TOKEN today and the public-consume path emits 410 EXPIRED — a caller handling one 410 discriminator must handle the other, so accept both codes and the same remediation (start a new verification).

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

The enroll → verify sequence above is the whole flow — the paired Node.js tab in each <CodeGroup> rides the same orbit.request() escape hatch, so the sequence composes the way the cURL forms do.

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