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.
/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.
- curl
- Node.js
- Python
- Go
- Ruby
- PHP
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
{ "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
analytics for conversion-anomaly in the path to reach the fraud
report — the envelopes differ, the request shape is identical.
Response shapes
Every terminalsend / 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:
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 return200 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):
- sent —
{ recipient, status: "sent", verification_id, channel } - failed —
{ recipient, status: "failed", error: { code, message } }
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.
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
+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 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 singleprofileId. 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 theverify: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 theotpauth:// 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
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
400 INVALID_CODE. A consumed recovery code returns the
same error — no enumeration.
Regenerate recovery codes / delete a factor
<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).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
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
Passkeys (WebAuthn / FIDO2)
Two ceremony pairs: registration (options → verify) and authentication (options → verify), plus revoke. The browser runsnavigator.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
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
signCount / lastUsedAt. A counter
regression (cloned authenticator indicator) auto-revokes the factor.
POST /verify/passkey/factors/{factorId}/revoke sets status: "revoked"
and is idempotent.
Magic link
Email a signed, single-use link viachannel: "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)
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
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
- Channels — pick the right channel for your audience
- Webhooks → verify events