Docs
GuidesAccepting Crypto Payments

Accepting Crypto Payments

Intermediate25 min

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

text
DRAFT โ”€โ”€โ–ถ OPEN โ”€โ”€โ–ถ PAYMENT_PENDING โ”€โ”€โ–ถ PAID
             โ”‚            โ”‚
             โ–ผ            โ–ผ
        CANCELLED      EXPIRED

InvoiceStatus has six values: DRAFT, OPEN, PAYMENT_PENDING, PAID, EXPIRED, CANCELLED. What writes each transition matters more than the diagram:

  • OPEN is written by POST /api/v1/invoices. DRAFT is the column default but the gateway never leaves an invoice there โ€” a created invoice is immediately payable.

  • PAYMENT_PENDING is written by checkout, at the moment a deposit address is leased. Checkout on an invoice that is not OPEN fails with INVOICE_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 read OPEN, both pass the check, and both lease an address, leaving you two live payments for one invoice.

    Idempotency-Key does 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.

  • CANCELLED is written by DELETE /api/v1/invoices/{id}, and only from OPEN or DRAFT. Once checkout has opened a payment the invoice is PAYMENT_PENDING and the call fails with INVOICE_CANNOT_BE_CANCELLED. Let the payment expire instead.

  • PAID follows 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.

  • EXPIRED relates to expiresAt. Expiry is enforced when someone acts on the invoice: checkout compares now against expiresAt and refuses with INVOICE_EXPIRED, and the public payment page answers 400 INVOICE_EXPIRED rather than 200. Do not wait for a status flip to decide an invoice is dead โ€” compare expiresAt yourself.

1. Create the invoice

Shell
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:

TypeScript
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);
JSON
{
  "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

CodeHTTPMeaning
INVOICE_NOT_FOUND404Unknown id, or an invoice belonging to a different merchant point
INVOICE_NOT_AVAILABLE400The invoice is not OPEN โ€” usually already checked out
INVOICE_EXPIRED400now is past expiresAt
INVALID_CURRENCY400That token is not configured on that chain
PAYMENT_ALREADY_IN_PROGRESS400A PENDING, PROCESSING or CONFIRMING payment is already open on this invoice
NO_AVAILABLE_WALLET503The 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:

StatusWhat it means
PENDINGAddress leased, nothing seen on chain yet
PROCESSINGA transaction is matched to the payment but has zero confirmations
CONFIRMING0 < confirmations < requiredConfirmations
COMPLETEDconfirmations >= requiredConfirmations; completedAt is stamped
FAILEDThe 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:

Shell
curl "https://api2.havala.io/api/v1/payments/clyv4p1c70003qh7m8w2r6z9f/status" \
  -H "X-API-Key: $HAVALA_API_KEY" \
  -H "X-Signature: t=1787735900,v1=<hmac>"
JSON
{
  "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.

FieldMeaning
expectedAmountWhat the buyer was told to send
receivedAmountWhat the chain watcher actually observed. Starts at "0"
paymentAmountSerialised 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:

TypeScript
// 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 with INVOICE_EXPIRED and the public invoice read answers 400, not 200.
  • Past payment.expiresAt, the checkout API refuses to accept a confirmation for it with PAYMENT_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.

NextWhat it covers
WebhooksVerifying payment.completed and staying idempotent
Chains, Currencies & ConfirmationsThe chain/token matrix and confirmation counts
The Hosted Checkout PageLetting the buyer pick the coin
Testing on TestnetsRunning this end to end without real money