Accept your first crypto payment in five minutes.
Get your API key, install the SDK, configure your wallet addresses
(MERCHANT_BTC_PUBKEY /
MERCHANT_ETH_PUBKEY /
MERCHANT_SOL_PUBKEY) in env vars on
your server, call zp.register(), point
the customer at the payment URL. ZettaPay watches the chain and posts a signed webhook the moment
the transaction confirms. We never see your pubkey in our database and never hold the funds.
Overview
ZettaPay is a peer-to-peer, non-custodial payment protocol. The customer's funds move directly from their wallet to yours on-chain — the protocol never touches the money. What ZettaPay actually does is the part that's hard in pure crypto: it answers "did the customer pay?" automatically, by running chain listeners on Bitcoin, Ethereum mainnet + L2s (Base, Polygon), and Solana, and posting a signed webhook to your server the instant a deposit matching the invoice is confirmed.
How confirmation works
The flow is the same Stripe-style webhook pattern you already know — only the source of truth is the blockchain itself instead of a payment processor's ledger.
┌─────────────────┐ ┌────────────────────┐ ┌───────────────────┐ ┌─────────────────┐ │ Customer wallet │ ──TX──▶ Blockchain │ ──tx──▶ ZettaPay listener │ ──POST▶ Merchant webhook │ │ (any wallet) │ │ BTC / ETH / SOL │ │ watches the mempool & │ │ HTTPS · HMAC-signed └─────────────────┘ └────────────────────┘ matches by address+amount └─────────────────┘ └───────────────────┘ your code marks order paid
Prerequisites
- › Node 18+, Python 3.10+, PHP 8.1+, or Rust 1.74+ (pick one)
- › A public address you control on at least one chain you want to accept (BTC, ETH, Solana). The pubkey lives in your own env vars — you never paste it on our site.
- › A publicly reachable HTTPS endpoint to receive webhooks (use
ngrok,cloudflared, or any deploy) — HTTP is rejected - › Devnet / testnet funding (optional) — claim free tokens from the ZettaPay faucet
Get your API key
Open zettapay.io/signup, enter your email and shop name. That is the whole intake — no wallet connect, and we do not collect your pubkey here. The form returns three credentials:
- ›
merchant_id— used in API calls - ›
api_key— bearer token for server-side calls - ›
webhook_secret— shared secret for HMAC signature verification
Your wallet addresses live in your own code — set them as
env vars in the next step. ZettaPay learns about them when your code calls
zp.register(), and they can be rotated by
re-deploying with new env vars (no login required).
Install the SDK · configure your pubkeys
Pick the language you ship in. All four SDKs share the same surface area.
# Official TypeScript / JavaScript SDK npm install @zettapay/sdk # or: pnpm add @zettapay/sdk · yarn add @zettapay/sdk · bun add @zettapay/sdk
# Official Python SDK pip install zettapay # or: poetry add zettapay · uv add zettapay
# Official PHP SDK (Composer)
composer require zettapay/sdk
# Official Rust crate cargo add zettapay # or in Cargo.toml: zettapay = "0.1"
Drop the credentials from step 1 plus the wallet addresses you control into a
.env file on your server. Only the chains
you set are listened to — leave any unused.
# .env — kept on your servers, never on ours ZETTAPAY_API_KEY=sk_live_... ZETTAPAY_WEBHOOK_SECRET=whsec_... # Wallet addresses you control — pick any subset MERCHANT_BTC_PUBKEY=bc1qx5...e92 MERCHANT_ETH_PUBKEY=0x7a3...4F2 MERCHANT_SOL_PUBKEY=7Np41oeYqPefeNQEHSv1UDhYrehxin3NStpSyab9YVhT
dev /
staging /
prod become three different env files instead of
three different dashboards.
Register · create an invoice
Initialize the SDK with your API key, webhook secret, and the wallet addresses from your env vars.
Call zp.register() once on boot — it registers
your pubkeys with the ZettaPay chain listener. Then create an invoice (amount + currency + a stable
customer_ref of your choice) and point the
customer at the returned payment URL.
import { ZettaPay } from '@zettapay/sdk'; // Pubkey lives in your code, in env vars you control. const zp = new ZettaPay({ apiKey: process.env.ZETTAPAY_API_KEY!, webhookSecret: process.env.ZETTAPAY_WEBHOOK_SECRET!, pubkeys: { btc: process.env.MERCHANT_BTC_PUBKEY, eth: process.env.MERCHANT_ETH_PUBKEY, sol: process.env.MERCHANT_SOL_PUBKEY, }, webhookUrl: 'https://my-app.com/webhooks/zettapay', }); await zp.register(); // idempotent: registers pubkeys with the chain listener const invoice = await zp.invoices.create({ amount: '19.90', // string for precise decimals currency: 'USDC', // 'BTC' | 'ETH' | 'USDC' customer_ref: 'order_42', }); // Send the customer here: console.log(invoice.payment_url); // https://pay.zettapay.io/inv_01HM...
import os from zettapay import ZettaPay # Pubkey lives in your code, in env vars you control. zp = ZettaPay( api_key=os.environ["ZETTAPAY_API_KEY"], webhook_secret=os.environ["ZETTAPAY_WEBHOOK_SECRET"], pubkeys={ "btc": os.environ.get("MERCHANT_BTC_PUBKEY"), "eth": os.environ.get("MERCHANT_ETH_PUBKEY"), "sol": os.environ.get("MERCHANT_SOL_PUBKEY"), }, webhook_url="https://my-app.com/webhooks/zettapay", ) zp.register() # idempotent: registers pubkeys with the chain listener invoice = zp.invoices.create( amount="19.90", currency="USDC", # "BTC" | "ETH" | "USDC" customer_ref="order_42", ) print(invoice.payment_url) # https://pay.zettapay.io/inv_01HM...
use ZettaPay\Client; // Pubkey lives in your code, in env vars you control. $zp = new Client([ 'api_key' => getenv('ZETTAPAY_API_KEY'), 'webhook_secret' => getenv('ZETTAPAY_WEBHOOK_SECRET'), 'pubkeys' => [ 'btc' => getenv('MERCHANT_BTC_PUBKEY'), 'eth' => getenv('MERCHANT_ETH_PUBKEY'), 'sol' => getenv('MERCHANT_SOL_PUBKEY'), ], 'webhook_url' => 'https://my-app.com/webhooks/zettapay', ]); $zp->register(); // idempotent: registers pubkeys with the chain listener $invoice = $zp->invoices->create([ 'amount' => '19.90', 'currency' => 'USDC', // 'BTC' | 'ETH' | 'USDC' 'customer_ref' => 'order_42', ]); echo $invoice->payment_url; // https://pay.zettapay.io/inv_01HM...
use zettapay::{Client, ClientConfig, Pubkeys, CreateInvoice, Currency}; // Pubkey lives in your code, in env vars you control. let zp = Client::new(ClientConfig { api_key: std::env::var("ZETTAPAY_API_KEY")?, webhook_secret: std::env::var("ZETTAPAY_WEBHOOK_SECRET")?, pubkeys: Pubkeys { btc: std::env::var("MERCHANT_BTC_PUBKEY").ok(), eth: std::env::var("MERCHANT_ETH_PUBKEY").ok(), sol: std::env::var("MERCHANT_SOL_PUBKEY").ok(), }, webhook_url: "https://my-app.com/webhooks/zettapay".into(), })?; zp.register().await?; // idempotent: registers pubkeys with the chain listener let invoice = zp.invoices().create(CreateInvoice { amount: "19.90".into(), currency: Currency::USDC, // Currency::BTC | ETH | USDC customer_ref: "order_42".into(), ..Default::default() }).await?; println!("{}", invoice.payment_url); // https://pay.zettapay.io/inv_01HM...
zp.register() is idempotent and re-runs on boot —
change a pubkey in your env file, redeploy, and the chain listener picks up the new address on the
next register call. No support ticket, no dashboard edit, no downtime.
idempotency_key when you'd retry on network
errors. The SDKs auto-generate one per call; you can override it to dedupe at your own boundary.
Customer pays
Send the customer to invoice.payment_url. The
page shows the merchant's deposit address for the chosen chain, the exact amount, a QR code, and
a copyable string. The customer pays from whatever wallet they prefer — Phantom, Solflare,
MetaMask, Rainbow, a Bitcoin hardware wallet, a mobile wallet, an exchange withdrawal — anything
that can send to a plain address. They never connect their wallet to
ZettaPay.
ZettaPay's listener detects the inbound transaction, matches it to the invoice (by destination address + amount + memo where supported), and fires a signed webhook to the URL you registered at signup. Step 5 shows how to verify it.
Verify the webhook · mark the order paid
ZettaPay POSTs a JSON body with two headers: an HMAC-SHA256
X-ZettaPay-Signature over
timestamp.body, and a
X-ZettaPay-Timestamp for replay protection.
Verify both. Then idempotently mark the order paid by
tx_hash.
{
"invoice_id": "inv_01HMABCD...",
"event": "payment.confirmed",
"amount": "19.90",
"currency": "USDC",
"network": "solana", // "bitcoin" | "ethereum" | "base" | "polygon" | "solana"
"tx_hash": "5jkA...",
"confirmations": 1,
"customer_address": "7Np41oeY...",
"customer_ref": "order_42",
"created_at": "2026-05-17T12:34:56Z"
}
// Express handler · TypeScript import express from 'express'; import { verifyWebhook } from '@zettapay/sdk'; const app = express(); // IMPORTANT: keep the raw body — JSON parsing destroys the signature app.post( '/webhooks/zettapay', express.raw({ type: 'application/json' }), (req, res) => { try { const event = verifyWebhook({ body: req.body, // Buffer signature: req.header('X-ZettaPay-Signature')!, timestamp: req.header('X-ZettaPay-Timestamp')!, secret: process.env.ZETTAPAY_WEBHOOK_SECRET!, toleranceSec: 300, // reject if older than 5 min }); if (event.event === 'payment.confirmed') { // Idempotent by tx_hash — same hash is never delivered twice, // but you should still de-dupe on your side as defense in depth. markOrderPaid(event.customer_ref, event.tx_hash); } res.status(200).send('ok'); } catch (err) { res.status(400).send('invalid signature'); } }, );
# Flask handler · Python import os from flask import Flask, request, abort from zettapay import verify_webhook, WebhookError app = Flask(__name__) SECRET = os.environ["ZETTAPAY_WEBHOOK_SECRET"] @app.post("/webhooks/zettapay") def on_event(): try: event = verify_webhook( body=request.get_data(), # raw bytes, NOT request.json signature=request.headers["X-ZettaPay-Signature"], timestamp=request.headers["X-ZettaPay-Timestamp"], secret=SECRET, tolerance_sec=300, ) except WebhookError: abort(400) if event["event"] == "payment.confirmed": mark_order_paid(event["customer_ref"], event["tx_hash"]) return "ok", 200
// Slim / vanilla PHP handler use ZettaPay\Webhook; use ZettaPay\WebhookException; $secret = getenv('ZETTAPAY_WEBHOOK_SECRET'); $body = file_get_contents('php://input'); // raw body try { $event = Webhook::verify( $body, $_SERVER['HTTP_X_ZETTAPAY_SIGNATURE'], $_SERVER['HTTP_X_ZETTAPAY_TIMESTAMP'], $secret, 300, // tolerance seconds ); } catch (WebhookException $e) { http_response_code(400); exit('invalid signature'); } if ($event['event'] === 'payment.confirmed') { mark_order_paid($event['customer_ref'], $event['tx_hash']); } echo 'ok';
// Axum handler · Rust use axum::{extract::State, http::HeaderMap, response::IntoResponse}; use zettapay::webhook::{verify, VerifyParams}; async fn on_event( headers: HeaderMap, body: axum::body::Bytes, ) -> impl IntoResponse { let secret = std::env::var("ZETTAPAY_WEBHOOK_SECRET").unwrap(); let event = match verify(VerifyParams { body: &body, signature: headers.get("X-ZettaPay-Signature").unwrap().to_str().unwrap(), timestamp: headers.get("X-ZettaPay-Timestamp").unwrap().to_str().unwrap(), secret: &secret, tolerance_sec: 300, }) { Ok(e) => e, Err(_) => return (axum::http::StatusCode::BAD_REQUEST, "invalid"), }; if event.event == "payment.confirmed" { mark_order_paid(&event.customer_ref, &event.tx_hash).await; } (axum::http::StatusCode::OK, "ok") }
tx_hash; on retries you
get the same tx_hash + invoice_id, so a unique constraint on either is
sufficient. Full spec: webhook reference.
AI agents · x402 + MCP
ZettaPay is x402- and MCP-native. If your API returns
HTTP 402 Payment Required with a
ZettaPay-Pay header pointing at an invoice URL,
an x402-aware agent (Claude, GPT, any compliant client) pays autonomously from its own wallet and
retries. The protocol confirms the payment exactly the same way as a human checkout — on-chain
listener → webhook.
ZettaPay also exposes a pay_with_zettapay MCP
tool that any agent runtime can call directly — see /docs/api#mcp.
What's next
API reference (excerpt)
| Method | Path | Purpose |
|---|---|---|
POST | /merchants/register | Create merchant; returns api_key + webhook_secret. |
POST | /invoices | Create an invoice; returns payment_url. |
GET | /invoices/:id | Fetch invoice + payment status. |
POST | /webhooks | Update the HTTPS endpoint that receives events. |
POST | /webhooks/test | Send a synthetic event to your endpoint. |
GET | /healthz | Liveness probe (200 = OK). |
All write endpoints accept an Idempotency-Key
header. Rate limits are per API key (sliding window). Errors follow
{ code, message, details }.
See /docs/api for the full surface.