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

TypeFires whendata payload
transaction.createdA new transaction is created{ object } — the transaction
transaction.updatedA 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

  1. Register an HTTPS URL from the dashboard. We generate an Ed25519 keypair server-side and expose the public_key in the dashboard — use it to verify signatures.
  2. Receive POST requests to your URL with an event payload.
  3. Verify the signature using the webhook's public key.
  4. Respond with 2xx within 20 seconds. Non-2xx or timeout triggers retries with exponential backoff.

URL requirements

  • Must be HTTPS
  • Must have a domain-style hostname (localhost and bare single-label hosts are rejected)

These are enforced at creation time.

Verifying the signature

Each delivery carries three headers:

  • webhook-id — unique event id
  • webhook-timestamp — unix seconds when we signed
  • webhook-signaturev1a,<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:

  1. Timestamp window — reject any delivery whose signed webhook-timestamp is more than ~5 minutes old. Because the timestamp is signed, it can't be altered without invalidating the signature.
  2. 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 same webhook-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.