APIWebhooks
Webhooks
Signed event deliveries for partners: events, payload, signature check and retries.
Get a signed POST when a payment lands, a budget is returned or an alarm fires. Webhooks are delivered to partners for the nodes they answer for. The signing secret comes with your partner credentials.
The decision.* events cover Cardano wallets: decision.landed and decision.released come from the Cardano watcher, and decision.alarm from POST /v1/cosign. Guarded Safes on EVM chains send no decision events.
Events
| Event | data |
|---|---|
decision.landed | decisionId, walletId, nodeId, network, txHash, slot |
decision.released | decisionId, walletId, nodeId, network, txHash, invalidHereafterSlot |
decision.alarm | decisionId, walletId, nodeId, network, code, txBodyHash |
wallet.state_changed | walletId, nodeId, state (paused, resumed, revoked, restored), reason |
signing.state_changed | scope, stopped, reason |
quorum.state_changed | available, reachable, threshold |
webhook.test | partnerId, message |
{
"event_type": "decision.landed",
"service_name": "exchain-cosign",
"timestamp": "2026-11-05T10:04:11Z",
"webhook_id": "whd_3f2a9c…",
"data": { "decisionId": "dec_01J9XK3M4N5P6Q7R8S9T0V1W2X", "walletId": "wal_01J9XK3M4N5P6Q7R8S9T0V1W2Z",
"nodeId": "node_1", "network": "preprod", "txHash": "…", "slot": 138150155 }
}Verify the signature
Every delivery carries these headers:
| Header | Value |
|---|---|
X-Exchain-Event | The event type |
X-Exchain-Delivery | Equal to webhook_id |
X-Exchain-Timestamp | Unix seconds |
X-Exchain-Signature | v1=<hex> |
The signature is HMAC-SHA256 over "<timestamp>.<raw body>", keyed with the hex-decoded signing secret.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyExchainWebhook(rawBody: string, headers: Headers, secretHex: string) {
const ts = headers.get('x-exchain-timestamp') ?? '';
const sig = (headers.get('x-exchain-signature') ?? '').replace(/^v1=/, '');
const expected = createHmac('sha256', Buffer.from(secretHex, 'hex')).update(`${ts}.${rawBody}`).digest();
const given = Buffer.from(sig, 'hex');
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
return fresh && given.length === expected.length && timingSafeEqual(given, expected);
}Delivery
- Any
2xxacknowledges. - Failed deliveries are retried after 10 s, 30 s, 1 min, 5 min, 15 min, 1 h and 3 h (8 attempts in total), then marked failed.
- Deliveries can repeat. Deduplicate on
webhook_id.