Guide

How to accept crypto payments on any website

You want a customer to pay in crypto and your site to do the right thing when they do — show the order as paid, ship the goods, send the receipt. The honest version of this is three moving parts: create an invoice anchored to your fiat price, send the customer to a payment surface (halfin's or your own), and fulfil the order off a signed webhook rather than off the redirect that brings them back. This guide walks the full path with real calls, the events you react to, and the mistakes that strand money.

01

What 'accept crypto on a website' actually involves

A card payment hides a lot of machinery behind a single form submit. Crypto does not, and pretending it does is how integrations break. There is no synchronous "charge succeeded" you can read off the response to the button click. The customer signs and broadcasts a transaction from their own wallet, the network confirms it over some number of blocks, and only then is the payment something you can stand behind. Your website has to be built around that asynchrony instead of fighting it.

So the flow has a front half and a back half. The front half is presentation: create an invoice for the amount due and put a payment surface in front of the customer — an address, the exact amount, the network, a QR code, a countdown. The back half is settlement: a signed event arrives at your server when the invoice is actually paid, and that is what moves your own order state. The front half is what the customer sees; the back half is what you trust.

halfin gives you a primitive for each. Invoicing produces the payable object with the rate locked to your fiat price. Hosted checkout (or self-hosted checkout, if you render it yourself) is the front-half surface. Webhooks are the back-half signal. You wire three things together once, and any page on your site that needs to take a payment reuses them.

02

Step 1 — Create an invoice anchored to your fiat price

Your prices live in a currency — a product is $49, not 49-dollars-worth-of-whatever-a-token-costs-right-now. Create the invoice with the fiat amount and your fiat currency, and halfin quotes the payable crypto amount and locks that rate the moment the invoice activates. The customer pays the dollar value of the cart even if the asset's price moves while they have the checkout open, and the figure that lands in your books is the figure you billed. The lock carries an expiry; if the customer lets it lapse, you create a fresh invoice and they get a current quote.

Send the create call server-side, from the code that owns the cart, never from the browser — the API key that authenticates it must not ship to the client. Authenticate with a scoped API key, post the amount and currency as strings (monetary values are strings end to end, never floats), and pass an idempotency key derived from your own order id so a retried request returns the existing invoice instead of billing the customer twice. Attach your order id to the invoice too, so the webhook that comes back later tells you which order to fulfil without a side lookup.

Two request shapes exist depending on how you price. The fiat-anchored shape below is what most websites want — you state a fiat figure and the customer settles the equivalent in any supported asset. If instead you are selling something denominated in a token itself, you send a fixed-asset request where currency is the crypto code (for example BTC) and amount is the token amount. In both cases currency on the request is the unit you are pricing in; you never send a fiat amount under a crypto field or vice versa.

# Fiat-anchored: you price in USD, the customer pays the equivalent in any
# supported asset. Run this server-side — the API key never touches the browser.
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 #7F3A21",
    "idempotency_key": "order_7F3A21"
  }'

# halfin locks the rate at activation, pins the payable asset amount, and
# returns an invoice id plus a hosted checkout URL on checkout.thehalfin.com.
# Store the id against the order; send the customer the URL. See
# docs.thehalfin.com for the full request and response schema.
03

Step 2 — Pick a payment surface: hosted or self-hosted checkout

The invoice exists; now the customer needs somewhere to pay it. Two options, the same invoicing primitive underneath, and the right one depends on how much of the payment screen you want to own.

Redirect to hosted checkout for the smallest integration. The create-invoice response carries a checkout URL on checkout.thehalfin.com. Send the customer there — a link, a button, or a server redirect after they place the order — and halfin owns the whole payment screen: the asset picker, the address and QR code, the live "waiting / seen / confirming / paid" status, and the wallet deep-links. You write almost no payment UI. This is the default for a storefront that just needs money to come in.

Render self-hosted checkout when the payment step has to live inside your own design — a single-page app where a redirect would feel like leaving, a flow with custom branding, or a step embedded mid-funnel. You build the page against the same API: read the invoice's payable amount, address, and network, render your own QR and copy-to-clipboard, and poll the invoice (or drive your UI off webhooks) for status. More work, full control. Either way the back half — fulfilment off the webhook — is identical, so you can start hosted and move to self-hosted later without changing how you settle.

Hosted checkoutSelf-hosted checkout
You buildA redirect to the checkout URLThe full payment screen (address, amount, QR, status)
halfin ownsAsset picker, QR, live status, wallet deep-linksThe invoice object and its lifecycle only
Best forStorefronts that just need money inSPAs and flows that can't redirect away
Customer leaves your siteBriefly, to checkout.thehalfin.comNo
04

Step 3 — Know what 'paid' means before you ship anything

A payment is not final the instant a customer's wallet shows "sent". The transaction has to land in a block and then accumulate enough confirmations that a chain reorganization is no longer a realistic risk. Each chain has its own threshold and its own pace — fast on a chain like Solana, slower on Bitcoin — and halfin applies a per-chain confirmation threshold with reorg-aware crediting. An invoice it reports as paid has settled under that chain's rules, not merely been seen in the mempool.

