Authentication
Send your API key in the X-ZettaPay-Api-Key header.
On the self-hosted listener the key is whatever secret you put in
ZETTAPAY_API_KEY; it is compared in constant time and
protects POST /invoice. On Cloud the key is issued
when you create your account at /app and shown once — only its hash is stored. The same key signs you in to the dashboard.
X-ZettaPay-Api-Key: zp_live_xxxxxxxx
Content-Type: application/json
Self-hosted: if ZETTAPAY_API_KEY is unset, POST /invoice is open (development only) and the listener
refuses to start when NODE_ENV=production. GET /invoice/:id and GET /health need no key.
Chains & assets
| chain | Asset | Amount field | Receive address comes from |
btc (default) | BTC | amount_sats | Your BIP-84 xpub — one address per invoice. |
base | USDC | amount_usd | Your EVM account xpub (one address per invoice), or your fixed receive address. |
base + "asset": "usdt" | USDT | amount_usd | Your fixed receive address (fixed-address mode). |
In fixed-address mode every invoice shares one address and is
identified by a per-invoice nonce in the low decimals of the amount (for example
29.000042). The payer must send that exact amount; a
transfer that matches no open invoice is reported as a
payment.orphan webhook and never marks an invoice paid.
Fixed-address invoices expire after one hour.
Invoice statuses
| Status | Meaning |
pending | Created, waiting for a payment. |
partial | Reserved for a payment that does not yet satisfy the invoice. |
confirmed | Payment confirmed on-chain. tx_hash and paid_at are set and the webhook is queued. |
expired | Passed expires_at without a matching payment. |
failed | The invoice could not be completed. |
Errors
Errors return a non-2xx status and a stable JSON envelope. The
code field is machine-readable; message is human-readable
and may change. Never branch on message.
{
"error": {
"code": "invalid_amount",
"message": "amount_sats must be a positive integer"
}
}
| Status | Code | Meaning |
| 400 | bad_body | Body is not valid JSON or is larger than 16 KB. |
| 400 | invalid_amount | amount_sats is not a positive integer, or amount_usd is not a positive number. |
| 400 | unsupported_chain | chain is not one the API knows. |
| 400 | chain_disabled / base_disabled | The chain is not configured for this merchant. |
| 400 | asset_disabled | The token is not enabled (self-hosted: add it to MERCHANT_EVM_TOKENS). |
| 401 | unauthorized | Missing or invalid API key. |
| 402 | plan_limit_reached | Cloud only — the plan's monthly invoice cap is reached. |
| 404 | not_found | No such invoice or route. |
| 429 | rate_limited | Too many invoice requests — honor Retry-After. |
| 500 | create_failed / lookup_failed | The invoice could not be created or read. |
| 503 | capacity | Fixed-address nonce pool temporarily exhausted — retry shortly. |
Rate limits
Invoice creation is limited by a sliding window. When the limit is hit the API answers
429 rate_limited with a
Retry-After header in seconds.
| Surface | Scope | Default |
Self-hosted POST /invoice | Per client IP, plus a global cap | 30 / minute per IP, 300 / minute overall. Set ZETTAPAY_RATE_LIMIT to 30, 30,300 or off. |
Cloud POST /api/v1/invoice | Per API key | 60 / minute. |
Self-hosted listenerhttp://localhost:8787
POST
/invoice
Creates a pending invoice and returns the address the payer sends to. The listener starts watching
that address within about 30 seconds.
Request body
| Field | Type | | Description |
chain | string | optional | btc (default) or base. |
amount_sats | integer | btc | Amount in satoshis, positive. |
amount_usd | number | base | Amount in USD, positive. |
asset | string | optional | Base only: usdc (default) or usdt. Applies in fixed-address mode. |
memo | string | optional | Bitcoin only: label put in the BIP-21 URI, up to 200 characters. |
expires_in | integer | optional | Lifetime in seconds (Bitcoin and Base xpub mode). Default 3600. |
Examples
# Bitcoin
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", "expires_in": 3600}'
# USDC on Base
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": 42}'
# USDT on Base (fixed-address mode)
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": 42, "asset": "usdt"}'
Response · 201 · Bitcoin
{
"invoice_id": "inv_...",
"merchant_id": "mer_...",
"chain": "btc",
"asset": "BTC",
"amount_btc": "0.0005",
"amount_sats": 50000,
"receive_address": "bc1q...",
"child_index": 12,
"derivation_path": "m/84'/0'/0'/0/12",
"network": "mainnet",
"status": "pending",
"tx_hash": null,
"paid_at": null,
"expires_at": "2026-06-01T12:00:00.000Z",
"created_at": "2026-06-01T11:00:00.000Z",
"updated_at": "2026-06-01T11:00:00.000Z",
"qr_uri": "bitcoin:bc1q...?amount=0.0005&label=order%204711",
"verify_url": "https://mempool.space/address/bc1q..."
}
Response · 201 · Base, fixed-address mode
{
"invoice_id": "inv_...",
"chain": "base",
"asset": "USDT",
"mode": "fixed-address",
"nonce": 42,
"token_address": "0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2",
"receive_address": "0xC4a7...",
"amount_usd": 29,
"amount_usdc": "29.000042",
"amount_usdc_units": 29000042,
"status": "pending",
"expires_at": "...",
"qr_uri": "ethereum:0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2@8453/transfer?address=0xC4a7...&uint256=29000042"
}
amount_usdc / amount_usdc_units carry the stablecoin amount for both USDC and USDT (6 decimals).
In Base xpub mode the response has derivation_path and verify_url instead of mode / nonce / token_address.
GET
/invoice/:id
no key required
Returns the stored invoice: the same core fields as the create response, without the one-shot fields
(qr_uri, verify_url, derivation_path). A pending invoice past
its expires_at is flipped to expired on read. Use it to refresh a checkout
page or to reconcile after a missed webhook.
curl http://localhost:8787/invoice/inv_...
{
"invoice_id": "inv_...",
"chain": "btc",
"asset": "BTC",
"amount_btc": "0.0005",
"receive_address": "bc1q...",
"status": "confirmed",
"tx_hash": "...",
"paid_at": "2026-06-01T11:20:00.000Z",
"expires_at": "2026-06-01T12:00:00.000Z"
}
GET
/health
no key required
Liveness and watcher state — plug it into your monitoring. The
zettapay-listener healthcheck command probes it and exits 0 or 1.
{
"ok": true,
"ws_connected": true,
"subscribed_count": 17,
"last_event_at": "2026-06-01T11:59:00.000Z",
"last_block_height": 845123,
"uptime_s": 3600
}
Cloud/api/v1 · early access
Overview
Cloud is the same listener core run as a multi-tenant service. The API deliberately mirrors the
self-hosted one — same request bodies, same response fields, same webhook signature — so moving
between the two is a change of base URL. Every authenticated request is scoped to the merchant that
owns the API key. Cloud stores invoice metadata, your public xpub or address, and your webhook URL and
secret; it never receives a private key and is never in the flow of funds.
Cloud is self-serve: create an account at
/app, copy the API key (shown once) and
paste it there to sign in to the dashboard — no password, no wallet connection. A new account starts on
the Free plan; upgrades are paid from the dashboard, in crypto or by card. There is no per-transaction
fee; plans are a flat subscription by monthly invoice volume, listed on
/pricing. Cloud is in early access, so
features may change. In the examples below, $ZETTAPAY_URL stands for the origin of this
site — the Cloud API lives under /api/v1 on it.
POST
/api/v1/signup
public · throttled
Creates an account on the Free plan. This is what the form at
/app calls. It takes public material only:
an email, a shop name and at least one way to receive — a BIP-84 extended public key for Bitcoin
and/or, for Base, an extended public key or a fixed receive address. An extended private key
is refused and never stored.
| Field | Type | Notes |
email | string | Required. One account per email. |
shop_name | string | Required, 2–80 characters. Shown on hosted checkout. |
btc_xpub | string | Optional. Extended public key for Bitcoin. |
base_xpub | string | Optional. Extended public key for Base (one address per invoice). Wins over base_address when both are sent. |
base_address | string | Optional. Fixed 0x receive address for Base. |
webhook_url | string | Optional. https, default port, public host. |
curl -X POST $ZETTAPAY_URL/api/v1/signup \
-H "Content-Type: application/json" \
-d '{"email": "you@example.com", "shop_name": "My Shop", "base_address": "0x...", "webhook_url": "https://shop.example/api/zp/webhook"}'
Response · 201
{
"merchant_id": "...",
"plan": "free",
"api_key": "zp_live_xxxxxxxxxxxxxxxx",
"key_prefix": "zp_live_xxxxxxxx",
"webhook_secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxx"
}
api_key and webhook_secret are shown once — only the key's hash is stored. webhook_secret is present only when webhook_url was sent.
Errors: 400 invalid_email / invalid_shop_name / no_receive_method / private_key_rejected / invalid_xpub / invalid_address / invalid_webhook_url,
409 email_taken, 429 rate_limited (with Retry-After).
GET
/api/v1/me
API key required
The account behind the API key: plan, this calendar month's usage, receive methods and webhook URL.
No secrets are returned. A paid plan whose period has ended is reported as free.
{
"merchant_id": "...",
"shop_name": "My Shop",
"email": "you@example.com",
"plan": "starter",
"plan_expires_at": "2026-07-01T12:00:00.000Z",
"usage": { "invoices_this_month": 12, "monthly_limit": 500 },
"chains": [
{ "chain": "base", "mode": "fixed-address", "xpub": null, "fixed_address": "0x..." }
],
"webhook_url": "https://shop.example/api/zp/webhook",
"api_keys": [
{ "prefix": "zp_live_xxxxxxxx", "label": "signup", "created_at": "...", "revoked": false }
],
"created_at": "2026-06-01T12:00:00.000Z"
}
plan_expires_at is null on the Free plan. mode is "xpub" or "fixed-address"; in xpub mode the key is shortened for display.
GET
/api/v1/invoices?limit=25
API key required
Your most recent invoices. limit defaults to 25 and is clamped to 1–100.
{
"invoices": [
{
"invoice_id": "inv_...",
"chain": "base",
"asset": "USDC",
"amount": "42",
"receive_address": "0x...",
"status": "confirmed",
"tx_hash": "0x...",
"created_at": "...",
"paid_at": "...",
"expires_at": "..."
}
]
}
POST
/api/v1/webhook
API key required
Sets or replaces your webhook URL. Every call issues a fresh signing secret, shown once — the
previous secret stops being used, so update your server at the same time. The URL must be
https on the default port and resolve only to public addresses; this is checked when it
is saved and again before every delivery.
curl -X POST $ZETTAPAY_URL/api/v1/webhook \
-H "X-ZettaPay-Api-Key: zp_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"webhook_url": "https://shop.example/api/zp/webhook"}'
Response · 200
{
"webhook_url": "https://shop.example/api/zp/webhook",
"webhook_secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxx"
}
Errors: 400 invalid_webhook_url.
GET
/api/v1/plans
public
The plan catalogue: monthly invoice caps, prices and the payment methods currently on offer.
{
"plans": [
{ "plan": "free", "invoices_per_month": 50, "price_usd_per_month": 0 },
{ "plan": "starter", "invoices_per_month": 500, "price_usd_per_month": 19 },
{ "plan": "pro", "invoices_per_month": 5000, "price_usd_per_month": 49 }
],
"payment_methods": ["crypto", "stripe"],
"transaction_fee": 0
}
POST
/api/v1/billing/checkout
API key required
Starts the payment for a paid plan and returns a link to open. This is what the upgrade buttons in the
dashboard call.
| Field | Type | Notes |
plan | string | Required. starter or pro. |
method | string | crypto (default) or stripe. Must be listed in payment_methods of GET /api/v1/plans. |
crypto — an ordinary ZettaPay invoice in a stablecoin on Base; checkout_url is its hosted payment page. Once it confirms, the plan is active for 30 days. To renew, pay again; time still left on the same plan is kept.
stripe — a card subscription that renews monthly; checkout_url is a page hosted by Stripe. ZettaPay never receives card data.
- A paid plan that is not renewed falls back to Free.
curl -X POST $ZETTAPAY_URL/api/v1/billing/checkout \
-H "X-ZettaPay-Api-Key: zp_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"plan": "starter", "method": "crypto"}'
Response · 200
{
"subscription_id": "...",
"method": "crypto",
"plan": "starter",
"amount_usd": 19,
"invoice_id": "inv_...",
"checkout_url": "$ZETTAPAY_URL/checkout/inv_..."
}
invoice_id is present for crypto only. Errors: 400 unknown_plan, 409 method_unavailable, 502 stripe_error.
POST
/api/v1/invoice
Same body as the listener's POST /invoice:
amount_sats (+ optional memo) for Bitcoin, or
chain: "base" with amount_usd (+ optional asset) for Base. The
response has the same fields, plus checkout_url — a hosted payment page for this invoice —
when hosted checkout is enabled.
curl -X POST $ZETTAPAY_URL/api/v1/invoice \
-H "X-ZettaPay-Api-Key: zp_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"chain": "base", "amount_usd": 42}'
Plan limit
Once the month's invoice cap of your plan is reached, new invoices are refused with
402. Invoices that already exist are unaffected — they keep being watched, confirmed and
delivered by webhook.
{
"error": {
"code": "plan_limit_reached",
"message": "plan \"...\" allows N invoices per month",
"plan": "...",
"limit": 0,
"used": 0
}
}
GET
/api/v1/invoice/:id
API key required
Fetches one of your invoices. An invoice that belongs to another merchant answers
404 not_found — its existence is never revealed across tenants.
GET
/api/v1/checkout/:id
public · CORS open
The read-only view a payment page needs, callable from the payer's browser without an API key. It
returns only display fields — never your xpub, webhook secret, API key or email.
{
"invoice_id": "inv_...",
"shop_name": "My Shop",
"chain": "base",
"asset": "USDC",
"status": "pending",
"receive_address": "0x...",
"amount_usdc": "42",
"amount_usdc_units": 42000000,
"qr_uri": "ethereum:0x833589...@8453/transfer?address=0x...&uint256=42000000",
"expires_at": "...",
"created_at": "...",
"tx_hash": null,
"paid_at": null,
"verify_url": "https://basescan.org/address/0x...",
"tx_url": null
}
Bitcoin invoices carry amount_btc instead of the amount_usdc fields. Fixed-address invoices also carry nonce.
GET
/api/v1/health
public
Liveness probe.
{ "ok": true, "service": "zettapay-cloud", "ts": "2026-06-01T12:00:00.000Z" }
WebhooksHMAC-signed · retried
Delivery & payload
When an invoice confirms, a JSON event is POSTed to your webhook URL
(MERCHANT_WEBHOOK_URL on the self-hosted listener). The URL must be HTTPS;
http://localhost is accepted for development. Any 2xx within 10 seconds
acknowledges the delivery; anything else is retried, up to 10 attempts over roughly 17 hours. Full
details in the webhook reference.
Event payload · Bitcoin
{
"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
| Event | Fires when |
invoice.confirmed | The payment for an invoice reached the required confirmations. |
payment.orphan | Fixed-address mode only: a transfer arrived that matches no open invoice (wrong amount, or the invoice expired). |
Signature verification
Each delivery carries X-ZettaPay-Signature — the hex HMAC-SHA256 of the
raw body keyed by your webhook secret —
X-ZettaPay-Timestamp (unix milliseconds), X-ZettaPay-Event-Id (the same on
every retry of an event) and X-ZettaPay-Attempt (1-indexed). Recompute the HMAC, compare
in constant time, and reject timestamps more than five minutes off.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, sig, ts, secret) {
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);
}
AI AgentsMCP
MCP server — @zettapay/mcp
A Model Context Protocol server, speaking MCP over stdio, that runs on your machine and calls your own
listener (http://localhost:8787 by default). It never holds a key or funds; the agent
only ever receives a public receive address.
Tools advertised
| Tool | Does |
create_invoice | Creates a BTC or USDC/USDT-on-Base invoice (amount_sats, or chain: "base" + amount_usd); returns the receive address and payment URI. |
get_invoice_status | Polls an invoice: pending | partial | confirmed | expired | failed. |
list_supported_assets | Lists what the listener accepts: btc, usdc-base, usdt-base. |
Configuration
| Variable | Default | Notes |
LISTENER_URL | http://localhost:8787 | Base URL of your listener. |
ZETTAPAY_API_KEY | — | Sent as X-ZettaPay-Api-Key when your listener requires it. |
Claude Desktop · claude_desktop_config.json
{
"mcpServers": {
"zettapay": {
"command": "npx",
"args": ["-y", "@zettapay/mcp"],
"env": {
"LISTENER_URL": "http://localhost:8787",
"ZETTAPAY_API_KEY": "zp_live_xxxxxxxx"
}
}
}
}
Claude Code
claude mcp add zettapay -- npx -y @zettapay/mcp
Machine-readableOpenAPI 3.1
GET
/openapi.json
The listener API as an OpenAPI 3.1 document —
/openapi.json. A plain-text
summary for language models lives at /llms.txt.
Ready to ship?
Self-host the listener in a few minutes, or create a Cloud account and start on the free plan.