Quickstart · 5 min · non-custodial

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.

TypeScript Python PHP Rust

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.

Zero protocol fee. The customer pays the network's own gas (sub-cent on Solana, a few cents on Ethereum L2s, a few sats on Bitcoin). ZettaPay adds nothing on top.

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
Solana (USDC)
< 2 s
finalized commitment
Ethereum & L2s
~ 12 s
1 block confirmation
Bitcoin
~ 10 min
1 confirmation (configurable)

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
1

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).

Store the webhook secret server-side only. Treat it like an API key. You'll use it in step 5 to verify every event ZettaPay posts to you.
2

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

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
Why env vars, not a form? The pubkey never leaves your infrastructure. Rotating keys is a deploy, not a support ticket. dev / staging / prod become three different env files instead of three different dashboards.
3

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...
Rotate without logging in. 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. Pass an 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.
4

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.

Customer side
Scans QR or copies the address. Pays. Done.
Your side
Wait for the webhook. Don't poll.

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.

5

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');
    }
  },
);
Replay & idempotency. Reject events whose timestamp is older than 300 s. ZettaPay never re-delivers a successful event with the same 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)

MethodPathPurpose
POST/merchants/registerCreate merchant; returns api_key + webhook_secret.
POST/invoicesCreate an invoice; returns payment_url.
GET/invoices/:idFetch invoice + payment status.
POST/webhooksUpdate the HTTPS endpoint that receives events.
POST/webhooks/testSend a synthetic event to your endpoint.
GET/healthzLiveness 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.