All docs Reference

HTTP API

Two surfaces with the same shape: the self-hosted @zettapay/listener you run yourself, and the managed Cloud API under /api/v1. Create an invoice, poll its status, receive an HMAC-signed webhook. All requests and responses are JSON. Agents reach the same API through the MCP server.

listener v0.6 Self-hosted: http://localhost:8787 · Cloud: $ZETTAPAY_URL/api/v1 · Auth: X-ZettaPay-Api-Key

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

chainAssetAmount fieldReceive address comes from
btc (default)BTCamount_satsYour BIP-84 xpub — one address per invoice.
baseUSDCamount_usdYour EVM account xpub (one address per invoice), or your fixed receive address.
base + "asset": "usdt"USDTamount_usdYour 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

StatusMeaning
pendingCreated, waiting for a payment.
partialReserved for a payment that does not yet satisfy the invoice.
confirmedPayment confirmed on-chain. tx_hash and paid_at are set and the webhook is queued.
expiredPassed expires_at without a matching payment.
failedThe 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"
  }
}
StatusCodeMeaning
400bad_bodyBody is not valid JSON or is larger than 16 KB.
400invalid_amountamount_sats is not a positive integer, or amount_usd is not a positive number.
400unsupported_chainchain is not one the API knows.
400chain_disabled / base_disabledThe chain is not configured for this merchant.
400asset_disabledThe token is not enabled (self-hosted: add it to MERCHANT_EVM_TOKENS).
401unauthorizedMissing or invalid API key.
402plan_limit_reachedCloud only — the plan's monthly invoice cap is reached.
404not_foundNo such invoice or route.
429rate_limitedToo many invoice requests — honor Retry-After.
500create_failed / lookup_failedThe invoice could not be created or read.
503capacityFixed-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.

SurfaceScopeDefault
Self-hosted POST /invoicePer client IP, plus a global cap30 / minute per IP, 300 / minute overall. Set ZETTAPAY_RATE_LIMIT to 30, 30,300 or off.
Cloud POST /api/v1/invoicePer API key60 / 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

FieldTypeDescription
chainstringoptionalbtc (default) or base.
amount_satsintegerbtcAmount in satoshis, positive.
amount_usdnumberbaseAmount in USD, positive.
assetstringoptionalBase only: usdc (default) or usdt. Applies in fixed-address mode.
memostringoptionalBitcoin only: label put in the BIP-21 URI, up to 200 characters.
expires_inintegeroptionalLifetime 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.

FieldTypeNotes
emailstringRequired. One account per email.
shop_namestringRequired, 2–80 characters. Shown on hosted checkout.
btc_xpubstringOptional. Extended public key for Bitcoin.
base_xpubstringOptional. Extended public key for Base (one address per invoice). Wins over base_address when both are sent.
base_addressstringOptional. Fixed 0x receive address for Base.
webhook_urlstringOptional. 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.

FieldTypeNotes
planstringRequired. starter or pro.
methodstringcrypto (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

EventFires when
invoice.confirmedThe payment for an invoice reached the required confirmations.
payment.orphanFixed-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

ToolDoes
create_invoiceCreates a BTC or USDC/USDT-on-Base invoice (amount_sats, or chain: "base" + amount_usd); returns the receive address and payment URI.
get_invoice_statusPolls an invoice: pending | partial | confirmed | expired | failed.
list_supported_assetsLists what the listener accepts: btc, usdc-base, usdt-base.

Configuration

VariableDefaultNotes
LISTENER_URLhttp://localhost:8787Base 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.