Accepting Crypto Payments
The full invoice lifecycle: create an invoice, check it out onto a chain, hand the buyer a deposit address, watch it confirm.
Two objects carry a Havala payment. The invoice is what you owe the buyer a receipt for: an order id, an amount in a fiat currency, an expiry. The payment is the on-chain attempt to satisfy it: a chain, a coin, a deposit address, a transaction hash, a confirmation count.
One invoice can have several payments โ a buyer who abandons Bitcoin and comes back on TRON leaves two rows behind. Money is settled on the payment, so that is the object your fulfilment logic should read.
The invoice lifecycle
DRAFT โโโถ OPEN โโโถ PAYMENT_PENDING โโโถ PAID
โ โ
โผ โผ
CANCELLED EXPIREDInvoiceStatus has six values: DRAFT, OPEN, PAYMENT_PENDING, PAID,
EXPIRED, CANCELLED. What writes each transition matters more than the
diagram:
-
OPENis written byPOST /api/v1/invoices.DRAFTis the column default but the gateway never leaves an invoice there โ a created invoice is immediately payable. -
PAYMENT_PENDINGis written by checkout, at the moment a deposit address is leased. Checkout on an invoice that is notOPENfails withINVOICE_NOT_AVAILABLEโ but note how that check runs: the status is read, compared, and then written back in separate statements, with the wallet lease in between. It is not a compare-and-swap. Two checkouts racing on the same invoice can both readOPEN, both pass the check, and both lease an address, leaving you two live payments for one invoice.Idempotency-Keydoes not save you here: it replays the same request, and these are two different requests. If your code can issue concurrent checkouts for one invoice โ a double-clicked button, a retried job that did not cancel cleanly โ serialise them on your side before they reach Havala. -
CANCELLEDis written byDELETE /api/v1/invoices/{id}, and only fromOPENorDRAFT. Once checkout has opened a payment the invoice isPAYMENT_PENDINGand the call fails withINVOICE_CANNOT_BE_CANCELLED. Let the payment expire instead. -
PAIDfollows settlement of a payment against the invoice. It is not something you set, and it is not the signal to fulfil on โ see Settlement below. -
EXPIREDrelates toexpiresAt. Expiry is enforced when someone acts on the invoice: checkout comparesnowagainstexpiresAtand refuses withINVOICE_EXPIRED, and the public payment page answers400 INVOICE_EXPIREDrather than200. Do not wait for a status flip to decide an invoice is dead โ compareexpiresAtyourself.
1. Create the invoice
curl -X POST https://api2.havala.io/api/v1/invoices \
-H "X-API-Key: $HAVALA_API_KEY" \
-H "X-Signature: t=1787735662,v1=<hmac>" \
-H "Idempotency-Key: 9f2b1c44-7a0e-4d63-9f18-2c5b7ea31d04" \
-H "Content-Type: application/json" \
-d '{
"orderId": "order-8814",
"amount": "12500",
"currency": "USD",
"description": "Pro plan, annual billing",
"customerEmail": "buyer@example.com",
"returnUrl": "https://merchant.example.com/checkout/success",
"cancelUrl": "https://merchant.example.com/checkout/cancel",
"expiresInMinutes": 60
}'amount is a string of minor units in currency โ "12500" with "USD" is
$125.00. It is stored as a BigInt, which is why it crosses the wire as a
string in both directions. No cryptocurrency is named at this point; that is the
buyer's choice.
expiresInMinutes accepts 15 to 1440 and defaults to 60. It sets expiresAt,
which then caps the payment window later. metadata and customerMetadata are
arbitrary JSON, stored verbatim and echoed back unchanged โ that is where your
internal references belong, since orderId is capped at 255 characters and is
indexed for lookup.
Keep the returned id (a CUID). referenceId (INV-YYMMDD-NNNL) is a human
handle for receipts and support; no endpoint accepts it as a lookup key.
2. Check out: choose a chain and get an address
Nothing happens to an OPEN invoice until it is checked out. There are two ways
to do that, and they differ in who chooses:
- You choose, server-side, with
POST /api/v1/invoices/{id}/checkout. Use this when your own UI already collected "pay with USDT on TRON". - The buyer chooses, on the hosted page at
pay.havala.io, which calls the public checkout API. Use this when you want the chain picker, QR code and polling built for you. See The Hosted Checkout Page.
The signed, server-side version:
const { invoice, payment } = await havala(
'POST',
`/api/v1/invoices/${invoiceId}/checkout`,
{ blockchain: 'ethereum', cryptoCurrency: 'USDT' }
);
// Send the buyer here. The address is leased to this payment alone.
console.log(payment.paymentAddress, payment.expiresAt);{
"invoice": {
"id": "clyv3n8x40001qh7m2k9d5b1t",
"referenceId": "INV-260826-041C",
"status": "PAYMENT_PENDING",
"amount": "12500",
"currency": "USD",
"expiresAt": "2026-08-26T10:14:22.481Z"
},
"payment": {
"id": "clyv4p1c70003qh7m8w2r6z9f",
"referenceId": "PAY-260826-118A",
"paymentAddress": "0x7Ae2d4C1f83B90e5A6c7D218Ff43b9C0E15d8a72",
"amount": "12500",
"blockchain": "ethereum",
"cryptoCurrency": "USDT",
"status": "PENDING",
"expiresAt": "2026-08-26T09:44:26.902Z",
"createdAt": "2026-08-26T09:14:26.902Z"
}
}blockchain is a lowercase slug (ethereum, polygon, bsc, arbitrum,
optimism, base, avalanche, bitcoin, tron, solana, ton) and
cryptoCurrency must be a token that chain actually carries โ the pair is
validated, and an unsupported one is INVALID_CURRENCY. See
Chains, Currencies & Confirmations for the matrix.
paymentAddress is a one-time address leased from Havala's wallet pool and tied
to this payment. Do not cache it, do not reuse it for a second order, and do not
show it after payment.expiresAt.
The payment window is min(now + 30 minutes, invoice.expiresAt). An invoice
created with expiresInMinutes: 20 gives the buyer 20 minutes to pay, not 30.
If you want a full half-hour at the address, give the invoice room.
What can go wrong here
| Code | HTTP | Meaning |
|---|---|---|
INVOICE_NOT_FOUND | 404 | Unknown id, or an invoice belonging to a different merchant point |
INVOICE_NOT_AVAILABLE | 400 | The invoice is not OPEN โ usually already checked out |
INVOICE_EXPIRED | 400 | now is past expiresAt |
INVALID_CURRENCY | 400 | That token is not configured on that chain |
PAYMENT_ALREADY_IN_PROGRESS | 400 | A PENDING, PROCESSING or CONFIRMING payment is already open on this invoice |
NO_AVAILABLE_WALLET | 503 | The wallet pool for that chain had no free address to lease |
PAYMENT_ALREADY_IN_PROGRESS is the one that surprises people. A buyer who
picks Ethereum, then changes their mind and picks TRON, is blocked until the
first payment expires โ the address is already leased and Havala will not run
two live deposit addresses against one invoice. Surface it as "you already have
a payment open, finish it or wait for it to expire", not as an error.
3. Watch the payment confirm
PaymentStatus has seven values: PENDING, PROCESSING, CONFIRMING,
COMPLETED, FAILED, EXPIRED, REFUNDED. Three of them are the normal path:
| Status | What it means |
|---|---|
PENDING | Address leased, nothing seen on chain yet |
PROCESSING | A transaction is matched to the payment but has zero confirmations |
CONFIRMING | 0 < confirmations < requiredConfirmations |
COMPLETED | confirmations >= requiredConfirmations; completedAt is stamped |
FAILED | The transaction reverted on chain. metadata.failure_reason says so |
requiredConfirmations is fixed on the payment row when it is created, from the
chain and whether it is a testnet โ 12 on Ethereum mainnet, 128 on Polygon, 3 on
Bitcoin, 19 on TRON, and so on. It never changes for that payment, so a
confirmation count is safe to compare against the value on the payment itself
rather than a table in your code.
The tracker re-checks on a cadence derived from the chain's block time (250 ms
on Arbitrum, 12 s on Ethereum, 10 minutes on Bitcoin, with a 5-second floor), so
a Bitcoin payment legitimately sits in CONFIRMING for half an hour.
Poll the cheap projection while a buyer is watching:
curl "https://api2.havala.io/api/v1/payments/clyv4p1c70003qh7m8w2r6z9f/status" \
-H "X-API-Key: $HAVALA_API_KEY" \
-H "X-Signature: t=1787735900,v1=<hmac>"{
"id": "clyv4p1c70003qh7m8w2r6z9f",
"referenceId": "PAY-260826-118A",
"status": "CONFIRMING",
"confirmations": 46,
"requiredConfirmations": 128,
"expiresAt": "2026-08-26T11:02:18.000Z",
"completedAt": null
}Remember that the signed path excludes the query string. If you list payments
with ?status=COMPLETED, sign /api/v1/payments and nothing else.
Settlement
When confirmations reaches requiredConfirmations, the payment goes
COMPLETED, completedAt is stamped, and โ if the merchant point has a
webhookUrl โ Havala posts payment.completed to it. That event is the
settlement signal.
Gate fulfilment on the payment reaching COMPLETED, not on
invoice.status === 'PAID'. The payment carries the confirmation count, the
transaction hash and the received amount; the invoice carries none of that. Code
that waits for PAID will wait on the wrong object.
Delivery is best effort: one attempt, ten-second timeout, only a 2xx counts. Do
not build a flow that assumes it arrived. Pair every webhook handler with a
reconciliation job that re-reads
GET /api/v1/payments/{id}/status
for anything you were expecting and did not hear about. The full delivery and
verification contract is in Webhooks.
Amounts, and why you must check them yourself
A payment carries three amount columns, all VARCHAR(80) decimal strings so
that 18-decimal values survive the round trip. They are strings on the wire,
never numbers โ parse them with a decimal type, never a float.
| Field | Meaning |
|---|---|
expectedAmount | What the buyer was told to send |
receivedAmount | What the chain watcher actually observed. Starts at "0" |
paymentAmount | Serialised as amount. The figure the payment was opened for |
Havala records the difference; it does not resolve it. There is no automatic partial-payment credit, no automatic refund of an overpayment, and the confirmation tracker compares confirmation counts only โ it never compares amounts. So:
// In your payment.completed handler, before you ship anything.
const received = new Decimal(payment.received_amount ?? '0');
const expected = new Decimal(payment.amount);
if (received.lessThan(expected)) {
// Underpaid. The payment is COMPLETED โ it confirmed on chain โ but the
// buyer owes the difference. Hold the order and reconcile manually.
return holdForReview(payment, expected.minus(received));
}
if (received.greaterThan(expected)) {
// Overpaid. Fulfil, then arrange the refund out of band.
flagOverpayment(payment, received.minus(expected));
}
fulfil(payment);One more thing to know before you write that comparison. On the invoice-checkout
path Havala currently opens the payment with paymentAmount and
expectedAmount copied verbatim from invoice.amount โ the fiat minor units โ
because fiat-to-crypto conversion is not yet applied at that step. Read amount
next to cryptoCurrency and the token's decimals rather than assuming it is
already denominated in the coin, and treat the received-versus-expected check as
a reconciliation rule you own.
Expiry
Nothing sweeps rows in the background. Expiry is enforced at the moment someone tries to act:
- Past
invoice.expiresAt, checkout refuses withINVOICE_EXPIREDand the public invoice read answers400, not200. - Past
payment.expiresAt, the checkout API refuses to accept a confirmation for it withPAYMENT_EXPIRED.
For your own reporting, compare timestamps rather than waiting for EXPIRED to
appear. A buyer who pays a stale address is not automatically credited โ that is
a manual reconciliation, which is the strongest argument for keeping invoice
windows short and telling the buyer plainly when the address dies.
Reconciling later
GET /api/v1/invoices/{id} is the only
invoice route that includes the payments relation โ every attempt ever opened
against the invoice, each with its leased address, status and received amount.
Create, checkout and cancel return the invoice without that key at all.
GET /api/v1/payments pages
through the point's payments, newest first, filterable by status and
blockchain (lowercase slug โ not the uppercase enum form). take defaults to
20 and is clamped to 100.
| Next | What it covers |
|---|---|
| Webhooks | Verifying payment.completed and staying idempotent |
| Chains, Currencies & Confirmations | The chain/token matrix and confirmation counts |
| The Hosted Checkout Page | Letting the buyer pick the coin |
| Testing on Testnets | Running this end to end without real money |