Skip to main content
You do not need our SDK to ship OTP verification. The full flow is two HTTP requests from your backend (one to send the code, one to check it) plus an optional webhook subscription if you’d rather react to events than parse /check responses inline. This guide walks the end-to-end developer journey using curl so you can mirror it in any language that speaks HTTPS.

End-to-end flow

  1. Your backend POSTs /api/v1/verify/send with { to, channel } → captures verification_id from the response.
  2. Your frontend collects the code from the user via your own form. (We never need your UI — verification_id is opaque to the user.)
  3. Your backend POSTs /api/v1/verify/check with { verification_id, code } → receives status: "approved" | "failed" | "expired".
  4. Optionally subscribe to verification.* webhook events so you can react asynchronously instead of polling.

Authentication

Every request carries an X-API-Key header. Live keys are prefixed dv_live_sk_, sandbox keys dv_test_sk_. Keep them server-side only — never embed in a mobile app or browser bundle.
1

1. Send the code

Generate and dispatch a one-time code to the recipient. The response is returned in <200ms p95 under normal load; the provider dispatch is fire-and-forget after the row is committed.
Request bodyResponse — 201 Created
verification_id is the canonical handle for the rest of the flow. The exact prefix is vrf_ followed by 32 lowercase hex characters (per generateId('verification') in packages/shared/src/utils/id.ts). Treat it as opaque — never parse the suffix.
2

2. Map verification_id to your user

Stash the verification_id against your own user/session so step 3 can look it up. The platform itself does not know about your users — every call is tenant-scoped via your API key, and the verification row lives inside your tenant schema regardless of which of your end-users it belongs to.
expires_at is the row TTL — 10 minutes by default, or whatever the profile’s expirySeconds is set to. After expiry the /check call returns EXPIRED_TOKEN (HTTP 410) and the row can no longer be approved; create a fresh verification.
3

3. Validate the code

When the user submits the digits, post them straight through to /verify/check. Do not compare codes server-side yourself — the platform stores only the hash, never the raw code, and a constant-time comparison runs inside the platform to defeat timing side-channels.
Response — 200 OK (status reflects the row’s terminal state when applicable)
Status valuesRetry behaviour on a wrong code (before max_attempts)A wrong code that does not yet exhaust max_attempts returns VALIDATION_ERROR (HTTP 422) with attempts_remaining in the details block:
Surface attempts_remaining to the user in your own UI so they see how many tries are left before the row hard-fails.Resending a codeIf the user didn’t receive the SMS / email, call POST /api/v1/verify/:id/resend (NOT /send again — a second /send creates a brand-new row and orphans the first). The resend reuses the same verification_id but generates a fresh code, bumps the resends_count ledger, and re-bills the per-channel rate. The response carries the new expires_at. Per-recipient resend caps configured on the profile still apply.
4

4. (Optional) Subscribe to webhook events

If you’d rather not block your check endpoint on the synchronous /check response — for example, you’d like to react to the verification.approved event from a queue worker that handles post-verification side-effects — subscribe to webhook events instead of (or in addition to) reading the /check response.Configure a webhook endpoint in Settings → Webhooks and pick the verification.* events. The platform fires:Full payload shapes live in Webhook events reference.The canonical webhook signature header (X-Orbit-Signature, with the back-compat X-Devotel-Signature carrying the same value) is Stripe-style: t=<unix>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw_body> keyed by your endpoint’s signing secret. Verify it server-side before trusting any payload — see Webhook verification for the full parse-and-compare recipe.

Common patterns

Session expiry

The row’s expires_at is the TTL of the verification, not the TTL of any session you might mint after approval. Default is 10 minutes; override per profile via expirySeconds. Once expires_at is past, /check returns EXPIRED_TOKEN (410). Use the response field to drive a countdown timer or auto-resend prompt in your UI.

Rate limits

Three layers stack:
  • API-key rate limit — 20 /send calls / minute / key, 60 /check calls / minute / key. 429 with RATE_LIMIT_EXCEEDED.
  • Org-wide per-recipient rate limit — defaults to 5 sends / hour / recipient (tenant-configurable). 429 with RATE_LIMIT_EXCEEDED.
  • Per-recipient brute-force lockout — 30 wrong /check codes / recipient / hour locks out further attempts even if the attacker rotates verification_id. 429 with RATE_LIMIT_EXCEEDED once tripped. The lockout exists specifically to defeat the “spam /send to get fresh ids, then guess the 4–6 digit code” attack.
Profile-level velocity caps (velocityPerHour, velocityPerDay) layer on top and override the org-wide hourly when set.

Fallback chain

If the profile has channels: ["sms", "whatsapp", "voice"], the async fallback engine retries through the chain on provider rejection or timeout. You’ll see one verification.fallback_triggered event per channel advance; the recipient receives the same OTP across channels (we re-deliver, never re-generate, the code on fallback re-sends). Watch for verification.fallback_exhausted to know the chain failed in full.

Error envelope

All non-2xx responses share the platform envelope:
The codes you will see on the verify path:

Latency budget

P95 send / check latency is <200ms server-side under normal load. Add your network round-trip on top. Provider dispatch is fire-and-forget after the row commit — the response returns as soon as the row is durable, not after the provider acknowledges the message.

Security checklist

  • Send every request over HTTPS. The platform rejects plain HTTP.
  • Keep X-API-Key server-side. Never expose it to the browser or a mobile binary. Use API-key IP allowlisting to constrain where it can be used from.
  • Never log the raw code body field. Log verification_id instead. The platform itself stores only the SHA-256 hash and discards the raw code immediately after dispatch.
  • Verify the X-Orbit-Signature header (or the back-compat X-Devotel-Signature) on every webhook delivery. Parse it as t=<unix>,v1=<hex> and reject any body whose recomputed HMAC-SHA256 of <t>.<raw_body> does not match a v1 candidate.
  • Treat verification_id as opaque. Do not parse the suffix to derive metadata — the entropy block has no semantic meaning and the prefix may change in future versions.

Observability — “did the user verify?”

The canonical signal is the verification.approved webhook event. If you build only on the synchronous /check 200 response, a network blip between Orbit and your check endpoint will leave you uncertain whether the row was approved. The webhook is delivered at-least-once with retry + DLQ, so subscribing makes the side-effect (mint session, unlock account, write audit row) the responsibility of an idempotent queue worker rather than your synchronous HTTP handler. GET /api/v1/verify/:id is the polling fallback if you cannot accept inbound webhooks today — it returns the current row state including status and attempts. Cap polling to once every few seconds; the row is terminal within at most expires_at.

What you do NOT need

  • Our SDK — every endpoint described here works with any HTTP client. The Node SDK (@devotel/orbit-sdk) is a typed wrapper that calls these same routes; using it is optional.
  • WebSockets or SSE — verification is request/response. Webhooks cover the async side.
  • Background polling — subscribe to verification.approved and let the platform push the state change to you.
  • A separate test environmentdv_test_sk_ keys hit the same endpoints and emit the same webhook events; only the underlying wallet ledger and provider dispatch are sandboxed.

Next steps