Skip to main content

Verify

Orbit Verify is a turnkey OTP (one-time password) verification service. Send verification codes via SMS, WhatsApp, Voice, Email, Viber, Telegram, RCS, or a missed-call Flashcall — Orbit handles code generation, delivery, expiration, brute-force protection, and validation. To understand how a verify works in a real flow, start here; the behavior of endpoints and profiles follows.

Formats and recipient requirements

POST /verify/send requires to and accepts channel, code_length, max_attempts, country, profile_id, channels, fallback_config, locale, custom_code, and device_token. Every recipient shape runs through the same resolver the /check endpoint uses. Recipient shape by channel. The to resolver rewrites national-format numbers to E.164 when a country hint is present; short codes are rejected up-front (they are sender IDs, not destinations).

Limits and validation windows

These bounds stop misconfiguration at send time and they are the same ones the profile service enforces. The /check endpoint mirrors the same limits on attempts and code length.
Why expirySeconds is profile-only. The /verify/send body has no expiry field. Direct sends expire after the default 600s. To change the TTL, create a verify profile with expirySeconds and pass its profile_id on the send; the profile owns the TTL because the fallback engine and per-channel templates also live on the profile. When a profile is passed and the profile expires, the request is rejected — not silently hot-swapped. That is also why the profile update path re-validates expirySeconds instead of computing a synthetic default.

Rate-limit + fraud surface

Verify runs the full send pipeline in service of the /verify/send and /verify/bulk calls. Every recipient flows through the same per-recipient checks.
  • Rate limiting — per-recipient hourly cap (default 5/hour; tuned via a verify profile rateLimitPerHour, org-level verify_recipient_hourly_cap, or platform default). 429 VERIFY_RECIPIENT_RATE_LIMITED receipts are per-recipient, not per-batch.
  • Velocity limits — the verify profile fraud.velocityPerHour / fraud.velocityPerDay (values 1–100 and 1–1000) and the composite-risk verifier (lib/verify-fraud-guard.ts) that scores SIM-swap, port-event, and CAMARA possession.
  • Resend cooldownVERIFY_RESEND_COOLDOWN runs as a SET NX guard (30s per recipient) before the hourly INCR, so a resend burst does not consume an hourly slot.
  • VoIP line-type guard — a tenant opt-in verify_line_type_policy allows voip_sms / voip_voice to be off (default), warn, or block, so VoIP line-types are gated on SMS and voice only by tenant choice. The default is off.

Verify-specific fraud pre-flight

Before rate-limit/increment or billing deduct, /verify/send runs the fraud gate. SIM-swap pre-flight returns a synchronous 403 SIM_SWAP_DETECTED with last_swap_date and block_window_hours in details. No verification row is created and no verification.failed webhook fires — wire step-up (login attempts) to the 403 response, not to the webhook. The endpoint rate-limits are separate (verify-send = 20/min, verify-bulk = 2/min, verify-check = 60/min, verify-fraud-score = 20/min, verify-fraud-gate = 20/min).

Fraud Score dip — POST /verify/fraud-score

Score one E.164 number’s SIM-swap and line-type fraud risk without spending an OTP. The body takes a single phone_number; a mis-formatted (non-E.164) number fails fast with a 422 before a lookup dip is burned.
A 2xx returns the composite under data (the meta block is trimmed here):
risk_score runs 0–100 (higher is riskier). risk_level bands it: low under 25, medium 25–49, high 50+. recommendation maps 1:1 from the band — allow, step_up, or block — and is advisory only: your send flow decides. reasons carries the machine-readable codes (SIM-swap recency, VOIP line-type, roaming) that drove the score.
Fail-open on an unavailable lookup. When the number-intelligence lookup cannot run (invalid number, gateway timeout, no operator broker for the region) the response still returns 2xx with lookup_available: false, risk_score: 0, and recommendation: "allow". Treat that as no signal, not a confirmed-clean verdict — the flag is how you tell the two apart.

Fraud Gate composite — POST /verify/fraud-gate

Fuse the operator-asserted identity signals into one composite verdict you can gate a send (or any transaction) against. The body takes phone_number plus optional access_token (a device-bound 3-legged Silent Auth token — without it the possession signal is skipped), sim_swap_max_age (hours, 1–2400, default 240), number_recycling_max_age (days, 1–2555, default 30), and per-request weights / thresholds overrides that tune the same fusion the risk-score kernel uses.
decision is the composite verdict — allow, review, or deny — computed against your thresholds. phone_number is echoed masked. signals_unavailable lists the fusion keys the operator could not answer this dip; without an operator broker configured (or a missing access_token for possession) those degrade fail-closed into the unavailable list rather than reading as a clean pass.

Node.js SDK escape hatch

The typed SDK has no per-dip helper for these two endpoints — call them through the generic orbit.request() escape hatch (it attaches the API key, retries 429/5xx, and sends an idempotency key on every POST):

Error and rate-limit behaviour

Both dips are capped at 20 per minute per tenant each. A 429 is per-endpoint, not shared across the two.

Webhook semantics

Subsequent /check attempts land on verification.checked (any attempt, success or not) so anti-fraud scoring can be driven from the stream. The one-time terminal approvals are the CAS-guarded operations:
  • verification.approved — a /check matched the code; fires exactly once per verification via the compare-and-set (CAS) transition gate
  • verification.failedmax_attempts were exhausted or the verification moved to a terminal unsuccessful state
  • verification.sent — the code was handed to the provider successfully
