Guide

How to integrate a crypto checkout

You want customers to pay in crypto and your order to flip to paid on its own — no staff watching a block explorer, no fulfilling against a screenshot. A checkout integration is three decisions made in order: how the customer sees the payment page (a halfin-hosted redirect or a checkout you render yourself), how your backend learns the money arrived (a verified invoice.paid webhook, not polling), and how you stay correct when the webhook is late, duplicated, or never seen (reconcile against the API by invoice id). This guide walks all three end to end, with the exact create call and the rule that keeps fulfilment safe: never act on a payment event until its HMAC signature checks out.

01

The shape of a checkout: invoice in, webhook out

Strip away the UI and every crypto checkout is the same loop. Your backend creates an invoice for an amount; the customer pays it on-chain through some payment page; the chain confirms; halfin sends your backend a signed event; you fulfil. The invoice is the object both sides agree on — you create it, the customer pays it, and the webhook references it by id. Everything in this guide hangs off that one object.

Two things are not your job, and that is the point of integrating rather than building. You do not run nodes, derive addresses, count confirmations, or handle reorgs — halfin does, with reorg-aware crediting and per-chain confirmation thresholds, so a paid event reflects money that actually held. And you do not have to render the wallet UI unless you want to; the hosted checkout is a finished page you redirect to.

So the integration reduces to three choices made in sequence. First, how the customer sees the payment page: redirect to halfin's hosted checkout, or render your own against the same API. Second, how your backend hears that the invoice is paid: a signed webhook, verified before you trust it. Third, how you reconcile when a webhook is delayed, redelivered, or lost: read the invoice back by id. Get those three right and the checkout runs itself.

  • The invoice is the shared object: you create it, the customer pays it, the webhook references it.
  • halfin owns the chain: address derivation, confirmations, reorg-aware crediting — not your code.
  • Decision 1 — hosted redirect or self-hosted render for the payment page.
  • Decision 2 — fulfil off a verified webhook, never a polling loop or the customer's word.
  • Decision 3 — reconcile against the API by invoice id when the webhook is late or missing.
02

Step 1 — Choose hosted redirect or self-hosted

The first decision is who renders the payment page, and it is mostly about how much of the surface you need to own. Hosted checkout is a finished page at checkout.thehalfin.com: you create the invoice, redirect the customer to the URL halfin returns, and the QR code, address, network picker, countdown, and live paid state are all rendered and maintained by halfin. You write none of the wallet code. This is the right default for a storefront — you ship in an afternoon and never touch a block explorer.

Self-hosted checkout draws the line differently: you render the payment step inside your own UI — your brand, your layout, your analytics — and call the same REST API and the same signed webhooks underneath. You take on the front-end work of presenting the address, amount, network, and countdown; halfin still owns every on-chain concern. Reach for this only when a redirect to a third-party domain genuinely breaks the product: a trading-platform funding flow, a game top-up screen, an embedded B2B portal where the checkout is part of the experience rather than a detour.

The decision does not change the backend in this guide. Both paths create the invoice the same way and both reconcile off the same webhook — the only difference is whether you send the customer to halfin's URL or draw the payment screen yourself. Pick hosted unless you have a concrete reason the redirect costs you a conversion; the self-hosted work is real and you should spend it deliberately.

Hosted checkoutSelf-hosted checkout
Who renders the pagehalfin, at checkout.thehalfin.com — you redirect to it.You, in your own UI, against the same API.
Wallet / QR / network UIBuilt and maintained by halfin.You render it from the invoice fields.
On-chain plumbinghalfin (addresses, confirmations, reorgs).halfin (addresses, confirmations, reorgs).
Customer returnSent back to your redirect_url after payment.Stays on your page; you drive the post-paid state.
Reach for it whenA storefront — the common case.A redirect would break the product flow.
03

Step 2 — Create the invoice from your backend

Whichever page the customer sees, your server creates the invoice with one authenticated POST. Authenticate with a scoped API key in the X-API-Key header — there is no Idempotency-Key header to set; idempotency is a snake_case field in the JSON body. You can bill two ways. Anchor the charge to fiat by sending amount_fiat plus fiat_currency with deferred set to true, and halfin locks the conversion rate when the invoice activates and pins the payable asset amount. Or set a fixed asset price by sending currency (the crypto, for example USDT) plus amount as a string. Most storefronts price in fiat, so that is the call shown below.

