/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
- Your backend POSTs
/api/v1/verify/sendwith{ to, channel }→ capturesverification_idfrom the response. - Your frontend collects the code from the user via your own form. (We never need your UI —
verification_idis opaque to the user.) - Your backend POSTs
/api/v1/verify/checkwith{ verification_id, code }→ receivesstatus: "approved" | "failed" | "expired". - Optionally subscribe to
verification.*webhook events so you can react asynchronously instead of polling.
Authentication
Every request carries anX-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 body
Response — 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
Response — 200 OK (status reflects the row’s terminal state when
applicable)Status valuesSurface
/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.Retry 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: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’sexpires_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
/sendcalls / minute / key, 60/checkcalls / minute / key. 429 withRATE_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
/checkcodes / recipient / hour locks out further attempts even if the attacker rotatesverification_id. 429 withRATE_LIMIT_EXCEEDEDonce tripped. The lockout exists specifically to defeat the “spam/sendto get fresh ids, then guess the 4–6 digit code” attack.
velocityPerHour, velocityPerDay) layer
on top and override the org-wide hourly when set.
Fallback chain
If the profile haschannels: ["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: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-Keyserver-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
codebody field. Logverification_idinstead. The platform itself stores only the SHA-256 hash and discards the raw code immediately after dispatch. - Verify the
X-Orbit-Signatureheader (or the back-compatX-Devotel-Signature) on every webhook delivery. Parse it ast=<unix>,v1=<hex>and reject any body whose recomputed HMAC-SHA256 of<t>.<raw_body>does not match av1candidate. - Treat
verification_idas 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 theverification.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.approvedand let the platform push the state change to you. - A separate test environment —
dv_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
- Browse the full endpoint reference at API reference → Verify.
- Configure a profile (templates, channel chain, fraud caps) in Verify → Configuration before going live.
- Wire a webhook endpoint via Webhooks → Events.
- Read the Webhook events reference
for the full
verification.*payload schemas.