Accept your first crypto payment on your own server.
Install @zettapay/listener, give it your
public key material (a BIP-84 xpub for Bitcoin; an xpub or a fixed receive address for Base),
create an invoice over HTTP and show the customer the address. The listener watches the chain and
posts a signed webhook to your backend when the payment confirms. It never sees a private key and
never holds the funds.
Overview
ZettaPay is non-custodial: the customer's funds move on-chain directly from their wallet to yours. What the listener does is the part that is tedious to build yourself — it answers "did the customer pay?" automatically. It derives a receive address per invoice from your xpub (or reuses one fixed address on Base), watches Bitcoin and Base for the matching payment, and POSTs an HMAC-signed webhook to your server once it confirms.
There are two ways to run it. This page covers the self-hosted listener, which is open source (MIT) and free. If you would rather not run a server, the same listener core is offered as a managed service — see Cloud.
How confirmation works
It is the webhook pattern you already know from card processors — only the source of truth is the blockchain itself instead of a processor's ledger.
┌──────────────────┐ ┌───────────────────┐ ┌────────────────────┐ ┌──────────────────┐ │ Customer wallet │ ──tx──▶ │ Blockchain │ ──────▶ │ ZettaPay listener │ ─POST─▶ │ Your webhook URL │ │ (any wallet) │ │ Bitcoin · Base │ │ on your server │ │ HTTPS · HMAC │ └──────────────────┘ └───────────────────┘ └────────────────────┘ └──────────────────┘ matches address + amount your code marks the order paid
expiredPrerequisites
- › Node.js 18.18 or newer on the machine that will run the listener.
- › For Bitcoin: the BIP-84 extended public key (xpub / zpub) of a wallet you control — Sparrow, a hardware wallet, any HD wallet can export it.
- › For USDC / USDT on Base (optional): either an EVM account xpub (
m/44'/60'/0'), or simply the0xaddress your wallet shows under "Receive". - › An HTTPS endpoint on your backend to receive webhooks. Plain
http://localhostis accepted for development only.
Install the listener
One npm package. It installs the zettapay-listener
command.
npm install -g @zettapay/listener
# check it is on your PATH
zettapay-listener --version
Prefer containers? The package ships a Dockerfile and a compose example — see INSTALL-docker.md.
Configure · your public key and webhook URL
zettapay-listener init is a setup wizard. Run it with no
arguments to be asked each question, or pass flags for a non-interactive setup. It writes a
.env file in the current directory, generates your
webhook secret and prints it once.
zettapay-listener init
zettapay-listener init \ --xpub <BIP-84 zpub for Bitcoin> \ --xpub-evm <EVM account xpub, optional — enables USDC on Base> \ --webhook-url https://your-backend.example/webhooks/zettapay \ --storage json \ --force
Then open the generated .env and add an API key so that
only your backend can create invoices. If your Base wallet cannot export an xpub, use
fixed-address mode instead: one receive address for every invoice.
# written by `init` MERCHANT_XPUB=zpub... MERCHANT_WEBHOOK_URL=https://your-backend.example/webhooks/zettapay MERCHANT_WEBHOOK_SECRET=whsec_xxxxxxxxxxxx STORAGE=json HEALTH_PORT=8787 # add this — protects POST /invoice (any high-entropy secret you choose) ZETTAPAY_API_KEY=zp_live_xxxxxxxx # optional — Base, fixed-address mode (wallets without xpub export) MERCHANT_EVM_ADDRESS=0xYOUR_RECEIVE_ADDRESS MERCHANT_EVM_CHAINS=base MERCHANT_EVM_TOKENS=usdc,usdt
NODE_ENV=production
and no ZETTAPAY_API_KEY, the listener refuses to start —
an open POST /invoice would let anyone create invoices.
29.000042), so the customer must send
the exact amount shown.
Start · create an invoice
start runs the chain watcher, the HTTP API and the
webhook dispatcher on one port (8787 by default).
zettapay-listener verify-config # validates .env without starting zettapay-listener start # in another terminal curl http://localhost:8787/health
Your backend creates an invoice with one request. For Bitcoin pass
amount_sats; for Base pass
chain: "base" and
amount_usd.
curl -X POST http://localhost:8787/invoice \ -H "X-ZettaPay-Api-Key: $ZETTAPAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"amount_sats": 50000, "memo": "order 4711"}'
curl -X POST http://localhost:8787/invoice \ -H "X-ZettaPay-Api-Key: $ZETTAPAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"chain": "base", "amount_usd": 29}'
# USDT is served in fixed-address mode (MERCHANT_EVM_TOKENS must include usdt) curl -X POST http://localhost:8787/invoice \ -H "X-ZettaPay-Api-Key: $ZETTAPAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"chain": "base", "amount_usd": 29, "asset": "usdt"}'
// Any HTTP client works — this is plain fetch (Node 18+). const res = await fetch('http://localhost:8787/invoice', { method: 'POST', headers: { 'content-type': 'application/json', 'x-zettapay-api-key': process.env.ZETTAPAY_API_KEY, }, body: JSON.stringify({ chain: 'base', amount_usd: 29 }), }); const invoice = await res.json(); // Store invoice.invoice_id next to your order, then show the payer // invoice.receive_address and invoice.qr_uri.
The response (Bitcoin, abridged):
{
"invoice_id": "inv_...",
"chain": "btc",
"asset": "BTC",
"amount_btc": "0.0005",
"amount_sats": 50000,
"receive_address": "bc1q...",
"status": "pending",
"qr_uri": "bitcoin:bc1q...?amount=0.0005&label=order%204711",
"verify_url": "https://mempool.space/address/bc1q...",
"expires_at": "2026-06-01T12:00:00.000Z"
}
invoice_id on your order record when you create the
invoice — the webhook identifies the payment by that id.
Customer pays
Show the customer the receive_address, the exact
amount and a QR code of qr_uri (a BIP-21 URI for
Bitcoin, an EIP-681 URI for Base). They pay from whatever wallet they prefer — a hardware wallet, a
mobile wallet, an exchange withdrawal — anything that can send to a plain address.
Nobody connects a wallet to ZettaPay.
GET /invoice/:id to update the page.
The listener detects the inbound transaction, matches it to the invoice by receive address and amount,
and queues a signed webhook to MERCHANT_WEBHOOK_URL.
The checkout guide shows how to build the
payment screen.
Verify the webhook · mark the order paid
The listener POSTs a JSON body with an
X-ZettaPay-Signature header — the hex HMAC-SHA256 of
the raw request body, keyed by your
MERCHANT_WEBHOOK_SECRET — and an
X-ZettaPay-Timestamp header in unix milliseconds.
Verify both before you act.
{
"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"
}
import express from 'express'; import { createHmac, timingSafeEqual } from 'node:crypto'; const SECRET = process.env.MERCHANT_WEBHOOK_SECRET; function verify(rawBody, sig, ts) { if (!sig || !ts) return false; if (Math.abs(Date.now() - Number(ts)) > 5 * 60 * 1000) return false; // replay 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(); // IMPORTANT: keep the raw body — re-serialized JSON breaks the signature app.post( '/webhooks/zettapay', express.raw({ type: 'application/json' }), async (req, res) => { const ok = verify( req.body, // Buffer req.header('X-ZettaPay-Signature'), req.header('X-ZettaPay-Timestamp'), ); if (!ok) return res.status(401).send('invalid signature'); const event = JSON.parse(req.body.toString('utf8')); if (event.event === 'invoice.confirmed') { // your own function — make it idempotent by invoice_id await markOrderPaid(event.invoice_id, event.tx_hash); } 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() 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)) > 5 * 60 * 1000: # replay 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
X-ZettaPay-Event-Id, so de-duplicate on that header or on
invoice_id. Full spec:
webhook reference.
npx @zettapay/receiver listen --secret whsec_xxxxxxxxxxxx --pretty
starts a local receiver on port 9876 that verifies and prints every delivery. Point
MERCHANT_WEBHOOK_URL at
http://127.0.0.1:9876/webhook while developing.
AI agents · MCP
@zettapay/mcp is a Model Context Protocol server that
runs on your machine and talks only to your own listener. It gives an agent three tools —
create_invoice,
get_invoice_status and
list_supported_assets — so it can request a payment
and wait for it to confirm. The agent only ever receives a public receive address.
claude mcp add zettapay -- npx -y @zettapay/mcp
LISTENER_URL=http://localhost:8787 ZETTAPAY_API_KEY=zp_live_xxxxxxxx npx -y @zettapay/mcp
Details and the Claude Desktop config: /docs/api#mcp.
Prefer managed? ZettaPay Cloud
Cloud runs the same listener core as a multi-tenant service, so you do not operate a server. It is
just as non-custodial: you give it a public xpub or receive address, payments go directly to your
wallet, and it never holds keys or funds. The API mirrors the self-hosted one under
/api/v1, with the same
X-ZettaPay-Api-Key header and the same webhook format.
What's next
API reference (excerpt)
| Method | Path | Purpose |
|---|---|---|
POST | /invoice | Create an invoice; returns the receive address and qr_uri. |
GET | /invoice/:id | Invoice status: pending, partial, confirmed, expired, failed. |
GET | /health | Liveness and watcher state. |
POST /invoice is rate limited (30 requests per minute
per IP and 300 per minute overall by default; tune with
ZETTAPAY_RATE_LIMIT). Errors follow
{ "error": { "code", "message" } }.
See /docs/api for the full surface.