Receiving Results
A verification outcome reaches you through one of three channels. All three are signed with your webhookSecret, and GET /v1/sessions/{sessionId} 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 webhookUrl:
POST /kyc-result HTTP/1.1 Content-Type: application/json X-Inyo-Signature: t=1704829200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Verify the Signature
This is the same header format the Remittances API uses, so one verifier serves both products.
| Component | Meaning |
|---|---|
t=<seconds> | Unix timestamp when the signature was computed β use it to reject replays outside a tolerance window |
v1=<hex> | Hex-encoded HMAC-SHA256, keyed with your webhookSecret. v1 is the current scheme version |
The signed byte string is the ASCII concatenation:
<timestamp> + "." + <raw request body>
Use the digits from t= exactly as they appear β do not re-parse and re-format them.
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.
Three rules that will save you a migration later:
- Split the header on
,and look up your own version. A future scheme ships ast=β¦,v1=β¦,v2=β¦during a transition window, so a verifier that findsv1keeps working while you migrate. A verifier that assumes the header is one value breaks on the comma. - Reject a timestamp outside your tolerance. 300 seconds is a reasonable window. Without this check the timestamp is decoration and a captured delivery replays forever.
- Reject a header that repeats a version. We never send one, but HTTP permits proxies to join duplicate headers with commas β and since this format is comma-delimited, picking one silently would make verification depend on ordering.
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 header = req.get("X-Inyo-Signature") ?? "";
// split the list, and reject a repeated key rather than picking one
const parts = new Map();
for (const entry of header.split(",")) {
const [key, value] = entry.trim().split("=");
if (!key || !value) continue;
if (parts.has(key)) return res.sendStatus(401);
parts.set(key, value);
}
const timestamp = parts.get("t");
const signature = parts.get("v1");
if (!timestamp || !signature) return res.sendStatus(401);
// reject replays: without this the timestamp buys you nothing
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.sendStatus(401);
}
const expected = crypto
.createHmac("sha256", process.env.KYC_WEBHOOK_SECRET)
.update(`${timestamp}.`)
.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
import time
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
TOLERANCE_SECONDS = 300
def parse_signature(header: str) -> dict[str, str]:
"""Return {} on a repeated key rather than silently picking one."""
parts: dict[str, str] = {}
for entry in header.split(","):
key, _, value = entry.strip().partition("=")
if not key or not value:
continue
if key in parts:
return {}
parts[key] = value
return parts
@app.post("/kyc-result")
async def kyc_result(request: Request, x_inyo_signature: str = Header(default="")):
raw = await request.body()
parts = parse_signature(x_inyo_signature)
timestamp, signature = parts.get("t"), parts.get("v1")
if not timestamp or not signature:
raise HTTPException(status_code=401, detail="invalid signature")
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
raise HTTPException(status_code=401, detail="signature timestamp outside tolerance")
expected = hmac.new(
os.environ["KYC_WEBHOOK_SECRET"].encode(),
f"{timestamp}.".encode() + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
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.
Rotating Your Webhook Secret
A secret can be rotated β ask your Inyo contact. Rotation takes effect immediately and is not gradual: deliveries signed with the previous secret stop the moment the new one is issued, so plan for a short window where your verifier must accept either value, and drop the old one once you stop seeing it. Rotation also invalidates any redirect URL already issued for an in-flight session, because the signature was computed with the retired secret. Rotate between customer journeys where you can.
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/{sessionId}.
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 redirectUrl with the outcome in the query string:
https://you.example/kyc-done?sessionId=9f1c8e42β¦&status=approved&sig=7b3e1dβ¦&sigVersion=v1
| Parameter | Description |
|---|---|
sessionId | The session that completed |
status | approved, declined, expired, or in_review |
sig | HMAC-SHA256 hex of the string "<sessionId>.<status>", keyed with your webhookSecret |
sigVersion | The scheme sig was computed with β currently always v1 |
The redirect is not signed the way the webhook header is. sig is a bare 64-character hex digest with no timestamp, and the version travels as its own parameter rather than inside the value. The two differ because a redirect is a URL your customer's browser follows, not a request body we control: keeping sig bare means the verifier you already wrote keeps working, and sigVersion gives a future scheme somewhere to announce itself without a flag day.
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)
Read sigVersion and reject a value your code does not implement β that is what makes it useful. Treating an unknown version as v1 would verify a future digest against the wrong scheme and fail in a way that looks like tampering. There is no timestamp to check, so bound the landing yourself: a redirect is only meaningful for a session you are currently expecting.
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 webhookUrl 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.manualReview |
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 notifiedAt timestamp:
{
"sessionId": "9f1c8e42β¦",
"status": "approved",
"autoStatus": "declined",
"manualReview": { "action": "review", "reviewer": "analyst-7", "reason": "β¦", "autoStatus": "declined" },
"notifiedAt": "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
notifiedAtand ignore anything older. Do not rely on arrival order.
notifiedAt stamps the delivery, not the stored result, so it is absent from the result you read via GET /v1/sessions/{sessionId} β 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 β ask your Inyo contact. Enabling notifications without a webhookUrl 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 webhookUrl so later decisions reach you |
| Compliance-critical, cannot miss an outcome | Any of the above plus a reconciliation job polling GET /v1/sessions/{sessionId} for sessions without a terminal status |
Next Steps
- Checks & Decisions β reading the payload you just verified
- Manual Review β what
in_reviewandmanualReviewmean
