---
description: >-
  Quickstart for Inyo Identity Verification — obtain an OAuth token, create a session with POST /v1/sessions, open the returned widgetUrl, receive the signed webhook, and confirm the result server-side.
---

# Getting Started

This guide walks you through one complete identity verification on the Inyo sandbox. By the end you will have created a session, verified a document and selfie through the hosted widget, and received a signed result on your own endpoint.

***

### Prerequisites

* **Sandbox credentials** issued by Inyo during onboarding:
  * `client_id` and `client_secret` for the client-credentials grant
  * Your sandbox **base URL**, and confirmation that your egress IP ranges are allowlisted
  * A `webhookSecret` — used to verify the signature on delivered results
* **A webhook endpoint**, publicly reachable over HTTPS, registered with Inyo as your `webhookUrl`. During development a tunnel (ngrok, Cloudflare Tunnel) works; alternatively use [redirect delivery](results.md#redirect-mode) and skip the webhook entirely.
* **A phone with a camera** for the widget. Camera access requires a secure context, so open the widget over HTTPS — desktop browsers work too.
* A REST client (cURL, Postman, or similar).

> No credentials yet? Get in touch with our sales team to request sandbox access.

***

### Step 1: Get an Access Token

```bash
curl --request POST \
  --url https://{FQDN}/oauth/token \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data grant_type=client_credentials \
  --data client_id=$KYC_CLIENT_ID \
  --data client_secret=$KYC_CLIENT_SECRET
```

```json
{
  "access_token": "eyJhbGciOiJSUzI1NiIs…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "sessions verifications"
}
```

Store `access_token` and reuse it until it expires — see [Authentication](authentication.md) for refresh handling.

***

### Step 2: Create a Verification Session

`userRef` is your own identifier for the person being verified. It is echoed back in every result, so use whatever your system already keys users by.

```bash
curl --request POST \
  --url https://{FQDN}/v1/sessions \
  --header "Authorization: Bearer $ACCESS_TOKEN" \
  --header 'Content-Type: application/json' \
  --data '{
  "userRef": "user-123",
  "language": "en",
  "delivery": { "mode": "webhook" }
}'
```

```json
{
  "sessionId": "9f1c8e42-7c3e-4f2b-9d7a-2b1e5c8f4a10",
  "status": "pending",
  "widgetUrl": "https://{FQDN}/verify/8Kd2mQ…"
}
```

Persist `sessionId` against your user now — it is how you will correlate the incoming result.

> `delivery.mode` defaults to `webhook`. If you have no `webhookUrl` configured, this call returns `422`; use `{"mode": "redirect", "redirectUrl": "https://you.example/kyc-done"}` instead.

***

### Step 3: Send the Customer to the Widget

Open `widgetUrl` in the customer's browser or a native webview, or deliver it by SMS or email. The link carries a single-use code and is valid for 48 hours.

The customer selects a document type, photographs the document, takes a selfie, and sees the outcome. You write no camera code — see [Widget Delivery](widget-delivery.md) for webview and branding details.

***

### Step 4: Receive the Signed Result

When the verification finishes, Inyo `POST`s the result to your `webhookUrl` with an `X-Inyo-Signature` header:

```
POST /kyc-result HTTP/1.1
Content-Type: application/json
X-Inyo-Signature: t=1704829200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

```json
{
  "sessionId": "9f1c8e42-7c3e-4f2b-9d7a-2b1e5c8f4a10",
  "userRef": "user-123",
  "status": "approved",
  "autoStatus": "approved",
  "document": {
    "type": "passport",
    "number": "A12345678",
    "issuingState": "USA",
    "issuingCountry": "USA",
    "expirationDate": "2033-09-30"
  },
  "person": {
    "firstName": "ALEX",
    "lastName": "MORGAN",
    "dateOfBirth": "1988-03-04",
    "nationality": "USA"
  },
  "checks": [
    { "name": "document_readable", "status": "passed", "group": "document", "detail": "all core fields extracted" },
    { "name": "document_mrz_valid", "status": "passed", "group": "document", "detail": "all check digits valid" },
    { "name": "document_not_expired", "status": "passed", "group": "document", "detail": "expirationDate 2033-09-30" },
    { "name": "face_match", "status": "passed", "group": "selfie", "detail": "CompareFaces similarity vs threshold 90.0" }
  ],
  "prefillMismatches": [],
  "provider": "inyo"
}
```

**Verify the signature before trusting the payload.** The header is a comma-separated list: `t` is the Unix timestamp the signature was computed at, and `v1` is an HMAC-SHA256 keyed with your `webhookSecret` over the bytes `"<t>." + <raw body>`.

```javascript
import crypto from "node:crypto";

const parts = new Map(
  (req.headers["x-inyo-signature"] ?? "")
    .split(",")
    .map((entry) => entry.trim().split("=")),
);

const timestamp = parts.get("t");
const signature = parts.get("v1");

const expected = crypto
  .createHmac("sha256", process.env.KYC_WEBHOOK_SECRET)
  .update(`${timestamp}.`)
  .update(rawBody)                       // raw bytes, before JSON.parse
  .digest("hex");

const valid =
  timestamp &&
  signature &&
  Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300 &&
  crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
```

Two things this shortened example skips that production code needs: rejecting a header that repeats a version, and comparing lengths before `timingSafeEqual` (it throws on a mismatch). Parsing the body and re-serializing it changes whitespace and key order and **will** break the signature. Full details, including redirect mode and rotation, are on [Receiving Results](results.md).

Respond `2xx` to acknowledge. Inyo retries up to three times with backoff, then stops.

***

### Step 5: Confirm Server-Side

`GET /v1/sessions/{sessionId}` is the authoritative record. Use it to reconcile, to recover a dropped webhook, or whenever you need certainty rather than a push:

```bash
curl --request GET \
  --url https://{FQDN}/v1/sessions/$SESSION_ID \
  --header "Authorization: Bearer $ACCESS_TOKEN"
```

```json
{
  "sessionId": "9f1c8e42-7c3e-4f2b-9d7a-2b1e5c8f4a10",
  "userRef": "user-123",
  "status": "approved",
  "step": "done",
  "deliveryMode": "webhook",
  "result": { "…": "the same normalized result" },
  "createdAt": "2026-07-31T14:02:11.481Z",
  "updatedAt": "2026-07-31T14:04:57.902Z"
}
```

***

### Handle These Three Cases Before You Go Live

| Case | Why it matters | Where to read |
| ---- | -------------- | ------------- |
| `status` is `in_review` | Not a final answer — a human decides, and you are notified later. It can arrive by webhook *and* on a redirect. | [Manual Review](manual-review.md) |
| Two results for one session | An analyst decision or a quality-control reversal re-delivers the result. Apply the highest `notifiedAt`, not the latest arrival. | [Receiving Results](results.md#result-notifications) |
| A check failed but you want the detail | `status` is the outcome; per-tenant thresholds are quoted in each check's `detail`. | [Checks & Decisions](checks-and-decisions.md) |

***

### Next Steps

| Page | What it covers |
| ---- | -------------- |
| [Verification Sessions](sessions.md) | Prefill, document-type locking, data checks, capture limits |
| [Widget Delivery](widget-delivery.md) | Webview embedding, branding, localization |
| [Server-to-Server Verification](server-to-server.md) | Using your own capture UI instead of the widget |
| [Sandbox & Test Data](sandbox-and-test-data.md) | Driving approved, declined, and in_review outcomes |