Factor channels emit their own events (e.g. totp.create, backup_code.verify, push.factor.verify, passkey.challenge.verify) — see the API reference for the secure factor-event catalog.

Channel capability at a glance

The first group delivers an OTP over a carrier; the second group are non-delivery / factor channels that prove possession without carrying an OTP.

Delivery channels

Factor channels

Sending channel: "totp", "push", or "backup_code" to POST /verify/send does not issue an OTP — the send endpoint short-circuits with a typed 422 / 400 to the matching factor endpoint below. Those channels are possession-of-secret factors with their own lifecycle.

Send a Verification Code

Response

Fallback chain + fallback config

channels[] index 0 is the primary; the rest are tried in order when the first times out or fails. fallback_config activates the async fallback engine (scheduler-verify-fallback). With no fallback_config, the chain runs synchronously inside the same /verify request.

Bulk send body

POST /verify/bulk fans out up-to-1000 recipients per call. The endpoint rate-limits bulk to 2/min (vs 20/min for /verify/send) because one call can mint up to 1000 OTPs — operators can bump via per-org override.
Bulk response envelope. The HTTP status reflects the batch outcome: 201 when every recipient sent, 207 (Multi-Status) on partial success, and 400 when every recipient failed for a caller reason, or 503 when every recipient failed because of a genuine upstream delivery outage (retryable, matching verify/send’s per-recipient contract).

Check a Verification Code

Response

Every /check call consumes one attempt. max_attempts exhausted (default 3) terminates the verification; verification.approved only lands on a successful match.

MFA Factors

Beyond OTP send/check, Verify ships phishing-resistant and possession-of-secret MFA factor surfaces for end-tenant users. These factors carry no carrier delivery and no wallet deduct — they validate proof-of-possession against a server-side secret or device key pair. Every factor endpoint requires an authenticated request and is isolated to your organization — a factor created by one tenant is never visible to another. The scope requirement depends on the factor type:
  • TOTP and backup-code write operations (create, verify, delete, and recovery-code regeneration) require the verify:write scope. Dashboard (session) callers must also hold an owner, admin, or developer role.
  • TOTP and backup-code list (read) endpoints, along with every Verify Push and Passkey endpoint, are gated by authentication and per-tenant rate limiting only. They do not require an additional verify:write or verify:read scope, so any authenticated key scoped to the tenant can call them — restrict which keys reach these flows accordingly.
The factor suite is API-only by design — it has no dashboard screen. TOTP, backup codes, passkeys (WebAuthn / FIDO2), and push are enrolled, verified, and revoked from inside your own application, where the end user is present to scan a QR code, run 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)

RFC 6238 authenticator-app factors (Google Authenticator, Authy, 1Password, etc.). Creating a factor returns the otpauth:// URI for QR rendering plus the base32 secret once — subsequent reads return metadata only. Ten single-use recovery codes are minted on creation and surfaced once; only their SHA-256 hashes are persisted.

Backup codes

Single-use, hashed-at-rest recovery codes for users who have lost their device + SMS access. Creating a factor mints 10 plaintext codes returned once; the server stores SHA-256 hashes only. Each code can succeed at most once (enforced by an UPDATE ... WHERE consumed_at IS NULL gate), and verify responses return remaining_count so your UI can prompt regeneration.

Verify Push

Phishing-resistant MFA via a device public-key + signed-challenge flow (mirrors Twilio Verify factor.type="push"). Register a device public key, issue a challenge, then verify the signed nonce returned by the device.

Passkeys (WebAuthn / FIDO2)

Phishing-resistant passkey factors. The registration and authentication ceremonies follow the WebAuthn spec; attestation/assertion verification (CBOR / COSE / signature) runs server-side. Email-delivered single-use HTTPS link (Stytch / WorkOS / Auth0 parity) instead of a typed code. Send with channel: "magic_link" and an email to; Orbit emails a signed link. The user’s click is consumed by the public, unauthenticated endpoint: The token is a constant-time HMAC-signed payload; the consume endpoint approves the underlying verification on success and rejects tampered, expired, or already-used tokens.

Features

  • Auto-generated codes — secure random codes (4–8 digits)
  • Multi-channel delivery — SMS, WhatsApp, Voice, Email, Viber, Telegram, RCS, Flashcall, plus silent and sna for device-fraud-sterile checks
  • Channel fallback — automatic retry on a different channel if the first times out or fails
  • Rate limiting — built-in protection against brute-force attacks
  • Expiration — configurable TTL via profiles (default 10 minutes / 600s, max 3600s)
  • Attempt limits — configurable max verification attempts per code (1–10, default 3)
  • Locale support — localized message templates in 25 languages
  • Fraud detection — SIM-swap pre-flight, VoIP-type gating (tenant opt-in), velocity thresholds, composite risk, and offline fraud-score scoring

Configuration options (send body)

These are the body parameters accepted by POST /api/v1/verify/send (sendVerificationSchema). The profile object mirrors these fields to make them tenant-tunable (codeLength, expirySeconds, rateLimitPerHour, templates, fallbackConfig).
Code expiry is not a /send parameter. Direct sends expire after the default 600s (10 minutes). To change the TTL, set expirySeconds on a verify profile and pass its profile_id — there is no expiry field on the send body.

Webhook Events

A SIM-swap pre-flight block does not emit a webhook. When the pre-flight check rejects a send, /send returns a synchronous 403 SIM_SWAP_DETECTED with last_swap_date and block_window_hours in the response details. No verification row is created and no verification.failed event fires, so wire failure/step-up triggers to the 403 response rather than the webhook.

Next steps