Send the amount as a string, never a JavaScript number — monetary values are strings end to end, and the moment one becomes a float you have invited rounding drift between your ledger and the chain. Pass an idempotency_key so a retried create — a timeout, a double-submit, a queue redelivery — returns the existing invoice instead of opening a second one for the same order. Derive that key from your own order id so it is reproducible; a fresh UUID generated at send time defeats the dedup on the retry.

Set redirect_url to the page you want the customer returned to after they pay — your order-confirmation route. That return is a navigation, not proof of payment: a customer can close the tab before it fires, or land on it before the chain confirms. Treat it purely as UX. The authoritative signal that the order is paid is the webhook in Step 3, never the arrival on your redirect_url. The response carries the invoice id and, for the hosted path, the checkout URL you redirect to; the full response schema lives at docs.thehalfin.com.

# Create a fiat-anchored invoice from your backend. The rate locks at
# activation; halfin returns an invoice id and a hosted checkout URL.
curl -sS -X POST https://api.thehalfin.com/api/v1/invoices \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $HALFIN_API_KEY" \
  -d '{
    "amount_fiat": "49.00",
    "fiat_currency": "USD",
    "deferred": true,
    "description": "Order #10492",
    "redirect_url": "https://shop.example.com/orders/10492/paid",
    "idempotency_key": "order-10492"
  }'

# Hosted path: redirect the customer to the checkout URL in the response.
# Self-hosted path: render your own page from the invoice fields instead.
# Either way, you fulfil off the signed webhook — not this response.
# Full request and response schema: docs.thehalfin.com
04

Step 3 — Fulfil off the verified invoice.paid webhook

Your backend cannot see the blockchain, so something has to tell it the invoice settled. That something is a webhook: an HMAC-signed HTTP POST halfin sends to your endpoint the moment the invoice changes state. Do not poll the API on a timer waiting for paid — fast enough to feel responsive means hammering the endpoint, slow enough to be polite means the customer stares at a waiting screen long after their money confirmed. Subscribe to the invoice events and let halfin push.

The event that fulfils the order is invoice.paid — it fires only after on-chain confirmations reach the chain's threshold, with reorg-aware crediting, so it means money that actually held, not an optimistic first-seen. Before you act on it, verify the signature: recompute the HMAC over the exact raw request bytes using your endpoint's signing secret and compare it to the signature header with a constant-time compare. Your webhook URL is public the instant you register it, so anyone can POST forged JSON at it; the signature is the only thing separating a real halfin event from an attacker fulfilling an order for free. An unsigned or mismatched request gets a 4xx and nothing else — never fulfil, credit, or ship off the payload alone.

Handle the in-between states too, because real customers fat-finger amounts and exchanges skim withdrawal fees. invoice.confirming tells you a deposit was seen and is accumulating confirmations — show a waiting state, do not release goods. invoice.underpaid means a real but insufficient payment arrived — hold the order and surface the shortfall. invoice.overpaid settles the order with a surplus to refund or credit. invoice.expired means the quote window closed before payment — let the customer start a new invoice. There is no invoice.activated event; the live-with-a-locked-quote signal is invoice.confirming.

  • Fulfil on invoice.paid; never poll the API in a loop waiting for it.
  • Verify the HMAC over the raw request bytes (constant-time) before acting on any event.
  • Reject unsigned or mismatched requests with a 4xx — the endpoint is publicly reachable.
  • There is no invoice.activated event; the live-quote signal is invoice.confirming.
EventWhat it meansWhat your handler does
invoice.confirmingA deposit was seen; confirmations are accumulating toward the threshold.Show a waiting state; do not fulfil yet.
invoice.paidFull amount confirmed under the chain's threshold; reorg-aware.Fulfil the order — idempotently.
invoice.underpaidA real payment arrived but is below the amount due.Hold the order; surface the shortfall for follow-up.
invoice.overpaidSettled with a surplus over the amount due.Fulfil and flag the surplus for refund or credit.
invoice.expiredThe quote window closed before sufficient payment arrived.Cancel the order; let the customer start a new invoice.
05

