Skip to main content

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

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.
Customer app          Your backend                 Orbit API
─────────────────────────────────────────────────────────────────
 User taps "Send"  →  POST /verify/send       →  201 + verification_id
                      (stash id ↔ user_id)
 User types code   →  POST /verify/check      →  200 + status
                      (read status field)
                                              ↘  webhook delivery
                                                 (optional, async)

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.
export ORBIT_API_KEY="dv_live_sk_…"
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.
curl -X POST "https://api.orbit.devotel.io/api/v1/verify/send" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+14155552671",
    "channel": "sms",
    "max_attempts": 3
  }'
Request body
FieldTypeRequiredNotes
tostringyesE.164 phone (+14155552671) or RFC 5322 email. Channel must match recipient shape.
channelstringyesOne of sms, whatsapp, email, voice, viber, telegram.
max_attemptsintegerno1–10, default 3. Max wrong codes before the row hard-fails.
profile_idstringnoA verification profile id (format vprof_<hex>). Profiles override defaults for code length, expiry, channel chain, fraud caps, templates.
Response — 201 Created
{
  "data": {
    "verification_id": "vrf_3f1c0b2a8e4d4f7a9c2b1e6d5a4c3b2a",
    "status": "pending",
    "channel": "sms",
    "expires_at": "2026-05-24T12:10:00.000Z"
  },
  "meta": {
    "request_id": "req_8b2c1f4a…",
    "timestamp": "2026-05-24T12:00:00.000Z"
  }
}
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.
// Pseudo-code — adapt to your stack.
await db.user_verifications.insert({
  user_id: currentUser.id,
  verification_id: response.data.verification_id,
  channel: response.data.channel,
  expires_at: response.data.expires_at,
  consumed: false,
});
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.
curl -X POST "https://api.orbit.devotel.io/api/v1/verify/check" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "verification_id": "vrf_3f1c0b2a8e4d4f7a9c2b1e6d5a4c3b2a",
    "code": "482910"
  }'
Response — 200 OK (status reflects the row’s terminal state when applicable)
{
  "data": {
    "verification_id": "vrf_3f1c0b2a8e4d4f7a9c2b1e6d5a4c3b2a",
    "status": "approved",
    "attempts_remaining": 0
  },
  "meta": {
    "request_id": "req_…",
    "timestamp": "2026-05-24T12:01:14.000Z"
  }
}
Status values
StatusMeaning
approvedCode matched. Row is terminal — further /check calls return Verification already processed (409). Treat your user as verified, then discard the verification_id.
failedReturned inside the error envelope (HTTP 422) when the supplied code is wrong and max_attempts has been reached. The row is terminal.
expiredReturned inside the error envelope (HTTP 410) when the call lands after expires_at. The row is terminal.
pendingNot returned from /check — only /verify/:id GET shows this. It’s the in-flight state between /send and the first successful or terminal /check.
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:
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid verification code",
    "details": { "attempts_remaining": 2 }
  },
  "meta": { "request_id": "req_…", "timestamp": "…" }
}
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:
EventFired when
verification.sentThe code was successfully handed to the provider. Carries verification_id, channel, masked phone. SIM-swap soft warning adds sim_swap_warning: true.
verification.approvedA /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.failedmax_attempts exhausted with no match, or SIM-swap pre-flight blocked the send (reason: "sim_swap_detected" + last_swap_date). Carries verification_id.
verification.checkedAny /check attempt landed — successful or not. Useful for audit trails / fraud scoring.
verification.fallback_triggeredAsync fallback engine advanced from one channel to the next inside the profile’s channel chain. Carries verification_id, channel, channel_index.
verification.fallback_exhaustedEvery channel in the fallback chain rejected the send without ever obtaining an approved status. Treat as a hard failure on your end.
Full payload shapes live in Webhook events reference.The webhook signature header (X-Webhook-Signature) is HMAC-SHA256 over the canonical body using your endpoint’s signing secret — verify it server-side before trusting any payload.
// verification.approved
{
  "type": "verification.approved",
  "data": {
    "verification_id": "vrf_3f1c0b2a…",
    "channel": "sms",
    "to": "+14155552671"
  }
}

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:
{
  "error": {
    "code": "<MACHINE_READABLE_CODE>",
    "message": "<human-readable message>",
    "details": { /* error-specific, never the raw code */ }
  },
  "meta": { "request_id": "req_…", "timestamp": "…" }
}
The codes you will see on the verify path:
CodeHTTPWhen
VALIDATION_ERROR422Body schema invalid, wrong code (with attempts_remaining), max_attempts exhausted (Maximum attempts exceeded), channel disallowed by profile, or unsupported channel.
RATE_LIMIT_EXCEEDED429Any of the three rate-limit layers tripped. Honour Retry-After header where present.
EXPIRED_TOKEN410/check arrived after expires_at. Create a new verification.
INSUFFICIENT_FUNDS402Wallet deduct failed at send-time. Top up balance or wait for the org admin to refill.
SIM_SWAP_DETECTED403SMS / 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_UNAVAILABLE5xxTransient platform error. Safe to retry with exponential backoff up to 3 attempts.
DELIVERY_FAILED422Provider 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-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-Webhook-Signature header on every webhook delivery. Reject any body whose HMAC-SHA256 does not match.
  • 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