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, andTRANSACTIONSTATUSCHANGEDall subscribe to the same event. Theeventfield 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
Eventsuffix (TransactionComplianceStatusChangedEvent) β historical, kept for backwards compatibility.
Registering a Webhook
Endpoint: POST /organizations/{tenant}/webhooks
Authentication: Tenant-level (x-api-key)
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.
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):
{
"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 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)
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)
curl --request GET \
--url https://{FQDN}/organizations/$TENANT/webhooks \
--header "x-api-key: $API_KEY"
Response:
{
"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)
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.
{
"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 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.
{
"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.
{
"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 spellingdocumentUpdatedEventskeep working and keep receiving that spelling in the payload.
{
"event": "DocumentUpdatedEvents",
"id": "4ec66735-216b-4ab4-b1d7-00558baa6d85",
"entityId": "6588c7e7-3b1a-42ff-94ab-a834eda66640",
"entityType": "Participant",
"documentType": "DRIVER_LICENSE",
"verificationStatus": "VERIFIED",
"tenantId": "your-tenant-slug",
"version": 1
}
| Field | Type | Notes |
|---|---|---|
id | uuid | The document upload UUID |
entityId | uuid | The participant the document belongs to |
entityType | string | Participant |
documentType | string | PASSPORT, DRIVER_LICENSE, SSN, PROOF_OF_FUNDS, etc. β same taxonomy as the upload endpoints |
verificationStatus | string | PENDING (uploaded, awaiting verification), VERIFIED, or REJECTED. Compare case-insensitively β the initial upload event may deliver Pending. |
version | number | Schema version β always 1 today |
Typical lifecycle: 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.
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 spellingagentUpdatedEventskeep working and keep receiving that spelling in the payload.
{
"event": "AgentUpdatedEvents",
"id": "88fa9606-8345-454a-9669-19f13ccdae13",
"before": null,
"after": {
"id": "88fa9606-8345-454a-9669-19f13ccdae13",
"email": "[email protected]",
"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 thet=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
- Read the
X-Inyo-Signatureheader. Reject if absent. - Parse out
tand thev1component. Reject if either is missing or malformed. - Reject if
|now β t| > 300seconds (5-minute replay window; adjust for your clock-skew tolerance). - Build
signed_string = t + "." + raw_body. - Compute
expected = HMAC-SHA256(secret, signed_string), hex-encoded lowercase. - Compare
expectedagainst thev1value in constant time (crypto.timingSafeEqual,hmac.compare_digest,hash_equals) β never==. - Only after all checks pass, JSON-parse the body and act on the payload.
Node.js (Express)
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)
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:
- Temporarily accept deliveries that fail verification (log them instead of rejecting).
- Call
POST /organizations/{tenant}/webhooks/{webhookId}/secretand store the newsigningSecretfrom the response. - 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
2xximmediately and process asynchronously β long-running handlers get cut off and retried, producing duplicates. - Signatures: deliveries carry an
X-Inyo-SignatureHMAC-SHA256 header when a signing secret is configured for your subscription (see Verifying Signatures); otherwise they are unsigned. For unsigned subscriptions especially, treat the payload as a hint: fetch the authoritative state viaGET /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
oldStatuswith 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 |
