> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify integration without our SDK

> Add OTP verification to any app using plain HTTP — no Orbit SDK required.

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.

```bash theme={null}
export ORBIT_API_KEY="dv_live_sk_…"
```

<Steps>
  <Step title="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.

    ```bash theme={null}
    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**

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

    **Response — 201 Created**

    ```json theme={null}
    {
      "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.
  </Step>

  <Step title="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.

    ```ts theme={null}
    // 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.
  </Step>

  <Step title="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.

    ```bash theme={null}
    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)

    ```json theme={null}
    {
      "data": {
        "verification_id": "vrf_3f1c0b2a8e4d4f7a9c2b1e6d5a4c3b2a",
        "status": "approved",
        "attempts_remaining": 0
      },
      "meta": {
        "request_id": "req_…",
        "timestamp": "2026-05-24T12:01:14.000Z"
      }
    }
    ```

    **Status values**

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

    **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:

    ```json theme={null}
    {
      "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 code**

    If 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.
  </Step>

  <Step title="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:

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

    Full payload shapes live in [Webhook events reference](/reference/webhook-events#verification-events).

    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](/guides/security#webhook-verification)
    for the full parse-and-compare recipe.

    ```json theme={null}
    // verification.approved
    {
      "type": "verification.approved",
      "data": {
        "verification_id": "vrf_3f1c0b2a…",
        "channel": "sms",
        "to": "+14155552671"
      }
    }
    ```
  </Step>
</Steps>

## 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:

```json theme={null}
{
  "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:

| 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`     | 503  | The upstream provider rejected the send on the only configured channel (e.g. carrier overload or recipient number unreachable). Retry, or try 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](/guides/api-key-ip-allowlist)
  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 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](/api-reference/endpoints/verify).
* Configure a profile (templates, channel chain, fraud caps) in
  **Verify → Configuration** before going live.
* Wire a webhook endpoint via [Webhooks → Events](/webhooks/events).
* Read the [Webhook events reference](/reference/webhook-events#verification-events)
  for the full `verification.*` payload schemas.
