Docs
GuidesThe Hosted Checkout Page

The Hosted Checkout Page

Intermediate20 min

How the buyer-facing payment page works, what the public checkout API exposes, and when building your own is worth it.


Once you have an OPEN invoice, somebody has to show the buyer a chain picker, an amount, an address, a QR code and a countdown โ€” and then keep that screen truthful while a transaction confirms. Havala hosts that page at pay.havala.io, and exposes the same API it runs on so you can build your own.

What the hosted page does

Send the buyer to the checkout page for the invoice:

text
https://pay.havala.io/i/{invoiceId}

The {invoiceId} is the CUID from POST /api/v1/invoices. No token, no signature, no session โ€” possession of the invoice id is the capability. That is a deliberate design: the buyer has no account with you or with Havala, and the link has to survive being pasted into a chat window on a phone.

It also means the id is a bearer credential. Treat it like one: send it over HTTPS, do not put it in a URL you log in plaintext, and keep invoice expiry short.

On that page the buyer:

  1. Sees the amount, your company name, and the chains and coins available.
  2. Picks a chain and a coin.
  3. Gets a one-time deposit address, a QR code, and a countdown to payment.expiresAt.
  4. Pays from their own wallet.
  5. Watches the confirmation counter climb until the payment completes, then lands on your returnUrl.

Your returnUrl and cancelUrl are where the buyer ends up, not how you learn what happened. The browser is not a trustworthy source of truth here โ€” a buyer who closes the tab after paying never hits either URL. Settle on payment.completed; see Webhooks.

The public checkout API

Five routes under /checkout/api/v1, all unauthenticated except the last. This is the same surface the hosted page uses, so anything the hosted page can do, you can do.

RoutePurpose
GET /checkout/api/v1/invoices/{id}Everything the page renders
GET /checkout/api/v1/invoices/{id}/statusCheap invoice poll
POST /checkout/api/v1/invoices/{id}/checkoutLock in a chain and coin, get payment instructions
GET /checkout/api/v1/payments/{id}/statusConfirmation progress
POST /checkout/api/v1/payments/{id}/confirmAttach a client-signed transaction hash

Note the {id} in the two /status routes points at different objects โ€” an invoice CUID on one, a payment CUID on the other. Same suffix, never interchangeable.

Reading the invoice

Shell
curl https://api2.havala.io/checkout/api/v1/invoices/clx7k2p9a0001qz8fh3n2v4m6
JSON
{
  "id": "clx7k2p9a0001qz8fh3n2v4m6",
  "referenceId": "INV-260826-041B",
  "amount": "12500",
  "currency": "USD",
  "description": "Order #4471 โ€” annual plan",
  "merchant": { "name": "Northwind Digital LLC" },
  "availableBlockchains": ["ethereum", "polygon", "bsc", "bitcoin", "tron", "solana", "ton", "arbitrum", "optimism", "base", "avalanche"],
  "availableCurrencies": {
    "ethereum": ["ETH", "USDT", "USDC"],
    "tron": ["TRX", "USDT"],
    "bitcoin": ["BTC"]
  },
  "customerEmail": "a***@example.com",
  "customerName": "A*** N***",
  "status": "OPEN",
  "expiresAt": "2026-08-26T14:30:00.000Z",
  "returnUrl": "https://shop.example.com/orders/4471/thanks",
  "cancelUrl": "https://shop.example.com/orders/4471"
}

Because the endpoint is unauthenticated, it masks what it returns: your company name goes out in full, the buyer's email is cut to a***@example.com and their name to A*** N***. Anything you put in metadata is not returned here at all.

Two behaviours to design around:

  • It is not a pure read. The first fetch stamps viewedAt on the invoice.
  • It stops answering 200 when the invoice dies. An expired or cancelled invoice returns 400 with INVOICE_EXPIRED or INVOICE_CANCELLED, and an unknown id returns 404 INVOICE_NOT_FOUND.

So do not poll this route. Poll /status, which stays 200 in every state and returns three fields โ€” the invoice status, the id of the most recent payment (null before the buyer picks a coin), and paidAt.

Creating the payment

Shell
curl -X POST https://api2.havala.io/checkout/api/v1/invoices/clx7k2p9a0001qz8fh3n2v4m6/checkout \
  -H "Content-Type: application/json" \
  -d '{"blockchain": "ethereum", "cryptoCurrency": "USDT", "method": "direct"}'
JSON
{
  "payment": {
    "id": "clx7k2p9a0007qz8f1d0s9k2r",
    "referenceId": "PAY-260826-118A",
    "method": "direct",
    "paymentAddress": "0xe58b081006f7e3dfc967a64cb14028d512c9791e",
    "amount": "12500",
    "cryptoCurrency": "USDT",
    "blockchain": "ethereum",
    "network": "MAINNET",
    "decimals": 6,
    "expiresAt": "2026-08-26T14:12:00.000Z"
  }
}

