Put a crypto checkout on your site.
A ZettaPay checkout is an invoice plus a screen that shows it. Your backend creates the invoice; the payer sees a receive address and a QR code and pays from any wallet. Either send the payer to the hosted checkout link that Cloud returns, or render the address and QR yourself and poll the invoice status. Either way the payment goes straight to your wallet and your backend is told by a signed webhook.
Overview
| Path | Works with | You build |
|---|---|---|
| Hosted checkout link | Cloud, when the invoice response includes checkout_url | A redirect or a link. Nothing else. |
| Render it yourself | Self-hosted listener and Cloud | A payment screen: address, amount, QR, status. |
@zettapay/widget
and @zettapay/embed packages currently on npm were
built for an earlier version of the protocol and do not talk to the listener or to Cloud. Do not use
them for a new integration — follow this guide instead.
Create the invoice on your server
When the customer clicks "Pay with crypto", your backend asks ZettaPay for an invoice and stores the
returned invoice_id on the order. The request is the
same for the self-hosted listener (POST /invoice) and
for Cloud (POST /api/v1/invoice).
// Your backend (Express, Node 18+). The listener runs next to it. app.post('/api/checkout', async (req, res) => { const order = await loadOrder(req.body.orderId); // your own code const r = await fetch('http://localhost:8787/invoice', { method: 'POST', headers: { 'content-type': 'application/json', 'x-zettapay-api-key': process.env.ZETTAPAY_API_KEY, }, body: JSON.stringify({ chain: 'base', amount_usd: order.totalUsd }), }); if (!r.ok) return res.status(502).json({ error: 'could not create invoice' }); const inv = await r.json(); await saveInvoiceId(order.id, inv.invoice_id); // your own code // Hand the browser only what it needs to show. res.json({ invoice_id: inv.invoice_id, asset: inv.asset, receive_address: inv.receive_address, amount: inv.amount_usdc ?? inv.amount_btc, qr_uri: inv.qr_uri, expires_at: inv.expires_at, }); });
// Same request, Cloud base URL. ZETTAPAY_URL is the origin of the ZettaPay site. const r = await fetch(`${process.env.ZETTAPAY_URL}/api/v1/invoice`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-zettapay-api-key': process.env.ZETTAPAY_API_KEY, }, body: JSON.stringify({ chain: 'base', amount_usd: order.totalUsd }), }); const inv = await r.json(); // inv.checkout_url is present when hosted checkout is enabled.
For Bitcoin send { "amount_sats": 50000 } instead. All
fields are in the API reference.
Hosted checkout link · Cloud
On Cloud, the invoice response can carry a
checkout_url: a hosted page for that one invoice,
showing your shop name, the amount, the receive address, a QR code, a countdown and the live status.
Redirect the payer to it, or put it behind a button. The page needs no login and exposes no secret.
// in the server route from step 1 if (inv.checkout_url) return res.redirect(303, inv.checkout_url);
<a href="{{ checkout_url }}" rel="noopener">Pay with crypto</a>
checkout_url is absent, hosted checkout is not enabled for that
deployment — use option 2b. The self-hosted listener does not return this field.
Render it yourself
Show three things: the exact amount, the
receive address with a copy button, and a QR code of
qr_uri. The URI is a standard BIP-21
(bitcoin:…) or EIP-681
(ethereum:…) payment link, so any QR library can
encode it and any wallet can read it. No wallet is ever connected to the page.
<div id="zp-checkout"> <p>Send exactly <strong id="zp-amount"></strong> <span id="zp-asset"></span> to:</p> <code id="zp-address"></code> <div id="zp-qr"></div> <a id="zp-open">Open in wallet</a> <p id="zp-status">Waiting for payment…</p> </div>
const inv = await (await fetch('/api/checkout', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ orderId }), })).json(); document.getElementById('zp-amount').textContent = inv.amount; document.getElementById('zp-asset').textContent = inv.asset; document.getElementById('zp-address').textContent = inv.receive_address; document.getElementById('zp-open').href = inv.qr_uri; // Encode inv.qr_uri with the QR library you already use, // and draw the result into #zp-qr. renderQr(document.getElementById('zp-qr'), inv.qr_uri);
Poll the status to update the screen
Poll every few seconds until the status leaves
pending, and stop at
expires_at. Where the browser reads the status from
depends on what you run:
- › Self-hosted. Keep the listener private and add a small route on your backend that forwards to
GET /invoice/:id. (If you do expose the listener, list your site inMERCHANT_ORIGINso the browser is allowed to call it.) - › Cloud. The browser can call the public
GET /api/v1/checkout/:iddirectly — it needs no API key and returns display fields only.
// Self-hosted: forward status lookups to the listener. app.get('/api/checkout/:id', async (req, res) => { const r = await fetch( `http://localhost:8787/invoice/${encodeURIComponent(req.params.id)}`, ); if (!r.ok) return res.status(r.status).end(); const inv = await r.json(); res.json({ status: inv.status, tx_hash: inv.tx_hash }); });
// Self-hosted: your own route. Cloud: `${ZETTAPAY_URL}/api/v1/checkout/${id}` const statusUrl = `/api/checkout/${inv.invoice_id}`; const el = document.getElementById('zp-status'); const timer = setInterval(async () => { if (Date.now() > Date.parse(inv.expires_at)) { clearInterval(timer); el.textContent = 'This invoice expired. Start again.'; return; } const s = await (await fetch(statusUrl)).json(); if (s.status === 'confirmed') { clearInterval(timer); el.textContent = 'Payment confirmed. Thank you!'; } else if (s.status === 'expired' || s.status === 'failed') { clearInterval(timer); el.textContent = 'This invoice is no longer payable.'; } }, 5000);
Fulfil the order on the webhook
The status poll is only for the payer's screen. The thing that should release the order is the signed
invoice.confirmed webhook your backend receives — it
arrives even if the payer closed the tab. Verify the signature, look the order up by
invoice_id, mark it paid.
Handler code and the full payload are in the webhook reference.
Exact amounts in fixed-address mode
If your Base setup uses one fixed receive address (no xpub), every invoice shares that address and is
told apart by a small nonce in the low decimals of the amount — for example
29.000042 for a 29-dollar order. The response then
carries "mode": "fixed-address".
amount_usdc digit for digit, and say "send exactly". A rounded
amount (29.00) matches no invoice: the funds still arrive in your wallet, but the order is not marked
paid and you receive a payment.orphan webhook instead. The
qr_uri already encodes the exact amount and the right token contract.
Field reference
The invoice fields a payment screen uses.
| Field | Use it for |
|---|---|
invoice_id | Your key to the order; the id to poll. |
receive_address | The address the payer sends to. Show it with a copy button. |
asset | BTC, USDC or USDT. |
amount_btc / amount_sats | Bitcoin amount, as a decimal string and in satoshis. |
amount_usdc | Exact stablecoin amount to send (USDC or USDT), as a decimal string. |
qr_uri | BIP-21 or EIP-681 payment URI — encode it as a QR, or use it as a link target. |
expires_at | ISO timestamp for the countdown. |
status | pending, partial, confirmed, expired, failed. |
tx_hash | Set once paid — link it to a block explorer. |
verify_url | Block-explorer page of the receive address (xpub-derived invoices). |
checkout_url | Cloud only, when hosted checkout is enabled: the hosted payment page. |