Inyo

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.

ChannelWhen it applies
Webhookdelivery.mode is webhook (the default)
Signed redirectdelivery.mode is redirect
Result notificationA 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

PropertyBehavior
AcknowledgementAny 2xx response. Anything else counts as a failure
RetriesUp to 3 attempts with backoff (roughly 1s, 4s, 10s)
After 3 failuresThe delivery is dropped and not retried later
Timeout15 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…
ParameterDescription
session_idThe session that completed
statusapproved, declined, expired, or in_review
sigHMAC-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:

SituationWhat you receive
Redirect-mode session completesThe initial result, including a non-terminal in_review that the redirect itself cannot carry
Any later changeThe updated result, carrying result.manual_review
The original POST /v1/verifications answerNothing β€” 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_at and 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 situationRecommended approach
Server-side onboarding flowWebhook mode, with signature verification and an idempotent handler
Web flow where the customer must continue immediatelyRedirect mode for the customer, plus notifications for the record of truth
Own capture UIServer-to-server, plus a webhook_url so later decisions reach you
Compliance-critical, cannot miss an outcomeAny of the above plus a reconciliation job polling GET /v1/sessions/{session_id} for sessions without a terminal status

Next Steps