Skip to main content

Webhooks and the event stream

There are two ways to hear from us without asking:

  • Webhooks — signed POST requests to an HTTPS endpoint you register. This is the integration channel.
  • The event stream — a server-sent-events feed for the dashboard, opened with a session.

Read the next section before you design around either: no webhook reports anything about payouts today, and the event stream relays only our banking partner's raw notices, to a dashboard session. Track payouts by polling GET /v1/banking/payouts/{id} — see Payouts.


Events​

The catalogue is served at GET /v1/webhooks/events. It has two entries:

EventSentdata
kyb.updatedWhenever your account's verification status changes — above all when our banking partner approves your business. kyb_status is pending, active or suspended, the same value GET /v1/accounts/me returns. One event per account per status: if the partner's notification and our own status check both report the same approval, you receive it once. There is no rejection event: our partner reports approvals only, so an application it declines stays pending — poll the KYB referral.{"account_id": "…", "kyb_status": "active"}
payin.receivedMoney arrived on one of your accounts: by bank transfer at our banking partner, or — in the sandbox — a credit booked on a digital-asset account (test funds, a convert's USDC, a send by @handle, a swap's bought asset, a credit released when you open its asset) or a send-back's simulated EUR arrival on your sandbox EUR account. Sent once per pay-in, after it is booked; nothing in data names the partner. On a digital-asset credit, currency is the asset, rail the chain (solana, base, …) and payer_name null — except a send by @handle and a swap's bought asset, whose rail is internal (a send by @handle names its sender in payer_name). The sandbox's simulated bank produces no pay-ins of its own.{"payin_id": "…", "amount": "250.00", "currency": "EUR", "rail": "sepa", "payer_name": "…", "reference": "…", "booked_at": "…"}

There are no payout, beneficiary or conversion events. Subscribing to an event that is not in the catalogue is a 400 that lists the catalogue.


Register an endpoint​

HTTP
POST /v1/webhooks
Content-Type: application/json

{
"url": "https://example.com/hooks/cashxchain",
"description": "Production receiver",
"events": ["kyb.updated"]
}

201 Created:

JSON
{
"id": "e291f4b1-6db4-4a5f-832a-565b884c8fcf",
"object": "webhook.endpoint",
"url": "https://example.com/hooks/cashxchain",
"description": "Production receiver",
"events": ["kyb.updated"],
"status": "active",
"secret": "whsec_BFQPEJQPyvGRLvYX4ILfrl9gtG99ow3X",
"created_at": "2026-10-15T20:05:39Z"
}
  • secret is shown once. It signs every delivery to this endpoint. Store it with your other secrets.
  • events empty or absent means every event in the catalogue.
  • url must be https (the sandbox also accepts http), at most 2,000 characters, with no credentials in it. An IP address must be public, and the host is re-checked at every delivery — an endpoint that resolves to a private address is never called.
  • One registration per URL: a second one is 409 conflict, even if the first has been disabled. Delete the old one first.

Endpoints are managed with a secret key, a master key, a dashboard session, or a restricted key with accounts:write (reads need accounts:read). A deploy script can point your webhooks at a new URL without a person in a browser. A publishable key cannot hold accounts:read — only fx:read is publishable — so a key you ship to browsers can never list your endpoints or read the delivery log.

RouteDoes
GET /v1/webhooksList endpoints: status (active or disabled), disabled_reason, and success_rate over the last 100 finished deliveries
GET /v1/webhooks/deliveries?endpoint_id=&limit=The delivery log: event, event_id, status (pending, retrying, delivered, failed), attempts, http_status, latency_ms, last_error. limit 1–100, default 20.
DELETE /v1/webhooks/{id}Remove an endpoint. 204.
POST /v1/webhooks/{id}/rotate-secretReplace the secret. 200 with the new secret, shown once.

There is no route to read one endpoint, edit one, send a test event, or re-enable a disabled one.


What a delivery looks like​

HTTP
POST /hooks/cashxchain HTTP/1.1
Content-Type: application/json
User-Agent: CashXChain-Webhooks/1
X-Cxc-Event: kyb.updated
X-Cxc-Event-Id: evt_6f1c2b9e-0d1a-4c3e-9f7a-2b8d4e6a1c00:active
X-Cxc-Signature: t=1767225600,v1=1fcceb8ea0c45bef577e0bc3cf7b5df1e3834cb3183586b43b062840513f1a1a

{"created":"2026-10-15T20:00:00.418263412+00:00","data":{"account_id":"6f1c2b9e-0d1a-4c3e-9f7a-2b8d4e6a1c00","kyb_status":"active"},"id":"evt_6f1c2b9e-0d1a-4c3e-9f7a-2b8d4e6a1c00:active","object":"event","type":"kyb.updated"}

The body is compact JSON with its keys in alphabetical order. Do not rely on either — parse it — but do verify the signature against these exact bytes.

FieldMeaning
idThe event id, also in X-Cxc-Event-Id. The same on every retry — de-duplicate on it.
typeThe event name, also in X-Cxc-Event.
createdWhen the event happened. The same on every retry.
dataThe event's payload — see Events.

Verify the signature​

