The Hosted Checkout Page
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:
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:
- Sees the amount, your company name, and the chains and coins available.
- Picks a chain and a coin.
- Gets a one-time deposit address, a QR code, and a countdown to
payment.expiresAt. - Pays from their own wallet.
- 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.
| Route | Purpose |
|---|---|
GET /checkout/api/v1/invoices/{id} | Everything the page renders |
GET /checkout/api/v1/invoices/{id}/status | Cheap invoice poll |
POST /checkout/api/v1/invoices/{id}/checkout | Lock in a chain and coin, get payment instructions |
GET /checkout/api/v1/payments/{id}/status | Confirmation progress |
POST /checkout/api/v1/payments/{id}/confirm | Attach 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
curl https://api2.havala.io/checkout/api/v1/invoices/clx7k2p9a0001qz8fh3n2v4m6{
"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
viewedAton the invoice. - It stops answering 200 when the invoice dies. An expired or cancelled
invoice returns
400withINVOICE_EXPIREDorINVOICE_CANCELLED, and an unknown id returns404 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
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"}'{
"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 aspaymentAddress. 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 carriescontractAddress,externalId(bytes32),externalIdUuid,treasuryWallet,tokenAddress(nullfor the chain's native coin) andchainIdinstead of an address.havala_walletโ the same contract path through a Havala-managed wallet, and it addsrequiresConfirmation: true, meaning you must call/confirmwith 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
curl https://api2.havala.io/checkout/api/v1/payments/clx7k2p9a0007qz8f1d0s9k2r/status{
"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.
| Route | Per minute |
|---|---|
GET /invoices/{id} | 100 |
GET /invoices/{id}/status | 60 |
POST /invoices/{id}/checkout | 10 |
GET /payments/{id}/status | 60 |
POST /payments/{id}/confirm | 5 |
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:
GET /invoices/{id}once, on mount. Render the amount, the merchant name and the chain picker fromavailableBlockchains/availableCurrencies.- On selection,
POST /invoices/{id}/checkout. RenderpaymentAddressas text and as a QR, and start a countdown toexpiresAt. - Poll
GET /payments/{id}/statuson a timer. Showconfirmations / requiredConfirmations. - On
COMPLETED, send the buyer toreturnUrl.
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.
| Next | What it covers |
|---|---|
| Accepting Crypto Payments | The signed, server-side path to the same payment |
| Webhooks | The only trustworthy settlement signal |
| Chains, Currencies & Confirmations | What to put in the chain picker |