---
description: >-
  Receive KYC results by HMAC-SHA256 signed webhook or signed redirect — verifying X-Inyo-Signature against the raw request body, retry and delivery guarantees, and ordering concurrent notifications by notifiedAt.
---

# 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](#webhook-mode) | `delivery.mode` is `webhook` (the default) |
| [Signed redirect](#redirect-mode) | `delivery.mode` is `redirect` |
| [Result notification](#result-notifications) | A redirect session's result, a server-to-server result that later changes, or any decision made after the fact |

***

## Webhook Mode

Inyo `POST`s the [normalized result](checks-and-decisions.md) 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](../remittances/webhooks.md#verifying-signatures-hmac-sha256) 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:

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):**

```javascript
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):**

```python
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}`](sessions.md#retrieve-a-session).

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](#ordering-concurrent-notifications)).

***

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

```python
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](#result-notifications) 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** `POST`ed 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:

```json
{
  "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 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](checks-and-decisions.md) — reading the payload you just verified
* [Manual Review](manual-review.md) — what `in_review` and `manualReview` mean
