Inyo

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

EventDescription
TransactionStatusChangedThe 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.
TransactionComplianceStatusChangedEventThe transaction's compliance status changed (e.g., Pending β†’ Approved). Fires only when the compliance value actually differs from its prior value.
TransactionPayoutStatusChangedThe payment gateway reported a status change on the payout leg (Inyo β†’ recipient). Near-real-time gateway signal, faster than the state-machine event.
DocumentUpdatedEventsA document was uploaded, or its verification completed (approved/rejected).
AgentUpdatedEventsAn 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)

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"
  ]
}'
FieldTypeRequiredDescription
urlstringYesHTTPS endpoint to receive deliveries (max 2048 chars)
eventsstring[]YesAt 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"
}
FieldDescription
signingSecretThe 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.
secretFingerprintFirst 8 hex characters of sha256(secret) β€” safe to display; use it to confirm which secret is active
signatureVersionSignature scheme version, currently v1
secretRotatedAtWhen 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"]
}
FieldTypeNotes
eventstringEchoes your registered spelling
transactionIduuidInyo transaction UUID
externalTransactionIdstring | nullYour externalId from transaction create; null if you didn't send one
tenantIdstringYour tenant slug
oldStatusstring | nullPrevious status; null on the very first transition
newStatusstringNew status β€” see values below
newStatusMessagesstring[]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": []
}
FieldTypeNotes
oldComplianceStatusstring | nullTitle-cased; null on the first transition
newComplianceStatusstringTitle-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"]
}
FieldTypeNotes
oldPayoutStatusstring | nullRaw gateway status
newPayoutStatusstringRaw gateway status β€” see below
gatewayPaymentIdstring | nullThe gateway's own payment reference
gmtReceiptstring | nullPopulated when the payout routed through the MSB network (most cases)
amountobject | 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.

{
  "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
}
FieldTypeNotes
iduuidThe document upload UUID
entityIduuidThe participant the document belongs to
entityTypestringParticipant
documentTypestringPASSPORT, DRIVER_LICENSE, SSN, PROOF_OF_FUNDS, etc. β€” same taxonomy as the upload endpoints
verificationStatusstringPENDING (uploaded, awaiting verification), VERIFIED, or REJECTED. Compare case-insensitively β€” the initial upload event may deliver Pending.
versionnumberSchema 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 spelling agentUpdatedEvents keep 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
ComponentMeaning
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)

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:

  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); 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

OperationMethodEndpoint
Register webhookPOST/organizations/{tenant}/webhooks
List webhooksGET/organizations/{tenant}/webhooks
Delete webhookDELETE/organizations/{tenant}/webhooks/{webhookId}
Generate / rotate signing secretPOST/organizations/{tenant}/webhooks/{webhookId}/secret
Disable signingDELETE/organizations/{tenant}/webhooks/{webhookId}/secret