X-Cxc-Signature is t=<unix seconds>,v1=<signature>:

  • Algorithm: HMAC-SHA256.
  • Key: the endpoint's secret, the whole string including whsec_, as UTF-8 bytes.
  • Signed message: the decimal t, a full stop, then the raw request body exactly as received — "{t}.{body}". Verify before you parse the JSON; re-serialising it changes the bytes.
  • Encoding: lowercase hex.
  • Tolerance: reject a t more than 300 seconds from your clock. t is inside the signed message, so it cannot be moved without breaking the signature, and it is set fresh on every delivery attempt.
  • Parts may appear in either order; ignore any part you do not recognise, so a future v2= does not break you.
TypeScript
import crypto from 'node:crypto';

const TOLERANCE_SECONDS = 300;

// rawBody: the request body exactly as received (a Buffer), before any JSON parsing.
export function verifyCxcSignature(
rawBody: Buffer,
header: string | undefined,
secret: string,
nowSeconds = Math.floor(Date.now() / 1000),
): boolean {
const parts = Object.fromEntries(
String(header ?? '')
.split(',')
.map((p) => p.trim().split('='))
.filter((kv) => kv.length === 2),
);
const t = Number.parseInt(parts.t, 10);
const v1 = parts.v1;
if (!Number.isInteger(t) || typeof v1 !== 'string') return false;
if (Math.abs(nowSeconds - t) > TOLERANCE_SECONDS) return false;

const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.`)
.update(rawBody)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(v1, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

With Express, take the raw body for this route — app.post('/hooks/cashxchain', express.raw({ type: 'application/json' }), handler) — and pass req.body (a Buffer) and req.get('X-Cxc-Signature').

Test your verifier against this example: the secret whsec_7QhT2mF9xKpR4vN8wL3dB6sY1aZ5cE0g, t=1767225600 and the body in What a delivery looks like give v1=1fcceb8ea0c45bef577e0bc3cf7b5df1e3834cb3183586b43b062840513f1a1a. All three snippets above produce it. Pass nowSeconds/now as 1767225600 when you test, or the timestamp check will reject the example.


Respond, and what happens if you don't​

Answer with any 2xx within 10 seconds, then do the work. A slow handler is a failed delivery.

Your responseWe
2xxMark it delivered
408, 429, 5xx, a timeout, a connection or DNS failure, or a redirectRetry
Any other 4xxStop — the delivery is failed, not retried

Redirects are never followed. A delivery is attempted up to 14 times over about 26 hours, waiting 30 seconds, then 1, 2, 4, 8, 16, 32 and 64 minutes, then about 2 and 4 hours, then 6 hours between the last attempts. Deliveries may arrive out of order and, after a retry, more than once: de-duplicate on the event id.

Disabling. When the last 20 deliveries to an endpoint that reached a final state all failed, the endpoint is disabled: status: "disabled" with a disabled_reason. Nothing more is sent to it — including deliveries still waiting to retry — and there is no route to re-enable it. Delete it and register the URL again; you get a new secret.

Rotating the secret takes effect immediately. There is no period in which both secrets are valid: deliveries already waiting for a retry are signed with the new secret on their next attempt. Have your receiver accept both secrets while you deploy the new one, then drop the old.


The event stream​

GET /v1/notifications is a server-sent-events feed of the notifications our banking partner sends us about your account. It exists for the dashboard; it is not a substitute for webhooks.

Opening it. A browser EventSource cannot send an Authorization header, so the stream authenticates with a short-lived cookie:

  1. POST /v1/notifications/ticket with a dashboard session or a master key (secret keys get 401). The response is {"expires_in": 900} and sets an HttpOnly cookie, cxc_sse, scoped to /v1/notifications, valid for 15 minutes.
  2. Open GET /v1/notifications with that cookie — new EventSource(url, { withCredentials: true }) from an allowed origin.

Mint a new ticket before reconnecting after it expires. Without a valid cookie the stream answers 401.

What it carries. Each message is an SSE event named partner_event:

Plain text
event: partner_event
data: {"id":48213,"env":"production","event_id":"…","event_type":"payment_status_changed","received_at":"2026-10-15T20:01:02Z","signature_ok":true,"payload":{…}}

event_type is one of payment_inbound, payment_status_changed, account_status_changed, kyb_status_changed, balance_updated, or the partner's own name for a notification we do not classify. payload is the partner's raw notification body — its format is theirs and is not part of this API's contract.

  • A notification whose signature failed verification is kept for audit but never streamed: anyone can post to a webhook receiver, and the dashboard must not relay what they sent as the partner's word. signature_ok is therefore always true; the field stays for compatibility — ignore anything else all the same.
  • It starts from the moment you connect. Pass ?since=<RFC 3339 time> to include earlier notifications. Messages have no SSE id:, so a reconnect cannot resume automatically — reconnect with since and de-duplicate on id.
  • ?event_type= filters. ?partner= is refused with 400 bad_request: the rail is not named, here or anywhere else in the API.
  • A keepalive comment is sent every 15 seconds.

What it does not carry. None of the webhook events above. Nothing from the sandbox rail — sandbox payouts, conversions and test funds are booked directly and generate no partner notifications, so in the sandbox the stream is silent for everything you do through the API. No beneficiary checks, FX quotes, lockdown changes, billing or Collect events. Do not build a payout state machine on it.