What a crypto invoice actually is here
An invoice is a request for a specific amount, payable to an address you do not have to manage, valid for a bounded window. The detail that makes it usable for a real business is that you state the amount in fiat — your home currency, the number your accounting cares about — and halfin works out the crypto the customer sends. You bill 49.00 USD; the customer pays the dollar-equivalent in whatever supported asset they hold; you reconcile the clean 49.00 figure.
Two things have to be true for that to be safe. First, the crypto amount the customer is shown must not move after you commit to it, or you will be short when the market ticks. halfin handles this with a rate lock at activation. Second, the bill cannot stay payable forever against a stale quote, so each invoice carries an expiry. The rest of this guide is mostly about creating the invoice correctly and then reacting to the events it emits — the locking, expiry, confirmation counting, and reorg handling are the platform's job, not yours.
Before you write any code, get one credential in place: a scoped API key from the dashboard. Use a key scoped to invoicing for the service that creates invoices, and keep it out of source control. The same key works against the public REST API and the @halfin/sdk-merchant TypeScript client.
Step 1 — Create a fiat-anchored invoice
The core call is a single POST to the invoices endpoint. You authenticate with the API key, send the fiat amount as a string and the fiat currency code, and pass an idempotency key so a retried request never issues the customer two bills for the same thing. Setting deferred:true creates the invoice without locking a rate yet — the lock happens when you activate it for the customer (Step 2), which is what keeps the payment window from burning down while the bill sits in a queue.
Amounts are strings end to end. Monetary values are never floats here — sending "49.00" rather than 49.00 avoids the rounding drift that bites every naive money integration. The fiat_currency is your anchor (USD, EUR); you are not choosing the crypto at create time, because the customer chooses the asset when they pay.
The call below is the minimal, correct shape. The response carries the invoice id and, when you present it through hosted checkout, a payment URL on checkout.thehalfin.com. Store the id against whatever you are billing for — an order, a cycle, a top-up — so the webhook in Step 4 maps straight back to it. The full request and response schema live in the API reference at docs.thehalfin.com.
- Anchor to fiat: send amount_fiat and fiat_currency, not a crypto amount.
- Send amounts as strings — never floats — to avoid rounding drift.
- Pass an idempotency_key tied to the thing you are billing, so retries don't duplicate.
- Use deferred:true so the rate locks at activation, not while the invoice waits in a queue.
curl -X POST https://api.thehalfin.com/api/v1/invoices \
-H "X-API-Key: $HALFIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount_fiat": "49.00",
"fiat_currency": "USD",
"deferred": true,
"description": "Pro plan — March",
"idempotency_key": "order_4821"
}'
# The response carries the invoice id (store it against your order) and,
# when presented via hosted checkout, a payment URL on checkout.thehalfin.com.
# deferred:true defers the rate lock to activation — see Step 2.
# Full request/response schema: docs.thehalfin.com.Fixed-asset invoices, when you already know the coin
Most billing is fiat-anchored, but sometimes you want to bill a precise crypto amount — a customer who agreed to send exactly 0.01 BTC, a top-up denominated in the asset itself, a settlement where the token amount is the contract. For that, create the invoice with an asset amount and a crypto currency code instead of a fiat anchor. The currency here is the crypto code (BTC, ETH, USDT), not a fiat code.
The distinction is worth stating plainly because it is the one field people get wrong: a fiat-anchored invoice carries amount_fiat plus fiat_currency:"USD"; a fixed-asset invoice carries amount plus currency:"BTC". You never send currency:"USD" — USD is a fiat anchor and travels in fiat_currency. Pick the model that matches what you are actually quoting, and keep the rest of the flow identical.
| You want to bill | Send | Example |
|---|---|---|
| A dollar/euro amount, customer's choice of coin | amount_fiat + fiat_currency | {"amount_fiat":"49.00","fiat_currency":"USD"} |
| An exact crypto amount in a specific asset | amount + currency (crypto code) | {"amount":"0.01","currency":"BTC"} |
Step 2 — Understand the rate lock and the expiry
Crypto prices move on a timescale that matters during a checkout. If you quoted a rate when the invoice was drafted and the customer pays twenty minutes later, the token amount can drift enough to leave you short or leave them overpaying. halfin closes that window by locking the conversion rate at activation — the moment the invoice goes live and is presented for payment — not at creation. That is what deferred:true in Step 1 buys you: a quote fixed against the rate the customer actually sees.
Locking without a deadline would just trade one risk for another, so activation also stamps an expiry. The customer has a defined window to pay at the exact quote they were shown. Pay inside the window and the asset amount they saw settles to your billed fiat figure. Let the window lapse and the invoice is marked expired rather than silently re-pricing against a newer rate — at which point you decide whether to re-issue at the current rate.
For your code this means: do not treat an expired invoice as a failure or a lost customer. It is the system refusing to honor a stale price on your behalf. When a customer comes back late, create a fresh invoice; they get a current quote and a new window, and nobody is exposed to a rate they did not agree to.
Step 3 — Present the invoice to the customer
You have two ways to get the customer to a payment surface, both backed by the same invoice object. Hosted checkout is the smallest integration: the create-invoice response carries a payment URL on checkout.thehalfin.com, you send the customer there, and halfin renders the page — the address, the exact amount, the network choice, the QR code, and a live status that updates as the payment confirms. You own nothing on the payment screen; the customer pays and is returned to a success URL you control.
Self-hosted checkout is for when the payment screen has to live inside your own product. Same API, same invoice, but you render the address, amount, network, and countdown yourself and poll or subscribe for status. Reach for this when brand or layout control matters more than integration speed; reach for hosted checkout otherwise.
Whichever you choose, present four things clearly and the customer rarely gets stuck: the asset and network you expect (USDT on Tron is not USDT on Ethereum — the network is part of the instruction), the exact amount to send, the address, and how long the quote is valid. A QR code that encodes the address and amount removes most copy-paste mistakes. Do not, however, treat the customer landing on your success page as proof of payment — that is what Step 4 is for.
- Hosted checkout: send the customer to the checkout.thehalfin.com URL from the create response.
- Self-hosted checkout: render address, amount, network, and countdown against the same invoice.
- Always show the network, not just the asset — USDT on Tron differs from USDT on Ethereum.
- A QR encoding address + amount prevents most manual-entry errors.
Step 4 — Confirm payment with a signed webhook
A payment is not final the instant a wallet says "sent". The transaction has to land in a block and accumulate enough confirmations that a chain reorganization is no longer a realistic risk. halfin applies a per-chain confirmation threshold with reorg-aware crediting, so an invoice it reports as paid has settled under that chain's rules — not merely been seen in the mempool. Your job is to key your own action off the final state, and the reliable carrier of that state is the webhook, not the customer's redirect.
Why not the redirect? A customer pays on their phone, the wallet app foregrounds, the browser tab is gone, and your success page never loads. If your only signal is the redirect, you will silently miss payments that actually completed. The webhook arrives at your server independently of whatever the customer's browser did. When the invoice reaches paid, halfin POSTs an HMAC-signed event; verify the signature over the raw request bytes with a constant-time compare before you read the body as a business fact, and only then fulfil the order. An unsigned or mismatched request is not a halfin event and must never trigger fulfilment.
Build the handler to be idempotent — an event can be redelivered, and a second invoice.paid for the same id must not ship the order twice. The canonical events for an invoice are below. The first one you will see for a live invoice is invoice.confirming (there is no invoice.activated event); paid, overpaid, underpaid, and expired are the terminal outcomes. Full event schemas are in the docs at docs.thehalfin.com.
| Webhook event | What it means | What your handler does |
|---|---|---|
| invoice.confirming | A matching deposit is on-chain; confirmations are accumulating toward the threshold | Show "payment in flight" — do not release goods yet |
| invoice.paid | Confirmed past the chain's threshold; the billed fiat amount is settled | Fulfil the order (idempotently), reconcile against the fiat figure |
| invoice.overpaid | Settled with a surplus over the amount due | Fulfil, and flag the excess for refund or credit |
| invoice.underpaid | A real payment arrived but is below the amount due | Hold; request a top-up or settle partially per your policy |
| invoice.expired | The quote window closed before sufficient payment | Re-issue at the current rate if the customer still wants to pay |
A quick end-to-end checklist
Putting the four steps together, here is the shape of a correct issue-and-confirm flow you can check your integration against. None of it is exotic — it is the same create, present, and confirm loop you would run for any payment, with the crypto-specific care concentrated in the rate lock and the confirmation wait, both of which the platform handles for you.
- Hold a scoped invoicing API key in your secret manager — never in source control.
- Create the invoice anchored to fiat (amount_fiat + fiat_currency), with an idempotency key.
- Store the returned invoice id against your order before you present anything.
- Send the customer to hosted checkout, or render the address/amount/network/countdown yourself.
- Wait for the signed invoice.paid webhook — verify the HMAC before acting; never trust the redirect.
- On expiry, re-issue at the current rate rather than honoring a stale quote.