Quickstart · self-hosted · non-custodial

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.

Bitcoin USDC on Base USDT on Base MIT

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.

No per-transaction fee. ZettaPay takes nothing from a payment. The customer pays only the network's own transaction fee. Self-hosting has no fees and no limits; Cloud is a flat subscription — see pricing.

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
Bitcoin
1 / 3 / 6 confirmations
below 0.001 BTC / below 0.01 BTC / 0.01 BTC and above
USDC · USDT on Base
Read over Base RPC
fixed-address mode needs at least 2 RPCs to agree
Invoice lifetime
1 hour by default
then the invoice becomes expired

Prerequisites

  • › 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 the 0x address your wallet shows under "Receive".
  • › An HTTPS endpoint on your backend to receive webhooks. Plain http://localhost is accepted for development only.
Public material only. The listener refuses private keys (xprv / zprv). It only ever needs an xpub or a receive address — it can watch, but it can never move your money.
1

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.

2

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

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
Production guard. With NODE_ENV=production and no ZETTAPAY_API_KEY, the listener refuses to start — an open POST /invoice would let anyone create invoices.
xpub mode vs fixed-address mode. With an xpub every invoice gets its own address — the most private option, and the recommended one. In fixed-address mode all invoices share one address and each invoice is identified by a small nonce in the low decimals of the amount (for example 29.000042), so the customer must send the exact amount shown.
3

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"}'

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"
}
Keep the link to your order yourself. Save the returned invoice_id on your order record when you create the invoice — the webhook identifies the payment by that id.
4

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.

Customer side
Scans the QR or copies the address. Pays. Done.
Your side
Wait for the webhook. Poll 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.

5

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');
  },
);
Replay & idempotency. Reject deliveries whose timestamp is more than five minutes off. A failed delivery is retried (up to 10 attempts) with the same X-ZettaPay-Event-Id, so de-duplicate on that header or on invoice_id. Full spec: webhook reference.
Test it without a backend. 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

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.

Early access, self-serve. Signup is identity-free: an email, a shop name and your public xpub or address. Create an account at /app, copy the API key (shown once) and use it to sign in to the dashboard. You start on the free plan; plans are on /pricing.

What's next

API reference (excerpt)

MethodPathPurpose
POST/invoiceCreate an invoice; returns the receive address and qr_uri.
GET/invoice/:idInvoice status: pending, partial, confirmed, expired, failed.
GET/healthLiveness 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.