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_idandclient_secretfor 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 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
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
{
"access_token": "eyJhbGciOiJSUzI1NiIs…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "sessions verifications"
}
Store access_token and reuse it until it expires — see Authentication 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.
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" }
}'
{
"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.modedefaults towebhook. If you have nowebhookUrlconfigured, this call returns422; 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 for webview and branding details.
Step 4: Receive the Signed Result
When the verification finishes, Inyo POSTs 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
{
"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>.
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.
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:
curl --request GET \
--url https://{FQDN}/v1/sessions/$SESSION_ID \
--header "Authorization: Bearer $ACCESS_TOKEN"
{
"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 |
| 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 |
| A check failed but you want the detail | status is the outcome; per-tenant thresholds are quoted in each check's detail. | Checks & Decisions |
Next Steps
| Page | What it covers |
|---|---|
| Verification Sessions | Prefill, document-type locking, data checks, capture limits |
| Widget Delivery | Webview embedding, branding, localization |
| Server-to-Server Verification | Using your own capture UI instead of the widget |
| Sandbox & Test Data | Driving approved, declined, and in_review outcomes |
