What a halfin pay button actually is
There is no 'button' object or embeddable widget to drop in. A pay button is two pieces you already control: a button in your page, and a small backend route it triggers. When the button is clicked, your backend creates an invoice through the REST API, the create call returns a hosted checkout URL on checkout.thehalfin.com, and you redirect the customer to that URL. From there halfin owns the page — it renders the amount, the QR code, the address, the networks you accept, and the live payment status — and you write none of the wallet code behind it.
Getting the shape right up front saves you the one mistake that turns a five-minute integration into a security incident: the invoice create call needs your API key, and the API key must never reach the browser. So the button does not call halfin directly. The button calls your server, your server calls halfin with the key, and only the resulting checkout URL travels back to the browser. The button is a trigger; the invoice is created where your secrets live.
Because each click creates a fresh invoice, the button is naturally single-use per purchase. One click, one invoice, one amount, one expiry, one rate lock. That is what you want for a checkout: the customer pays for a specific thing at a specific price, and the rate the page shows is locked when the invoice activates so it cannot drift between the click and the confirmation.
- A pay button = a button in your page + a backend route that creates an invoice.
- The create call returns a hosted checkout URL on checkout.thehalfin.com; you redirect the customer there.
- The API key lives on your server only — the button never calls halfin directly.
- Each click creates one invoice with its own amount, expiry, and locked rate.
The flow, end to end
Before any code, hold the whole path in your head, because every step exists to keep the key off the client and the truth on your server. The button click does not move money — it kicks off a chain that ends with a webhook your backend trusts.
Read the table top to bottom. The browser only ever sees a redirect URL, never the API key, and the order is not marked paid by the redirect the customer comes back on — it is marked paid by the signed webhook your server receives independently. The redirect is for the customer's eyes; the webhook is for your database.
| Step | Where it runs | What happens |
|---|---|---|
| 1. Click | Browser | The customer clicks Pay with crypto; the page calls your own backend route, not halfin. |
| 2. Create invoice | Your server | Your route POSTs to /api/v1/invoices with the X-API-Key header and an idempotency_key. |
| 3. Get checkout URL | Your server | The create response carries a hosted checkout URL on checkout.thehalfin.com. |
| 4. Redirect | Browser | Your server sends the browser to that URL; the customer pays on halfin's hosted page. |
| 5. Confirm | Your server | A signed invoice.paid webhook arrives; you verify it and mark the order paid. |
Step 1 — Create the invoice on your server
The button's job is to reach a route you own; that route's job is to create the invoice. Keep the create call server-side for one non-negotiable reason: it carries your API key in the X-API-Key header, and any key shipped to the browser is one an attacker can lift from the network tab and use to create invoices, or worse. Issue a scoped key for this surface — one that can create invoices and nothing else — and store it in your secret manager, never in client code or source control.
Decide what the invoice is for. The cleanest pattern for a checkout button is a deferred, fiat-anchored invoice: you state the amount in your home currency (USD, EUR) and the customer settles in whichever supported asset they hold, with the rate locked at activation so the figure you booked is the figure you reconcile. If you would rather quote a fixed crypto amount, send currency plus amount instead of amount_fiat plus fiat_currency. Either way, attach an idempotency_key derived from your own order id so a double-click or a retried request never creates two invoices for one purchase.
The curl below is the exact call your route makes — run it from your backend, not the browser. Amounts go as strings end to end; never let a money value become a JavaScript number. The response includes the hosted checkout URL you hand back to the browser; the full schema lives at docs.thehalfin.com.
# Runs on YOUR SERVER — the X-API-Key never reaches the browser.
# The button click hits your own route, and your route makes this call.
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": "Order 10472",
"redirect_url": "https://shop.example.com/orders/10472/thanks",
"idempotency_key": "order-10472"
}'
# The response carries a hosted checkout URL on checkout.thehalfin.com.
# Return that URL to the browser and redirect the customer to it.
# See docs.thehalfin.com for the full request and response schema.Step 2 — Wire the button to that route
Now the front end. The button is ordinary HTML and one small handler — there is no halfin script to load. On click, call your backend route, take the checkout URL it returns, and send the browser there. Because the create call happens behind your route, the browser receives only the redirect target, which is exactly what you want.
Two details make the button feel right. First, disable the button on click and show a pending state, so an impatient customer cannot fire three invoices by triple-clicking — the idempotency_key already protects you on the server, but the UX should not invite the retries. Second, render a real <button> (or an <a> you progressively enhance), keep it keyboard-focusable, and give it a clear label like 'Pay with crypto' — a payment control is not the place to reinvent native semantics.
The handler below posts to your route, reads the checkout URL, and navigates to it. Note what it does not contain: no API key, no amount logic, no chain handling. The price lives on your server with the order, so a customer cannot tamper with the amount by editing the request — the button only ever says 'pay for this order', and the server decides what that costs.
<button type="button" id="pay-crypto">Pay with crypto</button>
<script>
const button = document.getElementById("pay-crypto");
button.addEventListener("click", async () => {
button.disabled = true;
button.textContent = "Starting payment…";
// Hits YOUR backend route — which creates the invoice with the API key.
const res = await fetch("/checkout/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderId: "10472" }),
});
if (!res.ok) {
button.disabled = false;
button.textContent = "Pay with crypto";
return; // surface an error to the customer here
}
const { checkoutUrl } = await res.json();
window.location.assign(checkoutUrl); // off to checkout.thehalfin.com
});
</script>Step 3 — Let the customer pay on hosted checkout
Once you redirect, your work pauses and halfin's begins. On checkout.thehalfin.com the customer picks a network from the ones you accept, sees the exact amount in that asset, a QR code, and the deposit address. halfin watches the chain, applies that chain's confirmation threshold, and updates the page in place as the payment moves from waiting to detected to confirming to confirmed. Crediting is reorg-aware, so a payment shown as confirmed has actually settled to the depth that chain requires.
You do not have to handle the long tail of payment UX here — the reloaded tab, the wallet that broadcasts twice, the chain that confirms slowly, the customer who underpays or overpays. The hosted page and the gates behind it absorb those cases. The supported rails are real on-chain gates: Bitcoin, Ethereum and ERC-20, Base, Arbitrum, Polygon, BNB Smart Chain, Tron and TRC-20, the XRP Ledger, and Solana and SPL, with USDT and USDC on the networks that carry them.
When the invoice resolves, the customer is returned to the redirect_url you set on the invoice — your order-confirmation page. Treat that landing purely as a customer convenience. It is the right place to say 'thanks, we are confirming your payment', but it is the wrong place to mark the order paid, because a customer can pay and then close the tab before the redirect ever fires. The authoritative signal comes next, on your server.
Step 4 — Confirm on the webhook, not the redirect
The redirect tells the customer something happened; the webhook tells your database what actually happened. Register a webhook endpoint and subscribe to invoice events. When the invoice is paid, halfin POSTs a signed invoice.paid event to your endpoint independently of whatever the customer's browser did. That event — not the landing page — is what flips your order to paid, releases the goods, or sends the receipt.
Verify before you trust. Your endpoint URL is public once you register it, so anyone can POST JSON at it. Recompute the HMAC over the exact raw request bytes using your endpoint's signing secret, compare it to the signature header with a constant-time check, and only then parse and act. An unsigned or mismatched request is not a halfin event and must never move your order state. Compute the HMAC before any framework middleware reserializes the body, or a re-encoded payload will fail an otherwise-valid signature.
Keep the handler idempotent and respond fast. Delivery is at least once, so the same invoice.paid can arrive more than once carrying the same stable event id — dedupe on that id and make fulfilling the order a no-op the second time. Watch for the awkward states too: invoice.underpaid means a real but insufficient payment landed, invoice.overpaid means a surplus arrived, and invoice.expired means the window closed before payment. Each is a defined outcome your back office can act on, not a silent failure. Acknowledge with a 2xx as soon as you have verified and recorded the event, then do slow work on a queue.
- invoice.paid is the signal you mark the order on — never the redirect landing page.
- Verify the HMAC over the raw request bytes (constant-time) before parsing or acting.
- Dedupe on the stable event id; delivery is at least once, so a repeat must be a no-op.
- Handle invoice.underpaid, invoice.overpaid, and invoice.expired as defined outcomes.
Common variations and where they change the recipe
The core pattern — button hits your route, route creates an invoice, redirect to checkout, confirm on webhook — stays the same across variations; only one or two fields move. To bill a fixed crypto amount rather than a fiat figure, send currency and amount instead of amount_fiat, fiat_currency, and deferred. To return the customer to a specific page, set redirect_url per order. If you send the link out of band — into an email or a chat — you skip the redirect and share the checkout URL itself; that is the payment-link pattern.
What does not change is the safety spine. The API key stays server-side, the idempotency_key stays derived from your order so retries collapse to one invoice, and the order is marked paid from a verified webhook. Hold those three and the button is hard to get wrong; drop any one and you have opened a hole — a leaked key, a double-charged customer, or an order marked paid off a redirect anyone can forge.