Step 4 — Make the handler idempotent and fast

Webhook delivery is at-least-once, not exactly-once. A network blip, a slow response, or a 5xx from your endpoint causes halfin to redeliver, and the same logical invoice.paid can legitimately arrive more than once — carrying the same stable event id each time. If your handler ships the order on every POST, a redelivery double-ships it. Build around the id: record processed event ids and make the side effect a no-op the second time you see one, so fulfilment runs exactly once no matter how many copies land.

Acknowledge quickly and push the heavy lifting elsewhere. Return 2xx as soon as you have verified the signature and durably recorded the event, then do order fulfilment, emails, and ledger writes on a background queue. A handler that does slow synchronous work risks a timeout, which halfin reads as a failed delivery and retries — multiplying the work it was already struggling to finish. Persist the raw event before you acknowledge, so a crash between ack and fulfilment cannot silently drop an order.

Tie the event back to your order through the invoice id you stored at create time, not through the redirect_url or anything the browser told you. The webhook's data object references the same invoice you created in Step 2; look the order up by that id, flip it to paid, and you have a closed loop that does not depend on the customer's tab staying open.

  • Dedupe on the stable event id; a redelivered invoice.paid must fulfil the order exactly once.
  • Return 2xx fast after verifying and recording; defer fulfilment, email, and ledger writes to a queue.
  • Persist the raw event before acknowledging so a crash can't lose an order.
  • Key the order off the invoice id from the event, never off the redirect_url navigation.
06

Step 5 — Reconcile against the API, don't depend on the webhook alone

A webhook is the fast path, not the only path. Endpoints have outages, deploys drop in-flight requests, and at-least-once delivery does not promise a specific moment of delivery. So before any irreversible action — shipping physical goods, granting an entitlement, releasing a download — confirm against the source of truth by reading the invoice back from the API by its id. The webhook tells you to look; the API read tells you the current, canonical state.

Run a reconciliation sweep on a schedule as a safety net for missed events. Periodically take your orders that are still awaiting payment past a sensible age and read each invoice by id: if the API says paid but your order is not, the webhook was lost and you fulfil now; if it says expired, you release the reservation. This sweep is also how you handle the rare case where your endpoint was down for the entire delivery-and-retry window — the order still settles, just on the next sweep instead of in real time.

Keep the two paths consistent by routing both through the same fulfilment code. Whether a paid invoice is discovered by a webhook or by a reconciliation read, it should call the identical idempotent fulfil function keyed on the invoice id — so the slow path can never double-ship what the fast path already handled, and the fast path can never skip a step the slow path expects. One fulfilment function, two triggers, exactly-once effect.

07

Step 6 — Go live: keys, sandbox, and a tampered-event test

Before production, exercise the whole path in the sandbox. Create an invoice against your test key, pay it, and watch your endpoint receive invoice.confirming and then invoice.paid. Confirm your order flips to paid off the webhook and not off the redirect_url by deliberately closing the tab before the return fires — the order should still settle. This is the test that proves your integration depends on the signal that is actually authoritative.

Prove the signature check the other way too. Take a genuine event body, flip one byte, POST it to your endpoint, and confirm you return a 4xx and do nothing — no fulfilment, no email. A checkout that fulfils on an unsigned body is a checkout that ships goods for free to anyone who finds the URL. Then point the same test at a redelivery: send the same event id twice and confirm the order ships exactly once.

Operate with scoped, separated keys. The service that creates invoices holds an invoicing-scoped key; your reporting holds a read-only key; nothing shares one credential. Store the webhook signing secret in your secret manager, never in source control, and rotate it if you suspect exposure. In production, log the event id and the verification result for every webhook, so when support asks why an order did not fulfil you can point to the exact event and whether it was received, verified, and acted on.

  • Sandbox-test the full path; confirm the order settles even if the customer never hits redirect_url.
  • Tamper-test: flip a byte, expect a 4xx and zero side effects.
  • Redelivery-test: send the same event id twice, expect exactly one fulfilment.
  • Use scoped keys per service; keep the signing secret in a secret manager and rotate on exposure.