Receiving Results
A verification outcome reaches you through one of three channels. All three are signed with your webhook_secret, and GET /v1/sessions/{session_id} is always the authoritative record behind them.
| Channel | When it applies |
|---|---|
| Webhook | delivery.mode is webhook (the default) |
| Signed redirect | delivery.mode is redirect |
| Result notification | A redirect session's result, a server-to-server result that later changes, or any decision made after the fact |
Webhook Mode
Inyo POSTs the normalized result to your configured webhook_url:
POST /kyc-result HTTP/1.1 Content-Type: application/json X-Inyo-Signature: 4c1f9a2e8b7dβ¦
Verify the Signature
X-Inyo-Signature is a hex-encoded HMAC-SHA256 over the raw request body bytes, keyed with your webhook_secret.
Verify against the raw bytes, before parsing JSON. Parsing and re-serializing changes whitespace and key order, which changes the bytes, which breaks the signature. This is the single most common integration failure. Compare in constant time β never with
==on strings.
Node.js (Express):
import express from "express";
import crypto from "node:crypto";
const app = express();
// raw body, not express.json()
app.post(
"/kyc-result",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.get("X-Inyo-Signature") ?? "";
const expected = crypto
.createHmac("sha256", process.env.KYC_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const expectedBuf = Buffer.from(expected, "utf8");
const signatureBuf = Buffer.from(signature, "utf8");
if (
expectedBuf.length !== signatureBuf.length ||
!crypto.timingSafeEqual(expectedBuf, signatureBuf)
) {
return res.sendStatus(401);
}
const result = JSON.parse(req.body.toString("utf8"));
handleResult(result);
res.sendStatus(200);
}
);
Python (FastAPI):
import hashlib
import hmac
import json
import os
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
@app.post("/kyc-result")
async def kyc_result(request: Request, x_inyo_signature: str = Header(default="")):
raw = await request.body()
expected = hmac.new(
os.environ["KYC_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, x_inyo_signature.strip()):
raise HTTPException(status_code=401, detail="invalid signature")
handle_result(json.loads(raw))
return {"ok": True}
crypto.timingSafeEqual throws on a length mismatch, so the Node example checks length first; hmac.compare_digest handles that internally.
Delivery Guarantees
| Property | Behavior |
|---|---|
| Acknowledgement | Any 2xx response. Anything else counts as a failure |
| Retries | Up to 3 attempts with backoff (roughly 1s, 4s, 10s) |
| After 3 failures | The delivery is dropped and not retried later |
| Timeout | 15 seconds per attempt |
Delivery is best-effort. Respond 2xx quickly and process asynchronously β a slow handler burns the timeout and turns a successful verification into a dropped delivery. If you need a guarantee rather than a push, poll GET /v1/sessions/{session_id}.
Make your handler idempotent. The same session's result can legitimately arrive more than once: a retry after your 2xx was lost in transit, or a genuinely newer outcome (see ordering).
Redirect Mode
When the session was created with delivery.mode: "redirect", the customer is returned to your redirect_url with the outcome in the query string:
https://you.example/kyc-done?session_id=9f1c8e42β¦&status=approved&sig=7b3e1dβ¦
| Parameter | Description |
|---|---|
session_id | The session that completed |
status | approved, declined, expired, or in_review |
sig | HMAC-SHA256 hex of the string "<session_id>.<status>", keyed with your webhook_secret |
Verify the Redirect Signature
import hashlib
import hmac
def valid_redirect(session_id: str, status: str, sig: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(), f"{session_id}.{status}".encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sig)
Because status is inside the signature, a customer cannot rewrite declined to approved, and a held session cannot be replayed as an approval. A signature that does not verify means the parameters were tampered with β reject the landing entirely.
status on a Redirect Can Be in_review
A held session still returns your customer to you rather than stranding them on an under-review screen. Your landing page must handle in_review as a pending state β not as a failure, and not as an approval. The analyst's eventual decision will not be in the redirect the customer arrived on; it reaches you through a result notification or by polling.
Result Notifications
A redirect rides home in your customer's browser, and a server-to-server verification answers in a response body. Neither survives a later change β and outcomes do change, when an analyst decides a queued session or a quality-control decision reverses a completed one.
So when you have a webhook_url configured, results are also POSTed to it β signed exactly like webhook-mode delivery β for sessions whose customer-facing delivery is something else:
| Situation | What you receive |
|---|---|
| Redirect-mode session completes | The initial result, including a non-terminal in_review that the redirect itself cannot carry |
| Any later change | The updated result, carrying result.manual_review |
The original POST /v1/verifications answer | Nothing β the 201 was the delivery |
Webhook-mode sessions are unaffected: there the webhook is the delivery, not a notification about it.
Ordering Concurrent Notifications
Every notification carries a notified_at timestamp:
{
"session_id": "9f1c8e42β¦",
"status": "approved",
"auto_status": "declined",
"manual_review": { "action": "review", "reviewer": "analyst-7", "reason": "β¦", "auto_status": "declined" },
"notified_at": "2026-07-31T14:41:09.882Z"
}
A session can have two deliveries in flight at once β one still retrying while a newer decision is dispatched β so they can arrive out of order.
Apply the highest
notified_atand ignore anything older. Do not rely on arrival order.
notified_at stamps the delivery, not the stored result, so it is absent from the result you read via GET /v1/sessions/{session_id} β that endpoint always returns the current state, so there is nothing to order.
Opting Out
Result notifications are on by default. They can be disabled for your tenant, leaving the browser redirect or the synchronous response as your only channel β see Tenant Configuration. Enabling notifications without a webhook_url is rejected rather than silently ignored.
Choosing a Strategy
| Your situation | Recommended approach |
|---|---|
| Server-side onboarding flow | Webhook mode, with signature verification and an idempotent handler |
| Web flow where the customer must continue immediately | Redirect mode for the customer, plus notifications for the record of truth |
| Own capture UI | Server-to-server, plus a webhook_url so later decisions reach you |
| Compliance-critical, cannot miss an outcome | Any of the above plus a reconciliation job polling GET /v1/sessions/{session_id} for sessions without a terminal status |
Next Steps
- Checks & Decisions β reading the payload you just verified
- Manual Review β what
in_reviewandmanual_reviewmean
