What 'accept Bitcoin' actually involves
Accepting a card is synchronous: you call an API, it returns approved or declined, you ship the order. Bitcoin is not that. A payment is a transaction the customer signs and broadcasts to the network; it sits in the mempool, gets mined into a block, and only becomes trustworthy after enough subsequent blocks make a reorganization unrealistic. Nothing about that timeline happens inside your application, and none of it returns synchronously from a single call.
So the integration has a shape. You create an invoice that names what is owed and on which asset. You present a payment surface — a hosted page or a deposit address with the amount and a QR code. The customer pays from their wallet. The platform watches the chain, waits for Bitcoin's per-chain confirmation threshold with reorg-aware crediting, and tells your backend the moment the invoice is settled. You fulfil on that signal, not on the customer's word that they sent it.
Two decisions sit at the front of this. First, do you price the invoice in fiat (you book $49, the customer pays the BTC equivalent) or fix it in BTC (you ask for exactly 0.01 BTC)? Second, do you let halfin host the checkout page or do you render your own? The next two sections take those in order; everything after is the same regardless of how you answer.
Step 1 — Decide how you price: fiat-anchored or fixed BTC
Most businesses think in their home currency. Your catalogue is in dollars or euros, your accounting reconciles in fiat, and you do not want the amount you booked to drift because BTC moved while the customer was opening their wallet. For that case, create a fiat-anchored invoice: you send an amount and a fiat currency, and halfin computes the payable BTC amount and locks that rate when the invoice activates. The customer pays the BTC equivalent of $49; you reconcile the clean $49.
Sometimes the price genuinely is denominated in Bitcoin — a fixed on-chain fee, a BTC-priced product, a payment a counterparty agreed to in coin. For that, create a fixed-asset invoice: you send a BTC amount and the currency BTC, and the invoice asks for exactly that. There is no fiat conversion to lock because there is no fiat anchor; the customer owes the coin amount you named.
The request bodies differ in exactly one way, and getting it right is the single most common mistake. A fiat-anchored request carries the fiat amount and the fiat currency. A fixed-BTC request carries the coin amount and a crypto currency code. The currency field on a fixed-asset invoice is the crypto code (BTC), never a fiat code — there is no such thing as an invoice whose currency is USD and whose amount is a coin amount. Pick the model that matches what you actually booked.
| You booked | Use | Request carries | Customer pays |
|---|---|---|---|
| A fiat price ($49) | Fiat-anchored invoice | amount_fiat + fiat_currency | The BTC equivalent at the locked rate |
| A coin price (0.01 BTC) | Fixed-asset invoice | amount + currency (BTC) | Exactly that BTC amount |
Step 2 — Create the invoice
Invoicing is spec-first REST. You authenticate with a scoped API key, post the amount, and get back an invoice carrying its id, the payable BTC amount, a Bitcoin deposit address, and a hosted checkout URL on checkout.thehalfin.com. Send the amount as a string — monetary values are strings end to end, never floats — and pass an idempotency key so a retried create call returns the same invoice instead of billing the customer twice.
The curl below creates a fiat-anchored invoice: you book $49 and the customer pays the BTC equivalent at the rate locked on activation. To ask for a fixed coin amount instead, replace the body with the fixed-asset shape and keep everything else the same. The same call works through the @halfin/sdk-merchant TypeScript client if your backend is in TypeScript. Field names and the full response schema live in the API reference at docs.thehalfin.com — this is the request shape and the moving parts, not an exhaustive field list.
# Fiat-anchored: you book USD, the customer pays the BTC equivalent.
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",
"description": "Order #1024",
"idempotency_key": "00000000-0000-4000-8000-000000000001"
}'
# Fixed BTC instead — the customer owes exactly this coin amount:
# -d '{ "amount": "0.01", "currency": "BTC", "description": "Order #1024" }'
#
# The response carries the invoice id, the payable BTC amount, a Bitcoin
# deposit address, and a hosted checkout URL on checkout.thehalfin.com.
# Store the id, send the customer to the URL, and wait for the signed
# invoice webhook. See docs.thehalfin.com for the full response schema.Step 3 — Present a payment surface: hosted checkout or your own address
The create-invoice response gives you two ways to collect the payment, and you pick based on how much of the experience you want to own. The fastest path is hosted checkout: redirect the customer to the checkout URL on checkout.thehalfin.com, and halfin renders the Bitcoin address, the exact payable amount, a scannable QR code, the countdown to expiry, and a live payment status that updates as the deposit is seen and confirmed. You build almost nothing and the payment page stays current with the chain on its own.
If the payment has to live inside your own product — a checkout step in your billing area, a desk that prefers its own UI — render it yourself from the same invoice object. The invoice already carries the Bitcoin deposit address and the payable amount, so you show the address, render a QR for it, display the amount, and surface the countdown. You are responsible for keeping that view honest, which in practice means driving its state from the same webhook events covered in Step 5 rather than guessing on a timer.
Whichever surface you choose, the customer-facing essentials are the same: the exact BTC amount, the deposit address, a QR code so they can pay from a phone wallet without copy-paste errors, and a visible payment window. Make the amount and address unmissable and copy-safe — a customer who fat-fingers the amount produces an underpaid invoice, which is recoverable but is work you would rather avoid.
- Hosted checkout: redirect to the checkout URL; halfin renders address, amount, QR, countdown, and live status.
- Self-rendered: read the deposit address and payable amount off the invoice and build the page; drive its state from webhooks.
- Always show a QR for the address so phone wallets can pay without manual entry.
- Show the payment window — the quote has an expiry, and an expired invoice should re-issue, not silently re-price.
Step 4 — Wait for confirmations before you treat it as paid
A Bitcoin payment is not final when the customer's wallet says 'sent', and it is not final when the transaction first appears in the mempool. It becomes final as it is mined into a block and subsequent blocks pile on top, making a reorg that unwinds it progressively less likely. halfin applies a per-chain confirmation threshold and credits reorg-aware: an invoice it reports as paid has settled under Bitcoin's rules, not merely been seen on the network.
For your logic this means one hard rule: do not release goods on first sight. The gap between a deposit being seen and the invoice reaching paid is real on Bitcoin — minutes, not seconds — and granting access the instant a transaction appears is how you occasionally ship an order against a payment that later gets reorged away. The platform absorbs the waiting and the reorg handling; your job is to key fulfilment off the final state, which Step 5 delivers as an event.
Not every payment lands exactly on the quote. An underpaid invoice records a real but insufficient amount against the quote — often because the customer paid the network fee out of the same total — and an overpaid invoice records the excess. Both surface as the dedicated webhook events in Step 5, so decide your policy once and let the recorded state drive it rather than discovering the mismatch in a reconciliation report a week later.
| Invoice state | What it means | What your code should do |
|---|---|---|
| Awaiting payment | Invoice live, BTC amount and address shown, waiting before expiry | Show address, amount, QR, and countdown — do not fulfil |
| Payment seen | A matching deposit is in the mempool / a block but under the threshold | Tell the customer it is in flight — still do not fulfil |
| Confirming | Confirmations are accumulating toward Bitcoin's threshold | Wait — crediting is reorg-aware and not yet final |
| Paid | Threshold met; the amount is settled to your balance | Fulfil the order (idempotently) and reconcile |
| Expired | The window elapsed before a sufficient payment arrived | Re-issue at the current rate if the customer still wants to pay |
Step 5 — React to the invoice.paid webhook, not the redirect
When the invoice settles, the customer is usually returned to a success page — but that redirect can be missed. Someone pays from a phone wallet, the wallet app foregrounds, the browser tab is gone, and your success page never loads. If the redirect firing is your only signal that they paid, you will silently fail to fulfil an order that was actually settled. The redirect is a courtesy to the customer, not a source of truth for your backend.
The reliable signal is the webhook. halfin sends your server an HMAC-signed event when the invoice reaches paid, and that event arrives independently of whatever the customer's browser did. Verify it before you trust it: recompute the HMAC over the exact raw request bytes using your endpoint's signing secret, compare it to the signature header in constant time, and only then parse the body and act. The endpoint URL is public the moment you register it, so an unsigned or mismatched request is hostile — return a 4xx and do nothing. Checking the signature before acting is what stops a forged 'paid' callback from shipping a free order.
Once verified, look up the order by the identifier you attached at creation, fulfil it, and record that you did. Keep the handler idempotent — delivery is at least once, and a redelivered invoice.paid must not ship twice or charge-credit twice; dedupe on the stable event id and make the second copy a no-op. Acknowledge with a 2xx quickly and push the slow work (email, fulfilment, ledger writes) onto a queue so a slow handler is not read as a failed delivery and retried. The canonical Bitcoin-relevant events are listed below; invoice.paid is the one that ships the order, but the under/overpaid and late-deposit events are the ones that save you a support ticket.
| Event | Meaning | Typical handler action |
|---|---|---|
| invoice.confirming | A deposit is seen and confirmations are accumulating | Show 'payment in flight' — do not fulfil yet |
| invoice.paid | Confirmed past Bitcoin's threshold; settled to your balance | Fulfil the order, idempotently, by your attached id |
| invoice.underpaid | A real but insufficient BTC amount arrived | Hold; request the remainder or void per your policy |
| invoice.overpaid | More than the quote was received | Fulfil and flag the surplus for refund or credit |
| invoice.expired | The window closed before a sufficient payment | Let the customer start a fresh invoice |
| invoice.late_deposit | A payment arrived after the invoice had expired | Reconcile out of band — credit, refund, or re-bill |
Step 6 — Operate it: test, secure the secret, watch deliveries
Before you go live, exercise the whole path against the sandbox. Create a Bitcoin invoice, pay it, and watch your endpoint receive the confirming and paid events in order. Confirm your signature check passes for a genuine event and fails for a tampered one — flip a byte in the body and make sure you return a 4xx. The point of the dry run is to catch the two failure modes that only show up under real delivery: a body that your framework re-parsed before you computed the HMAC, and a handler that is not idempotent under redelivery.
Treat the webhook signing secret like any other credential. Store it in your secret manager, never in source control, and rotate it if you suspect exposure. Use a scoped API key for invoice creation that is separate from any key that can move money out — a leaked read-or-invoice key should never be able to authorize a payout. Keeping those scopes apart is cheap and is the difference between a leaked credential being an annoyance and being an incident.
In production, keep an eye on delivery health. Log the event id and the verification result for every webhook you receive, so when finance asks why an order did not fulfil you can point to the exact event and whether it was received, verified, and acted on. An endpoint that starts returning non-2xx responses, or one that is briefly unreachable, will see redeliveries pile up — which is fine because the events are idempotent and the ids are stable, but only if you built the handler that way in Step 5.
- Dry-run the full path on the sandbox: create, pay, watch confirming then paid arrive.
- Compute the HMAC over the raw bytes — re-parsed bodies fail an otherwise-valid signature.
- Keep the webhook signing secret in a secret manager; rotate on suspected exposure.
- Scope the invoicing key separately from any payout-capable key.
- Log event id + verification result per request so missed fulfilments are explainable.