---
description: >-
  Webhook notifications for the Remittances API — transaction status, compliance status, payout status, document and agent events, with payload references, HMAC-SHA256 signature verification (X-Inyo-Signature), retry behavior, and handler best practices.
---

# Webhooks

Webhooks allow you to receive real-time notifications when important events occur in the Inyo platform — such as a transaction changing status, a compliance decision, or a document verification completing. Instead of polling the API, you register a callback URL and Inyo pushes events to you.

***

### Supported Events

| Event | Description |
| ----- | ----------- |
| `TransactionStatusChanged` | The transaction moved through Inyo's internal state machine (e.g., `PaymentAuthorized` → `ProcessingPayout`). The most frequent event — a typical transaction produces 8–10 of these across its lifecycle. |
| `TransactionComplianceStatusChangedEvent` | The transaction's compliance status changed (e.g., `Pending` → `Approved`). Fires **only** when the compliance value actually differs from its prior value. |
| `TransactionPayoutStatusChanged` | The payment gateway reported a status change on the **payout leg** (Inyo → recipient). Near-real-time gateway signal, faster than the state-machine event. |
| `DocumentUpdatedEvents` | A document was uploaded, or its verification completed (approved/rejected). |
| `AgentUpdatedEvents` | An agent record was created. |

> **Event-name casing:** subscription matching is case-insensitive — `TransactionStatusChanged`, `transactionstatuschanged`, and `TRANSACTIONSTATUSCHANGED` all subscribe to the same event. The `event` field in the delivered payload **echoes the exact spelling you registered**, so match it case-insensitively in your handler.

> The compliance event name carries a trailing `Event` suffix (`TransactionComplianceStatusChangedEvent`) — historical, kept for backwards compatibility.

***

### Registering a Webhook

**Endpoint:** `POST /organizations/{tenant}/webhooks`\
**Authentication:** Tenant-level (`x-api-key`)

```bash
curl --request POST \
  --url https://{FQDN}/organizations/$TENANT/webhooks \
  --header 'Content-Type: application/json' \
  --header "x-api-key: $API_KEY" \
  --data '{
  "url": "https://your-server.com/api/webhooks/inyo",
  "events": [
    "TransactionStatusChanged",
    "TransactionComplianceStatusChangedEvent",
    "TransactionPayoutStatusChanged",
    "DocumentUpdatedEvents",
    "AgentUpdatedEvents"
  ]
}'
```

| Field | Type | Required | Description |
| ----- | ---- | -------- | ----------- |
| `url` | string | Yes | HTTPS endpoint to receive deliveries (max 2048 chars) |
| `events` | string[] | Yes | At least one event name (case-insensitive match) |

**Subscription rules:**

* Only the events you list fire on that endpoint.
* Multiple endpoints per tenant are supported — register once per URL, each with its own event list.
* There is **no update endpoint**. To change an existing subscription's event list, delete it and re-create it.

Deliveries are **unsigned by default**. To have every delivery carry an `X-Inyo-Signature` HMAC-SHA256 header, generate a signing secret for the subscription — see the next section.

***

### Generating or Rotating a Signing Secret

**Endpoint:** `POST /organizations/{tenant}/webhooks/{webhookId}/secret`\
**Authentication:** Agent-level (`x-api-key` + `x-agent-id` + `x-agent-api-key`)

Generates a signing secret on first call, or **rotates** it (replacing the current one atomically) on subsequent calls. Once a secret is set, every delivery to that subscription carries the `X-Inyo-Signature` header.

> Unlike the other webhook endpoints, the secret operations require **agent credentials in addition to the tenant key**. Rotating a signing secret is security-sensitive (it grants the ability to forge or verify signed deliveries), so an agent identity is required for the audit trail — the same bar as transactional endpoints.

```bash
curl --request POST \
  --url https://{FQDN}/organizations/$TENANT/webhooks/$WEBHOOK_ID/secret \
  --header "x-api-key: $API_KEY" \
  --header "x-agent-id: $AGENT_ID" \
  --header "x-agent-api-key: $AGENT_KEY"
```

