Webhooks and the event stream
There are two ways to hear from us without asking:
- Webhooks — signed
POSTrequests 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:
| Event | Sent | data |
|---|---|---|
kyb.updated | Whenever 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.received | Money 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
POST /v1/webhooks
Content-Type: application/json
{
"url": "https://example.com/hooks/cashxchain",
"description": "Production receiver",
"events": ["kyb.updated"]
}
201 Created:
{
"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"
}
secretis shown once. It signs every delivery to this endpoint. Store it with your other secrets.eventsempty or absent means every event in the catalogue.urlmust behttps(the sandbox also acceptshttp), 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.
| Route | Does |
|---|---|
GET /v1/webhooks | List 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-secret | Replace 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
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.
| Field | Meaning |
|---|---|
id | The event id, also in X-Cxc-Event-Id. The same on every retry — de-duplicate on it. |
type | The event name, also in X-Cxc-Event. |
created | When the event happened. The same on every retry. |
data | The 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 includingwhsec_, 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
tmore than 300 seconds from your clock.tis 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 (Node)
- Python
- Shell
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').
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_cxc_signature(raw_body: bytes, header: str, secret: str, now: int | None = None) -> bool:
"""raw_body: the request body exactly as received, before JSON parsing."""
parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
try:
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
return False
now = int(time.time()) if now is None else now
if abs(now - t) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
With Flask, pass request.get_data() and request.headers.get("X-Cxc-Signature").
# Recompute v1 for a captured delivery: body.json holds the raw body.
printf '%s.' "$T" | cat - body.json | openssl dgst -sha256 -hmac "$WHSEC" -r | cut -d' ' -f1
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 response | We |
|---|---|
2xx | Mark it delivered |
408, 429, 5xx, a timeout, a connection or DNS failure, or a redirect | Retry |
Any other 4xx | Stop — 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:
POST /v1/notifications/ticketwith a dashboard session or a master key (secret keys get401). The response is{"expires_in": 900}and sets anHttpOnlycookie,cxc_sse, scoped to/v1/notifications, valid for 15 minutes.- Open
GET /v1/notificationswith 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:
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_okis therefore alwaystrue; 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 SSEid:, so a reconnect cannot resume automatically — reconnect withsinceand de-duplicate onid. ?event_type=filters.?partner=is refused with400 bad_request: the rail is not named, here or anywhere else in the API.- A
keepalivecomment 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.