Guide · checkout on your site

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.

Hosted link · Cloud Own UI · self-hosted or Cloud

Overview

PathWorks withYou build
Hosted checkout linkCloud, when the invoice response includes checkout_urlA redirect or a link. Nothing else.
Render it yourselfSelf-hosted listener and CloudA payment screen: address, amount, QR, status.
The invoice is always created on your server. Creating an invoice needs your API key, so it must never happen in the browser. The browser only receives public information: the receive address, the amount and the payment URI.
About the script-tag packages. The @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.
1

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,
  });
});

For Bitcoin send { "amount_sats": 50000 } instead. All fields are in the API reference.

2a

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);
If checkout_url is absent, hosted checkout is not enabled for that deployment — use option 2b. The self-hosted listener does not return this field.
2b

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>
3

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 in MERCHANT_ORIGIN so the browser is allowed to call it.)
  • › Cloud. The browser can call the public GET /api/v1/checkout/:id directly — 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 });
});
Hide the QR when the invoice expires. A payment sent after expiry cannot be matched to the order automatically. Invoices live for one hour by default.
4

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

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

FieldUse it for
invoice_idYour key to the order; the id to poll.
receive_addressThe address the payer sends to. Show it with a copy button.
assetBTC, USDC or USDT.
amount_btc / amount_satsBitcoin amount, as a decimal string and in satoshis.
amount_usdcExact stablecoin amount to send (USDC or USDT), as a decimal string.
qr_uriBIP-21 or EIP-681 payment URI — encode it as a QR, or use it as a link target.
expires_atISO timestamp for the countdown.
statuspending, partial, confirmed, expired, failed.
tx_hashSet once paid — link it to a block explorer.
verify_urlBlock-explorer page of the receive address (xpub-derived invoices).
checkout_urlCloud only, when hosted checkout is enabled: the hosted payment page.

What's next