Inyo

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.

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 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.

ComponentMeaning
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:

  1. Split the header on , and look up your own version. A future scheme ships as t=…,v1=…,v2=… during a transition window, so a verifier that finds v1 keeps working while you migrate. A verifier that assumes the header is one value breaks on the comma.
  2. 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.
  3. 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

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/{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
ParameterDescription
sessionIdThe session that completed
statusapproved, declined, expired, or in_review
sigHMAC-SHA256 hex of the string "<sessionId>.<status>", keyed with your webhookSecret
sigVersionThe 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:

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.manualReview
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 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 notifiedAt and 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 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 webhookUrl so later decisions reach you
Compliance-critical, cannot miss an outcomeAny of the above plus a reconciliation job polling GET /v1/sessions/{sessionId} for sessions without a terminal status

Next Steps