For your website this means one rule: do not fulfil on first sight. Wait for the invoice to reach paid. The gap between a deposit appearing on-chain and the invoice being credited is short on a fast chain and longer on Bitcoin, but it is real, and shipping the moment a transaction shows up in the mempool is how you occasionally hand out goods against a payment that gets reorged away. The platform absorbs the chain-specific waiting and the reorg handling; your job is to key fulfilment off the final state.

Two near-miss outcomes deserve a defined response rather than a crash. An underpaid invoice — the customer sent slightly too little, often because they paid the network fee out of the same balance — records the shortfall against the quote instead of discarding the funds, so you can ask for the remainder or settle partially under your own policy. An overpaid invoice records the excess the same way, so you can refund it or credit it. Decide each policy once and let the recorded state drive your support flow.

Invoice eventWhat it meansWhat your site should do
invoice.confirmingLive with a locked quote, payment seen and confirmingShow the address/amount and a waiting state — do not ship
invoice.paidConfirmed past the chain's thresholdMark the order paid and fulfil it (idempotently)
invoice.underpaidA real payment arrived but is below the amount dueHold the order; request the remainder or settle per policy
invoice.overpaidSettled with a surplus over the amount dueFulfil and flag the excess for refund or credit
invoice.expiredThe quote window closed before full paymentCancel the order; let the customer start a fresh invoice
05

Step 4 — Fulfil off the verified webhook, not the redirect

This is the step that separates a working integration from one that silently loses orders, so be strict about it. When the invoice resolves, the customer is returned to your site — but that return can be missed. They pay on their phone, 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 fail to fulfil orders that were actually paid for.

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. Treat it as the source of truth. Before you read a single field as a business fact, verify the signature: recompute the HMAC over the exact raw request bytes with your endpoint's signing secret, and compare it to the header in constant time. Do this before any JSON parsing or framework body-parser touches the request, because a re-serialized body changes whitespace and key order and will fail an otherwise-valid signature. An unsigned or mismatched request is not a halfin event — return a 4xx and do nothing else. Verifying before acting is exactly what stops a forged "paid" callback from shipping free goods.

Only after the signature passes do you act: look up the order by the id you attached in Step 1, mark it paid, and fulfil. Keep the handler idempotent — delivery is at least once, the same paid event can arrive more than once carrying the same stable event id, and a redelivered event must not ship twice or email twice. Record processed event ids and make the second copy a no-op. Acknowledge fast with a 2xx and push slow work (emails, shipping, ledger writes) onto a queue, so a slow handler is not read as a failed delivery and retried.

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();
const SIGNING_SECRET = process.env.HALFIN_WEBHOOK_SECRET!;

// HMAC must be computed over the EXACT bytes received, so capture the raw
// body and disable JSON parsing on this route.
app.post(
  "/webhooks/halfin",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.header("x-halfin-signature") ?? "";
    const expected = createHmac("sha256", SIGNING_SECRET)
      .update(req.body) // req.body is a Buffer here
      .digest("hex");

    const a = Buffer.from(signature);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
      return res.status(401).send("invalid signature");
    }

    // Verified — now it is safe to parse and act.
    const event = JSON.parse(req.body.toString("utf8"));
    if (event.type === "invoice.paid") {
      // look up the order by the id you attached at create time,
      // then fulfil exactly once (dedupe on event.id).
    }

    // Acknowledge fast; defer email/shipping to a queue.
    return res.status(200).send("ok");
  },
);
06

Step 5 — Handle the unhappy paths and go live

A real checkout meets customers who do not pay cleanly, and your site is better for handling them explicitly instead of treating everything that is not a perfect payment as a failure. Subscribe to the underpaid, overpaid, and expired events alongside paid, and give each a defined outcome: hold and request the remainder on underpaid, fulfil-and-flag on overpaid, and offer a fresh invoice on expired. Keep a fallback poll of the invoice by id for the rare missed webhook, but let the signed event be the spine — polling is a safety net, not the primary path.

Configure the webhook endpoint and its signing secret from the dashboard or the API, and store the secret the way you store any credential — in your secret manager, never in source control, rotated if you suspect exposure. Use a scoped API key for the create-invoice service: an invoicing-scoped key for the storefront, separate from any payouts key that can move money, so a leaked storefront credential cannot send funds out.

Before you flip it on, exercise the whole path against the sandbox. Create an invoice, pay it, and watch your endpoint receive the confirming and paid events. 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. Confirm a redelivered event is a no-op. Once the path holds end to end, the same three primitives carry every payment your site takes. The full event catalog and field-level schemas live at docs.thehalfin.com.

  • Verify the HMAC signature over the raw bytes before reading the body — always.
  • Fulfil off invoice.paid, never off the success redirect.
  • Dedupe on the stable event id so a redelivered event fulfils once.
  • Wait for the per-chain threshold; do not ship on a mempool sighting.
  • Subscribe to underpaid / overpaid / expired and give each a defined outcome.
  • Keep the create call and signing secret server-side; never ship the API key to the browser.