← halfin journalApr 27, 2026 · 10 min read
Engineering

A crypto checkout that doesn't lose payments

The browser redirect is a courtesy, not a fact. The webhook is the source of truth — verify the HMAC, fulfil on invoice.paid, and reconcile through the API after every outage.

RA
R. AdeyemiPayments Engineering
engineering · cover

If your fulfilment depends on the customer's browser making it back to your success page, you've already lost some orders. You just haven't found them yet.

The single most common bug we see in crypto checkout integrations is not a signature mismatch or a wrong chain. It's trusting the redirect. A customer pays, closes the tab before the confirmation lands, and never bounces back to ?status=success. The money is on-chain. The order is in limbo. Support gets a ticket two days later, and now someone is reconciling by hand.

This is avoidable. The fix is one sentence: fulfil off the webhook, never off the browser. Everything below is what that sentence actually costs to implement, and where teams cut the wrong corner.

Why the redirect lies

A hosted checkout redirect is a best-effort UX nicety. It tells the customer "we think you're done." It is not a payment confirmation, and you should never treat it as one. Three failure modes, all common:

  1. The customer closes the tab. They paid from their wallet app, switched back, saw the green check, and dismissed it. The redirect never fired. You have a paid invoice and no signal.
  2. Confirmation outlives the session. On a chain with a slower block time, the deposit is detected but not yet confirmed when the customer gives up waiting. The redirect, if it fires at all, fires before paid.
  3. The redirect fires on the wrong state. A naive integration treats any return to the success URL as fulfilment. But a customer can land back on your page after an underpayment — the invoice is not paid, and shipping the goods is a loss.

The redirect is a hint to the customer, delivered to the customer's browser. Fulfilment is a fact about money, and facts about money arrive on your server, signed.

The webhook is the source of truth

halfin emits a small, typed set of webhook events. For checkout, the one that matters is invoice.paid. That event — verified — is your fulfilment trigger. Not the redirect, not a polling loop you wrote at 2am, not the customer telling you they paid.

The events you'll actually wire into a checkout flow:

  • invoice.confirming — a deposit was detected and is accumulating confirmations. Useful for showing the customer "we see it, hang on." Not a fulfilment signal.
  • invoice.paid — confirmed and credited. This is the one you fulfil on.
  • invoice.overpaid / invoice.underpaid — the amount didn't match. Branch your logic here; don't silently treat either as paid.
  • invoice.expired — the window closed with no acceptable payment. Release the cart, free the reservation.
  • invoice.late_deposit — money arrived after expiry. This is the one that bites teams who only handle the happy path: a customer paid an expired invoice, and now you owe them either fulfilment or a refund. Decide that policy before it happens.

Note what is not on this list: there is no invoice.activated event. Activation isn't a webhook — the invoice is live the moment you create it. Build your state machine from the events above, not from ones you assume exist.

Verify the HMAC, on the raw bytes

A webhook you haven't verified is an HTTP request from a stranger. Anyone who learns your endpoint URL can POST a fake invoice.paid to it. So the first thing your handler does — before it parses JSON, before it looks up the order, before anything — is verify the signature.

Two rules that are not optional:

  • Verify against the raw request body, exactly as received. Do not JSON.parse and re-serialize first. Re-serialization changes key order, whitespace, and number formatting, and your computed HMAC will diverge from ours over bytes you thought were "the same."
  • Compare in constant time. A naive === on the signature leaks timing. Use your platform's constant-time comparison.

The full mechanics — which header carries the signature, how to read the raw body in Express / Next.js / Go, and the exact comparison — are in the guide on how to verify a webhook signature. Read it once and copy the handler shape; this is not a place to improvise.

The order of operations inside the handler is load-bearing:

  1. Read the raw body bytes.
  2. Compute the HMAC and compare, constant-time. Reject with 401 on mismatch — and return early, before any DB lookup.
  3. Only now parse the JSON.
  4. Check the event type and act.

If you parse before you verify, you've handed an unauthenticated stranger a code path into your deserializer. Verify first.

Make the handler idempotent

We retry deliveries. A 200 that your server sent but our side never received looks identical to a dropped delivery, so we send it again. Your handler will see the same invoice.paid twice eventually — design for it, don't hope against it.

The pattern is boring and correct: record the event you've already processed, keyed by the event's own ID, and no-op on a repeat. Do the dedupe check and the fulfilment write in the same transaction so two concurrent deliveries can't both pass the check. The same discipline applies on the way out — when you create the invoice, send an idempotency_key in the request body so a retried create doesn't mint two invoices for one cart.

Process by event ID and you'll be correct. Process by invoice ID and you'll be correct until the day a customer pays, you refund, and they pay again on a re-issued invoice — then your "already handled this invoice" check ships the goods for free.

We go deeper on the delivery side — backoff, jitter, dead-letter replay — in designing webhooks that survive everything. The short version for the receiving end: respond 2xx fast, do the slow work asynchronously, and never block our delivery on your downstream.

Reconcile after downtime — the API is the backstop

Webhooks are an accelerant, not the ledger. Your endpoint will be down at some point — a deploy, a bad migration, an expired cert. During that window we keep retrying, but you should not depend on retries alone to cover a multi-hour outage. The backstop is the API.

Build one reconciliation job and run it on a schedule:

  1. List invoices that your system still considers open (created, awaiting payment).
  2. For each, fetch its current state from the API.
  3. Any invoice the API reports as paid / overpaid / underpaid / expired that your DB still has as open — replay it through the same fulfilment path your webhook handler uses.

That last clause is the whole point. Reconciliation and live delivery must funnel into one idempotent fulfilment function. If reconciliation has its own copy of the fulfilment logic, the two drift, and you get the worst bug in commerce: an order fulfilled twice, or once-and-a-half. One path, idempotent, called from both. The API is the authority; the webhook is just the low-latency notification that the authority changed.

The shape of a checkout that doesn't lose payments

Put together, it's four moving parts and no heroics:

  • Create the invoice with an idempotency_key in the body so a retried submit is free.
  • Notify off invoice.confirming if you want a live "we see it" state — but never fulfil here.
  • Fulfil off a verified invoice.paid, through an idempotent handler that dedupes on event ID.
  • Reconcile open invoices against the API on a timer, replaying any terminal state through that same fulfilment path.

The redirect still has a job: it sends the customer somewhere pleasant. It just isn't allowed anywhere near your inventory, your ledger, or your fulfilment switch. Treat the browser as a courtesy and the server as the truth, and the class of bug where money is on-chain but the order is stuck simply stops existing.

We've watched teams ship the redirect-trusting version, run it for a quarter, and discover the gap only when a customer emails a transaction hash for an order that never shipped. Build it the other way and that email never gets written.

R. Adeyemi, halfin payments engineering

↳ end of articlehalfin journal · Apr 27, 2026