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

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