Reference · webhooks

Webhook reference.

Signed webhooks for on-chain payments. HMAC-SHA256 over the raw body, a millisecond timestamp for replay protection, at-least-once delivery with up to 10 attempts, and a stable X-ZettaPay-Event-Id for de-duplication.

Overview

When the listener sees a payment that matches an open invoice — by receive address and amount — and it reaches the required confirmations, it POSTs a signed JSON event to your webhook URL. The payload tells you which invoice was paid, on which chain, in which asset, and — where a single transaction identifies the payment — the tx_hash. You verify the signature, look up your order by invoice_id, mark it paid, and respond 2xx.

The contract is identical for the self-hosted listener and for Cloud. On the self-hosted listener the URL and secret are the MERCHANT_WEBHOOK_URL and MERCHANT_WEBHOOK_SECRET environment variables; zettapay-listener init generates the secret (whsec_…) and prints it once.

Delivery semantics

  • › Transport. HTTPS only. The single exception is http://localhost / 127.0.0.1, allowed for development; the listener refuses to start with any other plain-HTTP URL.
  • › Method. POST with Content-Type: application/json.
  • › Success. Any 2xx response within 10 s is treated as acknowledged.
  • › Failure. A non-2xx response or a timeout schedules a retry (see retries).
  • › At least once. The same event can arrive more than once — de-duplicate (see idempotency).

Headers

HeaderValue
X-ZettaPay-Signature Hex-encoded HMAC-SHA256 of the raw request body, keyed by your webhook secret.
X-ZettaPay-Timestamp Unix epoch milliseconds at the moment of this delivery attempt. Reject if more than 5 minutes off.
X-ZettaPay-Event-Id Id of the event. The same on every retry of that event — your de-duplication key.
X-ZettaPay-Attempt Integer, 1-based. First delivery is 1; retries increment.
Content-Type application/json

Body schema

A flat JSON object. The first eight fields are common to every invoice.confirmed event; a few extra fields depend on how the payment was detected.

{
  "event":         "invoice.confirmed",
  "invoice_id":    "inv_...",
  "merchant_id":   "mer_...",
  "chain":         "btc",
  "asset":         "BTC",
  "amount":        "0.0005",
  "address":       "bc1q...",
  "tx_hash":       "...",
  "confirmations": 3,
  "confirmed_at":  "2026-06-01T12:01:00.000Z"
}

Event types

EventFired when
invoice.confirmed The payment for an invoice reached the required confirmations. The signal you should act on.
payment.orphan Fixed-address mode only. A transfer reached your fixed address but matches no open invoice — the amount was not exact, or the invoice had expired. It carries chain, asset, token_address, address, tx_hash, value and detected_at, and no invoice_id. The funds are in your wallet; no invoice is marked paid. Handle it manually.

There is no webhook for expiry. To learn that an invoice expired unpaid, read GET /invoice/:id.

Confirmations

invoice.confirmed is sent only once the payment is considered final enough for its size.

ChainRule
btcTiered by invoice amount: 1 confirmation below 0.001 BTC, 3 below 0.01 BTC, 6 from 0.01 BTC up.
base · xpub modeThe invoice's own address is read over Base RPC until it holds the invoiced amount.
base · fixed addressThe transfer must match an open invoice's amount exactly and be reported by at least two independent RPCs with the required confirmations. With a single source the payment stays pending.

Bitcoin is watched through mempool.space and Base through public RPC by default. Self-hosters can point BASE_RPC_URL at their own node.

Signature scheme

The signature covers the request body, byte for byte:

signature = hex( HMAC_SHA256( key = webhook_secret, message = raw_body ) )
  • › Use the raw bytes you received. Parsing the JSON and serializing it again changes the bytes and breaks the signature.
  • › Compare in constant time.
  • › The timestamp is a separate header and is not part of the signed message — check it as well (see replay protection).

Verify examples

Standard library only — no package needed.

import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const SECRET = process.env.MERCHANT_WEBHOOK_SECRET;
const TOLERANCE_MS = 5 * 60 * 1000;

function verify(rawBody, sig, ts) {
  if (!sig || !ts) return false;
  if (Math.abs(Date.now() - Number(ts)) > TOLERANCE_MS) return false;
  const expected = createHmac('sha256', SECRET).update(rawBody).digest();
  const given = Buffer.from(sig, 'hex');
  return given.length === expected.length && timingSafeEqual(given, expected);
}

const app = express();
const seen = new Set();   // use your database in production

app.post(
  '/webhooks/zettapay',
  express.raw({ type: 'application/json' }),   // raw Buffer, not parsed JSON
  async (req, res) => {
    const ok = verify(
      req.body,
      req.header('X-ZettaPay-Signature'),
      req.header('X-ZettaPay-Timestamp'),
    );
    if (!ok) return res.status(401).send('invalid signature');

    const eventId = req.header('X-ZettaPay-Event-Id');
    if (seen.has(eventId)) return res.status(200).send('duplicate');

    const event = JSON.parse(req.body.toString('utf8'));
    if (event.event === 'invoice.confirmed') {
      await markOrderPaid(event.invoice_id, event.tx_hash);   // your own function
    }
    seen.add(eventId);
    res.status(200).send('ok');
  },
);

Replay protection

X-ZettaPay-Timestamp is set fresh on every delivery attempt, in unix milliseconds. Reject a request whose timestamp is more than five minutes away from your server's clock, so a captured request cannot be replayed later.

Milliseconds, not seconds. Compare against Date.now(), or multiply a seconds clock by 1000. Keep your server clock in sync (NTP).

Idempotency

Delivery is at least once: if your endpoint processed an event but the acknowledgement was lost, the event comes again. Every retry of an event carries the same X-ZettaPay-Event-Id, so store the ids you have processed and answer 200 to a repeat without doing the work twice. A unique constraint on invoice_id in your orders table gives the same guarantee for invoice.confirmed.

If you suspect a missed delivery, reconcile by reading GET /invoice/:id — it returns the current status and tx_hash.

Retry policy

A non-2xx response, a network error or a 10 s timeout schedules the next attempt on a fixed curve, up to 10 attempts in total:

After failed attemptWait before the next one
11 s
25 s
330 s
42 min
510 min
630 min
71 h
83 h
912 h
10 (final)no further attempts

After the tenth failed attempt the event is no longer delivered. The invoice itself is unaffected — read its state with GET /invoice/:id.

Testing locally

@zettapay/receiver is a small local webhook receiver. It performs the same checks your endpoint must make — signature and replay window — and prints every delivery. Point the listener at it, or send it a hand-signed event.

# Listens on http://127.0.0.1:9876/webhook
npx @zettapay/receiver listen --port 9876 --secret whsec_xxxxxxxxxxxx --pretty

A valid request gets 200 {"ok": true, ...}; a bad signature gets 401 invalid_signature; a timestamp older than five minutes gets 401 timestamp_too_old. To test your real endpoint from another machine, expose it over HTTPS with a tunnel such as ngrok or cloudflared and use that URL as MERCHANT_WEBHOOK_URL.