**Response (200):**

```json
{
  "signingSecret": "nZl3F0p9pKcW1sT7Yb2eXo4qUvJ8hRmA6dCgE5wLiSk=",
  "secretFingerprint": "abcd1234",
  "signatureVersion": "v1",
  "secretRotatedAt": "2026-08-04T15:30:00+00:00"
}
```

| Field | Description |
| ----- | ----------- |
| `signingSecret` | The raw secret (44-character base64 of 32 random bytes). **Shown only in this response** — store it immediately in your secrets manager. There is no GET endpoint; the value can never be retrieved again. |
| `secretFingerprint` | First 8 hex characters of `sha256(secret)` — safe to display; use it to confirm which secret is active |
| `signatureVersion` | Signature scheme version, currently `v1` |
| `secretRotatedAt` | When the secret was generated/rotated |

> **Rotation replaces the secret immediately** — there is no dual-secret grace period. Deliveries signed with the old secret stop the moment you rotate; see [Secret Rotation](#secret-rotation) for a no-downtime procedure.

### Disabling Signing

**Endpoint:** `DELETE /organizations/{tenant}/webhooks/{webhookId}/secret`\
**Authentication:** Agent-level (`x-api-key` + `x-agent-id` + `x-agent-api-key`)

```bash
curl --request DELETE \
  --url https://{FQDN}/organizations/$TENANT/webhooks/$WEBHOOK_ID/secret \
  --header "x-api-key: $API_KEY" \
  --header "x-agent-id: $AGENT_ID" \
  --header "x-agent-api-key: $AGENT_KEY"
```

Returns `204`. Deliveries resume **without** the `X-Inyo-Signature` header — disable verification on your side first, or your endpoint will start rejecting them.

***

### Listing Registered Webhooks

**Endpoint:** `GET /organizations/{tenant}/webhooks`\
**Authentication:** Tenant-level (`x-api-key`)

```bash
curl --request GET \
  --url https://{FQDN}/organizations/$TENANT/webhooks \
  --header "x-api-key: $API_KEY"
```

**Response:**

```json
{
  "webhooks": [
    {
      "id": "6f0e3a3a-6f27-4a2e-9c1f-1f2f3a4b5c6d",
      "url": "https://your-server.com/api/webhooks/inyo",
      "events": ["TransactionStatusChanged"],
      "isBatch": false,
      "createdAt": "2026-08-01T12:00:00+00:00",
      "signatureVersion": "v1",
      "secretFingerprint": "abcd1234",
      "secretRotatedAt": "2026-08-04T15:30:00+00:00"
    }
  ]
}
```

The raw signing secret is **never** included — `secretFingerprint` (`null` when no secret is configured) tells you whether signing is active and which secret is in use.

***

### Deleting a Webhook

**Endpoint:** `DELETE /organizations/{tenant}/webhooks/{webhookId}`\
**Authentication:** Tenant-level (`x-api-key`)

```bash
curl --request DELETE \
  --url https://{FQDN}/organizations/$TENANT/webhooks/$WEBHOOK_ID \
  --header "x-api-key: $API_KEY"
```

***

### Event Payloads

All deliveries are `POST` requests to your registered URL with `Content-Type: application/json`.

#### 1. `TransactionStatusChanged`

Fires when Inyo's internal state machine transitions the transaction.

```json
{
  "event": "TransactionStatusChanged",
  "transactionId": "0a3ca5d5-927d-4784-9dbe-a10087a746cc",
  "externalTransactionId": "de70d60f-11e7-431c-bd5d-4ae4f2f808e4",
  "tenantId": "your-tenant-slug",
  "oldStatus": "Paid",
  "newStatus": "Completed",
  "newStatusMessages": ["Transaction completed"]
}
```

| Field | Type | Notes |
| ----- | ---- | ----- |
| `event` | string | Echoes your registered spelling |
| `transactionId` | uuid | Inyo transaction UUID |
| `externalTransactionId` | string \| null | Your `externalId` from transaction create; `null` if you didn't send one |
| `tenantId` | string | Your tenant slug |
| `oldStatus` | string \| null | Previous status; `null` on the very first transition |
| `newStatus` | string | New status — see values below |
| `newStatusMessages` | string[] | Human-readable context, if any |

**Status values:**

`Created`, `PaymentProcessing`, `WaitingChallenge3ds`, `PaymentAuthorized`, `PaymentDeclined`, `PaymentCaptured`, `WaitingSettlement`, `PaymentSettled`, `ProcessingPayout`, `PayoutAccepted`, `PayoutHold`, `PayoutReleased`, `PayoutRejected`, `ManualReview`, `ReviewApproved`, `ReviewRejected`, `WaitingPayout`, `Paid`, `Completed`, `Cancelled`, `CancelRequested`, `Refunded`, `Voided`, `Error`, `PendingReversalApproval`, `BlockedPendingReview`.

**Typical happy-path sequence (card, no 3DS):**

```
Created → PaymentProcessing → PaymentAuthorized → ProcessingPayout
       → PayoutAccepted → ReviewApproved → PaymentCaptured
       → WaitingPayout → Paid → Completed
```

See the [transaction lifecycle](transaction.md#transaction-status-lifecycle) for the full state flows including 3DS and ACH.

#### 2. `TransactionComplianceStatusChangedEvent`

Fires **only when** the compliance status actually changes. State transitions that don't move compliance don't emit this event.

```json
{
  "event": "TransactionComplianceStatusChangedEvent",
  "transactionId": "0a3ca5d5-927d-4784-9dbe-a10087a746cc",
  "externalTransactionId": "de70d60f-11e7-431c-bd5d-4ae4f2f808e4",
  "tenantId": "your-tenant-slug",
  "oldComplianceStatus": "Pending",
  "newComplianceStatus": "Approved",
  "newStatusMessages": []
}
```

| Field | Type | Notes |
| ----- | ---- | ----- |
| `oldComplianceStatus` | string \| null | Title-cased; `null` on the first transition |
| `newComplianceStatus` | string | Title-cased — see values below |

**Compliance status values:** `Pending`, `Approved`, `Rejected`, `Cancelled`, `Refunded`, `Failed`.

Most transactions produce a single compliance event: `Pending → Approved` (or a terminal transition such as `Pending → Cancelled`). Note that a GMT payout hold does **not** change compliance status — holds surface as `PayoutHold` in `TransactionStatusChanged`.

#### 3. `TransactionPayoutStatusChanged`

Fires when the **payment gateway** reports a status change on the payout leg of a transaction (Inyo → recipient bank/wallet). This is a near-real-time gateway signal, distinct from `TransactionStatusChanged` — the latter reflects Inyo's internal state machine, which on the payout side is driven mostly by a periodic poller. Subscribe if you want faster payout visibility.

```json
{
  "event": "TransactionPayoutStatusChanged",
  "transactionId": "0a3ca5d5-927d-4784-9dbe-a10087a746cc",
  "externalTransactionId": "de70d60f-11e7-431c-bd5d-4ae4f2f808e4",
  "tenantId": "your-tenant-slug",
  "oldPayoutStatus": "PENDING",
  "newPayoutStatus": "AUTHORIZED",
  "gatewayPaymentId": "d5af3652-909e-44b6-9f89-f14d055baf26",
  "gmtReceipt": "GMT000641475307",
  "amount": {
    "total": 48.74999979043565,
    "currency": "USD"
  },
  "newStatusMessages": ["Payment: CONFIRM_PENDING"]
}
```

| Field | Type | Notes |
| ----- | ---- | ----- |
| `oldPayoutStatus` | string \| null | Raw gateway status |
| `newPayoutStatus` | string | Raw gateway status — see below |
| `gatewayPaymentId` | string \| null | The gateway's own payment reference |
| `gmtReceipt` | string \| null | Populated when the payout routed through the MSB network (most cases) |
| `amount` | object \| null | `{ "total": number, "currency": string }`. `total` is a raw float — **not** rounded to the currency's minor units. Round on your side. |

`newPayoutStatus` is the **raw string** the gateway sent — no Inyo-side enum mapping. Common values: `AUTHORIZED`, `PENDING`, `CAPTURED`, `DECLINED`, `VOIDED`, `REFUNDED`, `ERROR`. New gateway codes may appear at any time — treat unknown strings as "no action required" rather than erroring.

#### 4. `DocumentUpdatedEvents`

Fires when a document is uploaded, and again when verification completes (OCR or manual review).

> Subscribe as `DocumentUpdatedEvents` (recommended). Existing subscriptions registered with the legacy lowercase spelling `documentUpdatedEvents` keep working and keep receiving that spelling in the payload.

```json
{
  "event": "DocumentUpdatedEvents",
  "id": "4ec66735-216b-4ab4-b1d7-00558baa6d85",
  "entityId": "6588c7e7-3b1a-42ff-94ab-a834eda66640",
  "entityType": "Participant",
  "documentType": "DRIVERS_LICENSE",
  "verificationStatus": "VERIFIED",
  "occurredAt": "2026-08-19T15:30:00+00:00",
  "tenantId": "your-tenant-slug",
  "version": 1
}
```

| Field | Type | Notes |
| ----- | ---- | ----- |
| `event` | string | Echoes your registered spelling |
| `id` | uuid | The document upload UUID for events from the upload endpoints. For events from a [KYC verification session](sender/uploading-documents.md#kyc-verification-sessions-recommended), the **session id** — stable across every event that session produces. |
| `entityId` | uuid | The participant the document belongs to |
| `entityType` | string | `Participant` |
| `documentType` | string | `PASSPORT`, `DRIVERS_LICENSE`, `SSN`, `PROOF_OF_FUNDS`, etc. — same taxonomy as the upload endpoints. Note the plural `DRIVERS_LICENSE` (canonical since 2026-09-04). |
| `verificationStatus` | string | `PENDING` (awaiting verification), `VERIFIED`, `REJECTED`, or `EXPIRED` (a KYC session ended without a decision). Compare case-insensitively — the initial upload event may deliver `Pending` — and treat an unrecognised value as "no action required" rather than erroring, since values may be added. |
| `occurredAt` | string | ISO-8601 timestamp of the decision this event reports. **Order by this, not by arrival**, when you receive more than one event for the same `id` — see [Ordering two events for one document](#ordering-two-events-for-one-document). |
| `tenantId` | string | Your tenant slug |
| `version` | number | Schema version — always `1` today |

### Ordering two events for one document

A KYC verification can be re-decided: an analyst reviewing a case, or correcting one after the fact, produces a second decision for the same document. So you may receive `VERIFIED` after `REJECTED` — or the reverse — for one `id`.

Webhook delivery order does not settle which is current. **Take the event with the greatest `occurredAt` for a given `id` and ignore older ones.** A retry of the same delivery carries the same `occurredAt`, so treating equal timestamps as already-handled is safe and makes your handler idempotent.

Typical lifecycle for the upload endpoints: upload → webhook with `PENDING` → async verification (minutes) → webhook with `VERIFIED` or `REJECTED`. If AI OCR is not enabled for your tenant, only the pending webhook fires and verification is handled manually by the compliance team.

For a KYC verification session the sequence is the same shape, driven by the session outcome: a session routed to manual review delivers `PENDING` when it enters review, then `VERIFIED` or `REJECTED` when an analyst decides. A session that decides immediately delivers only the second event. An expired session delivers `EXPIRED`: nothing was decided about the document, but the event is your only signal that the session ended, so treating it as still pending would leave you waiting indefinitely. Because `id` is the session id throughout, the two events correlate; `entityId` (the participant) is a stable join key in either flow.

#### 5. `AgentUpdatedEvents`

Fires when an agent record is created via `POST /organizations/{tenant}/agents`.

> Subscribe as `AgentUpdatedEvents` (recommended). Existing subscriptions registered with the legacy lowercase spelling `agentUpdatedEvents` keep working and keep receiving that spelling in the payload.

```json
{
  "event": "AgentUpdatedEvents",
  "id": "88fa9606-8345-454a-9669-19f13ccdae13",
  "before": null,
  "after": {
    "id": "88fa9606-8345-454a-9669-19f13ccdae13",
    "email": "agent@your-server.example",
    "businessName": "Your Agent Business Name",
    "externalId": null,
    "status": "PENDING_APPROVAL",
    "createdAt": "2026-06-02T18:20:10+00:00"
  },
  "tenantId": "your-tenant-slug",
  "version": 1
}
```

`before` is `null` on creation (the only path that currently emits this event).

***

### Verifying Signatures (HMAC-SHA256)

When a signing secret is configured for your subscription, every delivery carries an `X-Inyo-Signature` header. Verifying it lets your endpoint reject forged requests and replays.

#### Header Format

```
X-Inyo-Signature: t=1704829200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

| Component | Meaning |
| --------- | ------- |
| `t=<seconds>` | Unix timestamp (seconds) when the signature was computed — use it to reject replays outside a tolerance window |
| `v1=<hex>` | Hex-encoded HMAC-SHA256. `v1` is the current scheme version; future revisions will use `v2`, `v3`, … so you can migrate without a hard cutover |

If your subscription has no signing secret, the header is not sent and the delivery is unsigned.

#### The Signed Byte String

The signature is `HMAC-SHA256(secret, signed_string)`, where `signed_string` is the ASCII concatenation:

```
<timestamp> + "." + <raw request body>
```

* **`<timestamp>`** — the exact ASCII digits from the `t=` component. Use the substring as-is; do not re-parse and re-format.
* **`"."`** — a single ASCII period, no whitespace.
* **`<raw request body>`** — the **exact bytes** of the HTTP request body, before any framework has parsed or re-serialized them.

> **Sign over raw bytes — this is the step integrators get wrong most often.** Do not `JSON.parse()` and re-`stringify()` the body: that re-orders keys and re-escapes characters, breaking the signature. Capture the raw body before your framework's JSON middleware runs — Express: `express.raw({ type: 'application/json' })`; Django: `request.body`; Rails: `request.raw_post`; Laravel: `$request->getContent()`.

Inyo serializes deliveries compactly (no pretty-printing), with forward slashes and non-ASCII characters unescaped, and no trailing newline — but none of that matters if you verify against the raw bytes as received.

#### Verification Steps

1. Read the `X-Inyo-Signature` header. Reject if absent.
2. Parse out `t` and the `v1` component. Reject if either is missing or malformed.
3. Reject if `|now − t| > 300` seconds (5-minute replay window; adjust for your clock-skew tolerance).
4. Build `signed_string = t + "." + raw_body`.
5. Compute `expected = HMAC-SHA256(secret, signed_string)`, hex-encoded lowercase.
6. Compare `expected` against the `v1` value **in constant time** (`crypto.timingSafeEqual`, `hmac.compare_digest`, `hash_equals`) — never `==`.
7. Only after all checks pass, JSON-parse the body and act on the payload.

#### Node.js (Express)

```javascript
const crypto = require('crypto');
const express = require('express');
const app = express();

// IMPORTANT: raw body, not JSON-parsed. Register BEFORE any express.json() middleware.
app.post('/webhooks/inyo',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const secret = process.env.INYO_WEBHOOK_SECRET;
    const header = req.get('X-Inyo-Signature') || '';
    const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
    const { t, v1: sig } = parts;

    if (!t || !sig) return res.status(400).send('bad signature header');
    if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(400).send('stale');

    const signedString = t + '.' + req.body.toString('utf8');
    const expected = crypto.createHmac('sha256', secret).update(signedString).digest('hex');

    const a = Buffer.from(expected, 'hex');
    const b = Buffer.from(sig, 'hex');
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(400).send('signature mismatch');
    }

    // Acknowledge immediately — you have 10 seconds before the delivery times out
    res.status(200).send('OK');

    const { event, transactionId, oldStatus, newStatus } = JSON.parse(req.body.toString('utf8'));
    if (event.toLowerCase() === 'transactionstatuschanged') {
      // …update your records…
    }
  });

app.listen(3001);
```

#### Python (Flask)

```python
import hmac, hashlib, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["INYO_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/inyo")
def inyo_webhook():
    header = request.headers.get("X-Inyo-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, sig = parts.get("t"), parts.get("v1")
    if not t or not sig:
        abort(400)
    if abs(int(time.time()) - int(t)) > 300:
        abort(400)

    raw = request.get_data()  # bytes, not request.json
    signed = t.encode() + b"." + raw
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig):
        abort(400)

    payload = request.get_json()
    # …process payload…
    return "", 200
```

#### Secret Rotation

One secret is active per subscription; rotating replaces it immediately — there is no dual-secret grace period. To rotate without downtime:

1. Temporarily accept deliveries that fail verification (log them instead of rejecting).
2. Call `POST /organizations/{tenant}/webhooks/{webhookId}/secret` and store the new `signingSecret` from the response.
3. Re-enable strict verification with the new secret.

Confirm which secret is active at any time by comparing `secretFingerprint` from `GET /webhooks` against the first 8 hex characters of `sha256(your_stored_secret)`.

#### Common Failure Modes

* **Body re-serialized before verifying** — middleware parsed the JSON and your handler re-stringified it. Capture raw bytes before any parser.
* **Encoding drift** — reading the body as latin-1 and re-encoding as UTF-8 changes bytes. Keep bytes as-is until after verification.
* **Non-constant-time compare** — `==` opens a timing side-channel; use your language's timing-safe comparison.
* **Clock drift** — a host clock skewed by more than 5 minutes rejects everything. Keep NTP enabled.

***

### Delivery & Reliability

* **Retries:** non-2xx responses (and timeouts) are retried up to **3 total attempts**, spaced about **1 minute apart**.
* **Timeout:** your endpoint has **10 seconds** to respond. Return a `2xx` immediately and process asynchronously — long-running handlers get cut off and retried, producing duplicates.
* **Signatures:** deliveries carry an `X-Inyo-Signature` HMAC-SHA256 header when a signing secret is configured for your subscription (see [Verifying Signatures](#verifying-signatures-hmac-sha256)); otherwise they are unsigned. For unsigned subscriptions especially, treat the payload as a *hint*: fetch the authoritative state via `GET /fx/transactions/{id}` before taking business-critical action.
* **Duplicates:** retries can deliver the same event more than once (there is no delivery-id header). Key your handler on `(transactionId, newStatus)` (or an equivalent tuple) and treat repeats as no-ops.
* **Ordering:** not guaranteed. Two events for the same transaction may arrive out of order. If sequence matters, compare the incoming `oldStatus` with the state you have stored — if they don't match, ignore the delivery and let a later delivery (or a poll) reconcile.

***

### All Endpoints

| Operation | Method | Endpoint |
| --------- | ------ | -------- |
| Register webhook | `POST` | `/organizations/{tenant}/webhooks` |
| List webhooks | `GET` | `/organizations/{tenant}/webhooks` |
| Delete webhook | `DELETE` | `/organizations/{tenant}/webhooks/{webhookId}` |
| Generate / rotate signing secret | `POST` | `/organizations/{tenant}/webhooks/{webhookId}/secret` |
| Disable signing | `DELETE` | `/organizations/{tenant}/webhooks/{webhookId}/secret` |
