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 parseDocumentation Index
Fetch the complete documentation index at: https://orbit-docs.devotel.io/llms.txt
Use this file to discover all available pages before exploring further.
/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. 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
| Field | Type | Required | Notes |
|---|---|---|---|
to | string | yes | E.164 phone (+14155552671) or RFC 5322 email. Channel must match recipient shape. |
channel | string | yes | One of sms, whatsapp, email, voice, viber, telegram. |
max_attempts | integer | no | 1–10, default 3. Max wrong codes before the row hard-fails. |
profile_id | string | no | A verification profile id (format vprof_<hex>). Profiles override defaults for code length, expiry, channel chain, fraud caps, templates. |
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. 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. 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 values
Retry behaviour on a wrong code (before Surface
/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.| Status | Meaning |
|---|---|
approved | Code matched. Row is terminal — further /check calls return Verification already processed (409). Treat your user as verified, then discard the verification_id. |
failed | Returned inside the error envelope (HTTP 422) when the supplied code is wrong and max_attempts has been reached. The row is terminal. |
expired | Returned inside the error envelope (HTTP 410) when the call lands after expires_at. The row is terminal. |
pending | Not returned from /check — only /verify/:id GET shows this. It’s the in-flight state between /send and the first successful or terminal /check. |
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. (Optional) Subscribe to webhook events
If you’d rather not block your check endpoint on the synchronous
Full payload shapes live in Webhook events reference.The webhook signature header (
/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:| Event | Fired when |
|---|---|
verification.sent | The code was successfully handed to the provider. Carries verification_id, channel, masked phone. SIM-swap soft warning adds sim_swap_warning: true. |
verification.approved | A /check call matched the code. Carries verification_id. Fires exactly once per row — a CAS-shaped state transition ensures concurrent /check calls cannot fan out duplicate webhooks. |
verification.failed | max_attempts exhausted with no match, or SIM-swap pre-flight blocked the send (reason: "sim_swap_detected" + last_swap_date). Carries verification_id. |
verification.checked | Any /check attempt landed — successful or not. Useful for audit trails / fraud scoring. |
verification.fallback_triggered | Async fallback engine advanced from one channel to the next inside the profile’s channel chain. Carries verification_id, channel, channel_index. |
verification.fallback_exhausted | Every channel in the fallback chain rejected the send without ever obtaining an approved status. Treat as a hard failure on your end. |
X-Webhook-Signature) is HMAC-SHA256
over the canonical body using your endpoint’s signing secret — verify
it server-side before trusting any payload.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:| Code | HTTP | When |
|---|---|---|
VALIDATION_ERROR | 422 | Body schema invalid, wrong code (with attempts_remaining), max_attempts exhausted (Maximum attempts exceeded), channel disallowed by profile, or unsupported channel. |
RATE_LIMIT_EXCEEDED | 429 | Any of the three rate-limit layers tripped. Honour Retry-After header where present. |
EXPIRED_TOKEN | 410 | /check arrived after expires_at. Create a new verification. |
INSUFFICIENT_FUNDS | 402 | Wallet deduct failed at send-time. Top up balance or wait for the org admin to refill. |
SIM_SWAP_DETECTED | 403 | SMS / voice send refused because the recipient’s SIM was swapped within the last 24h (configurable). The OTP never leaves the platform. Surface a fallback (“verify another way”) instead of retrying. |
SERVICE_UNAVAILABLE | 5xx | Transient platform error. Safe to retry with exponential backoff up to 3 attempts. |
DELIVERY_FAILED | 422 | Provider rejected the dispatch (e.g. recipient number unreachable). Choose a different channel or to. The send is refunded automatically. |
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-Webhook-Signatureheader on every webhook delivery. Reject any body whose HMAC-SHA256 does not match. - 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.