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
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.
- curl
- Node.js
- Python
- Go
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):
- 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
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).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