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.
POSTwithContent-Type: application/json. - › Success. Any
2xxresponse 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
| Header | Value |
|---|---|
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": "invoice.confirmed",
"invoice_id": "inv_...",
"merchant_id": "mer_...",
"chain": "base",
"asset": "USDC",
"amount": "42000000", // token base units (6 decimals)
"address": "0x...",
"tx_hash": null, // detected by balance — no single transaction
"balance": "42000000", // balance observed on the invoice address
"confirmed_at": "2026-06-01T12:01:00.000Z"
}
{
"event": "invoice.confirmed",
"invoice_id": "inv_...",
"merchant_id": "mer_...",
"chain": "base",
"asset": "USDT",
"token_address": "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2",
"amount": "29000042", // token base units, nonce included
"address": "0xC4a7...",
"tx_hash": "...",
"value": "29000042", // amount actually transferred
"metadata": { "mode": "fixed-address", "ref": "inv_...", "nonce": 42, "asset": "USDT" },
"confirmed_at": "2026-06-01T12:01:00.000Z"
}
event string · "invoice.confirmed" | "payment.orphan" invoice_id string · id returned when the invoice was created merchant_id string · the merchant the invoice belongs to chain string · "btc" | "base" asset string · "BTC" | "USDC" | "USDT" amount string · invoice amount: decimal BTC, or token base units on Base address string · receive address that was paid tx_hash string · on-chain transaction hash (null in Base xpub mode) confirmed_at string · ISO 8601 timestamp confirmations number · Bitcoin only balance string · Base xpub mode only, token base units token_address string · Base fixed-address mode only, ERC-20 contract value string · Base fixed-address mode only, token base units metadata object · Base fixed-address mode only: mode, ref, nonce, asset
Event types
| Event | Fired 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.
| Chain | Rule |
|---|---|
btc | Tiered by invoice amount: 1 confirmation below 0.001 BTC, 3 below 0.01 BTC, 6 from 0.01 BTC up. |
base · xpub mode | The invoice's own address is read over Base RPC until it holds the invoiced amount. |
base · fixed address | The 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'); }, );
import hashlib, hmac, json, os, time from flask import Flask, request, abort app = Flask(__name__) SECRET = os.environ["MERCHANT_WEBHOOK_SECRET"].encode() TOLERANCE_MS = 5 * 60 * 1000 def verify(raw: bytes, sig: str, ts: str) -> bool: if not sig or not ts or not ts.isdigit(): return False if abs(time.time() * 1000 - int(ts)) > TOLERANCE_MS: return False expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, sig.lower()) @app.post("/webhooks/zettapay") def on_event(): raw = request.get_data() # raw bytes, NOT request.json if not verify(raw, request.headers.get("X-ZettaPay-Signature", ""), request.headers.get("X-ZettaPay-Timestamp", "")): abort(401) event = json.loads(raw) if event["event"] == "invoice.confirmed": mark_order_paid(event["invoice_id"], event["tx_hash"]) # your own function return "ok", 200
$secret = getenv('MERCHANT_WEBHOOK_SECRET'); $raw = file_get_contents('php://input'); // raw body $sig = $_SERVER['HTTP_X_ZETTAPAY_SIGNATURE'] ?? ''; $ts = $_SERVER['HTTP_X_ZETTAPAY_TIMESTAMP'] ?? ''; $fresh = ctype_digit($ts) && abs(microtime(true) * 1000 - (float) $ts) <= 300000; $expected = hash_hmac('sha256', $raw, $secret); if (!$fresh || !hash_equals($expected, strtolower($sig))) { http_response_code(401); exit('invalid signature'); } $event = json_decode($raw, true); if ($event['event'] === 'invoice.confirmed') { mark_order_paid($event['invoice_id'], $event['tx_hash']); // your own function } echo '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.
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 attempt | Wait before the next one |
|---|---|
1 | 1 s |
2 | 5 s |
3 | 30 s |
4 | 2 min |
5 | 10 min |
6 | 30 min |
7 | 1 h |
8 | 3 h |
9 | 12 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
# .env of the listener — plain http is accepted for localhost only
MERCHANT_WEBHOOK_URL=http://127.0.0.1:9876/webhook
MERCHANT_WEBHOOK_SECRET=whsec_xxxxxxxxxxxx
# Sign a body by hand and post it to the receiver (or to your own endpoint). WEBHOOK_SECRET=whsec_xxxxxxxxxxxx TS=$(($(date +%s) * 1000)) BODY='{"event":"invoice.confirmed","invoice_id":"inv_test","chain":"btc","asset":"BTC","amount":"0.0005","tx_hash":"test"}' SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}') curl -X POST http://127.0.0.1:9876/webhook \ -H "Content-Type: application/json" \ -H "X-ZettaPay-Signature: $SIG" \ -H "X-ZettaPay-Timestamp: $TS" \ -d "$BODY"
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.