method picks the payment mechanism:

  • direct โ€” a one-time address is leased from the wallet pool and returned as paymentAddress. Works on all eleven chains. This is what a QR code and a wallet transfer need.
  • web3 โ€” the buyer's own EVM wallet calls the Havala payment contract. The response carries contractAddress, externalId (bytes32), externalIdUuid, treasuryWallet, tokenAddress (null for the chain's native coin) and chainId instead of an address.
  • havala_wallet โ€” the same contract path through a Havala-managed wallet, and it adds requiresConfirmation: true, meaning you must call /confirm with the transaction hash afterwards.

web3 and havala_wallet are EVM-only: Bitcoin, TRON, Solana and TON reject them with BLOCKCHAIN_NOT_SUPPORTED.

The invoice is claimed atomically โ€” an updateMany conditional on status = OPEN flips it to PAYMENT_PENDING before anything touches the wallet pool โ€” and reverted to OPEN if the rest of the handler throws. Two buyers racing on the same invoice cannot both get an address; the loser gets INVOICE_NOT_AVAILABLE. A buyer who switches coins mid-flow cannot end up with two live deposit addresses.

The payment window is min(now + 30 minutes, invoice.expiresAt), and checkout on this surface always runs on MAINNET. There is no testnet selector here โ€” that is only on the signed gateway route.

Polling the payment

Shell
curl https://api2.havala.io/checkout/api/v1/payments/clx7k2p9a0007qz8f1d0s9k2r/status
JSON
{
  "status": "CONFIRMING",
  "confirmations": 7,
  "requiredConfirmations": 12,
  "transactionHash": "0xa4c123b1612dd272d1371c17149d439536b3216fdaeeb975729fae923d5a4fd1",
  "blockchain": "ethereum",
  "amount": "12500",
  "cryptoCurrency": "USDT",
  "paymentAddress": "0xe58b081006f7e3dfc967a64cb14028d512c9791e",
  "expiresAt": "2026-08-26T14:12:00.000Z"
}

transactionHash stays null until a transaction is matched to the payment. paymentAddress is the leased pool address on a direct payment and the treasury wallet on a contract one.

Render confirmations / requiredConfirmations rather than a spinner. On Polygon that is 128 confirmations and the buyer needs to see it moving.

Confirming a wallet payment

POST /checkout/api/v1/payments/{id}/confirm is the one guarded route on this surface. It takes a Web3Auth bearer token, because the caller is asserting "this transaction is mine".

The handler does not take that on trust. It pulls the receipt, rejects a reverted transaction with TX_FAILED and a receipt whose to is not the payment contract with TX_CONTRACT_MISMATCH, then moves the payment to PROCESSING and starts confirmation tracking.

It is idempotent: a payment already PROCESSING, CONFIRMING or COMPLETED returns its current state rather than an error. And an unmined hash gets TX_NOT_FOUND โ€” retry after a few seconds instead of treating it as fatal.

Rate limits

Every route on this surface is throttled per IP per minute, on top of the service-wide buckets.

RoutePer minute
GET /invoices/{id}100
GET /invoices/{id}/status60
POST /invoices/{id}/checkout10
GET /payments/{id}/status60
POST /payments/{id}/confirm5

Service-wide buckets apply as well: 10 requests per second and 50 per ten seconds. A status poll every second is inside every limit; a tight retry loop is not.

Building your own

Take the hosted page when you can. It already handles QR generation, address copy, chain-specific amount formatting, the countdown, and the confirmation progress for eleven chains.

Build your own when the checkout has to live inside your app's shell โ€” a native mobile flow, or a page where leaving your domain costs you conversions. The whole surface is public, so a browser client can call it directly. Nothing on these five routes needs a secret, which is precisely why none of them may ever be called with your apiSecret in reach of a browser.

A working client is four states:

  1. GET /invoices/{id} once, on mount. Render the amount, the merchant name and the chain picker from availableBlockchains / availableCurrencies.
  2. On selection, POST /invoices/{id}/checkout. Render paymentAddress as text and as a QR, and start a countdown to expiresAt.
  3. Poll GET /payments/{id}/status on a timer. Show confirmations / requiredConfirmations.
  4. On COMPLETED, send the buyer to returnUrl.

Then, on your server, ignore all of it and settle on the webhook. The buyer's browser tells you what to draw; it never tells you what you got paid.

NextWhat it covers
Accepting Crypto PaymentsThe signed, server-side path to the same payment
WebhooksThe only trustworthy settlement signal
Chains, Currencies & ConfirmationsWhat to put in the chain picker