Webhooks
Signed event deliveries to your server when state changes.
Webhooks push events to your server when a transaction is created or changes — a swap settles, a withdrawal fails. They remove the need to poll.
Event types
| Type | Fires when | data payload |
|---|---|---|
transaction.created | A new transaction is created | { object } — the transaction |
transaction.updated | A transaction changes (e.g. status) | { object, previous_attributes } — the transaction plus the fields that changed, with their previous values |
The delivery body is { "type": "...", "timestamp": <unix-seconds>, "data": { ... } }. The event id is carried in the webhook-id header, not the body. To detect settlement, watch for transaction.updated where data.object.status is a terminal state (COMPLETED, FAILED, CANCELLED).
Lifecycle
- Register an HTTPS URL from the dashboard. We generate an Ed25519 keypair server-side and expose the
public_keyin the dashboard — use it to verify signatures. - Receive POST requests to your URL with an event payload.
- Verify the signature using the webhook's public key.
- Respond with
2xxwithin 20 seconds. Non-2xx or timeout triggers retries with exponential backoff.
URL requirements
- Must be HTTPS
- Must have a domain-style hostname (
localhostand bare single-label hosts are rejected)
These are enforced at creation time.
Verifying the signature
Each delivery carries three headers:
webhook-id— unique event idwebhook-timestamp— unix seconds when we signedwebhook-signature—v1a,<base64>Ed25519 signature
The signature covers the string ${webhook-id}.${webhook-timestamp}.${rawBody}. Verify with the webhook's public key (visible in the dashboard at any time):
import crypto from "crypto";
function verify(rawBody: Buffer, headers: Record<string, string>, publicKeyPem: string): boolean {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const sigHeader = headers["webhook-signature"];
const [scheme, b64] = sigHeader.split(",");
if (scheme !== "v1a") return false;
// Replay protection: reject deliveries older than 5 minutes. The check is
// one-directional — a small forward allowance covers clock skew, but a
// future-dated timestamp shouldn't otherwise pass. The timestamp is part of
// the signed string, so an attacker can't rewind it without breaking the
// signature.
const now = Math.floor(Date.now() / 1000);
const ts = Number(timestamp);
if (!Number.isFinite(ts) || now - ts > 300 || ts - now > 5) return false;
const signed = Buffer.from(`${id}.${timestamp}.${rawBody.toString("utf8")}`);
const key = crypto.createPublicKey(publicKeyPem);
return crypto.verify(null, signed, key, Buffer.from(b64, "base64"));
}Reject any request that fails verification — do not act on it.
Replay protection
The signature alone doesn't stop a captured delivery from being re-sent. Two checks close that gap, both shown above:
- Timestamp window — reject any delivery whose signed
webhook-timestampis more than ~5 minutes old. Because the timestamp is signed, it can't be altered without invalidating the signature. - Deduplicate on
webhook-id— record processed ids and no-op on repeats, so a replay inside the window is still only handled once. Retries reuse the samewebhook-id, so this doubles as retry-idempotency.
Managing webhooks
Webhooks are managed from the dashboard (Settings → Dev space) — there are no API-key-authenticated endpoints for webhook management:
- Register a new URL
- View the public key at any time
- Pause / resume delivery
- Delete
The public key is always retrievable in the dashboard — there's no one-time secret to store.
Delivery logs
Every delivery (success or failure) is recorded and viewable in the dashboard for debugging.
See Webhook setup for an end-to-end walkthrough.