← halfin journalApr 24, 2026 · 8 min read
Engineering

The redirect_url is a UI. The webhook is the truth.

The browser return is a courtesy to the customer. Fulfilment runs off the verified invoice.paid webhook, or it doesn't run at all. Here's why, and what breaks when you confuse the two.

RA
R. AdeyemiPayments Engineering
engineering · cover

If the customer's tab closing can lose you a sale, you built your fulfilment on the wrong signal.

We get the same support ticket every few weeks, phrased a dozen ways: "The customer paid but my system never granted access." Almost always, the integration is reading the wrong source of truth. It's treating the post-payment browser redirect as the event that means "money arrived." It isn't. It's a UI transition. The money arriving is a separate, asynchronous fact that reaches you over a signed webhook — and that's the one you fulfil on.

This post is the version of that conversation we'd rather have once, in writing, than reactively in a thread.

Two different things that look like one thing

When you spin up a hosted checkout and pass a redirect_url, you're describing where the browser should land after the payment flow ends. That's it. It's the same role the "back to merchant" button plays everywhere on the web.

The webhook is something else entirely. It's an HTTP request we make to your server, server-to-server, when an invoice changes state. invoice.paid fires when the deposit is confirmed on-chain to the required depth for that rail. No browser involved. No tab required.

They feel like the same moment because, on a good day, they happen close together. They are not the same moment, and the gap between them is exactly where integrations break.

Why the redirect can't be trusted with money

The redirect depends on a chain of things you don't control:

  • The customer's browser is still open. People close tabs. People background a payment, walk away, and never come back to the success page.
  • The customer's network survives the round trip. Mobile payers drop connections constantly — they're paying on a train, in a lift, on hotel wifi.
  • The customer didn't refresh, didn't hit back, didn't get bounced by an over-eager wallet app that hijacks the tab to confirm a transaction.
  • Nobody crafted the URL by hand. A redirect_url is a client-side landing. Anyone can navigate to https://yourshop.com/thanks?invoice=abc directly. If hitting that page grants the order, you just gave it away for free.

None of those failure modes mean the customer didn't pay. The on-chain deposit can confirm perfectly while the browser is already gone. If your fulfilment is wired to the redirect, you've coupled a confirmed payment to whether someone's phone stayed awake. That's not a payment system. That's a coin flip you're calling at the worst possible time.

Why the webhook is the truth

The webhook has the opposite properties. It's emitted by our infrastructure, not the customer's device. It's retried with backoff until your endpoint acknowledges it (we wrote about that retry curve in designing webhooks that survive everything). And critically, it's signed — every delivery carries an X-Halfin-Signature header computed over the raw request body. You verify that signature before you believe a single field in it.

That's the whole game. A redirect is a claim made by a browser. A verified webhook is a claim made by us, cryptographically, that you can check. One of those is forgeable in a text box. The other isn't.

So the rule we tell every integrator on day one:

Render UI on the redirect. Grant value on the webhook. Never the other way around.

The events you actually fulfil on

invoice.paid is the headline, but it isn't the only state that matters, and that's another reason the redirect is too blunt an instrument. The redirect gives you one binary "they came back." The webhook stream gives you the real lifecycle:

  • invoice.confirming — deposit seen, not yet confirmed to depth. Show a pending state; don't ship anything.
  • invoice.paid — confirmed in full. This is fulfilment.
  • invoice.overpaid — they sent more than the amount. Fulfil, and handle the surplus (we cover the playbook in handling underpaid and overpaid invoices).
  • invoice.underpaid — they sent less. Do not fulfil on the strength of a redirect that looked successful; the money isn't all there.
  • invoice.expired — the window closed. If a late deposit arrives after this, you'll hear about it as invoice.late_deposit rather than silently.
  • invoice.deposit_reversed — a confirmed deposit got reorged out. This is the one the redirect can never tell you, because by the time it happens the customer left long ago. Reorg-aware crediting is the whole reason you fulfil on a verified event and keep listening after it.

A browser redirect collapses all of that into "did the success page load." The webhook stream is the actual state machine. Fulfil off the state machine.

What "do it right" looks like in practice

The shape we recommend is boring on purpose:

  1. Create the invoice server-side, store its id against the order in a pending state.
  2. Set redirect_url to a thin "thanks, we're confirming your payment" page. It reads the order status from your database and shows whatever you've recorded — pending, paid, whatever's true. It does not grant anything. It does not trust query params.
  3. Receive the webhook on a dedicated endpoint. Verify the signature over the raw bytes first — before you parse, before you log a balance, before anything. An unverified body is untrusted input.
  4. On a verified invoice.paid, look up the order by the invoice id in the payload, flip it to paid, and run fulfilment. Make this idempotent: the same event can arrive twice, and the second one must be a no-op.
  5. The customer's "thanks" page, polling your own database, lights up green the moment step 4 lands — whether they're still watching or come back an hour later.

Notice the redirect page and the fulfilment never talk to each other directly. They both read from your order record. The webhook is the only thing that writes "paid" to it. That decoupling is the entire point: the customer's browser and your money are no longer on the same fragile thread.

The failure we want you to avoid

The expensive version of this bug is silent. It passes every test because in your test environment the redirect and the webhook arrive a second apart and everything looks fine. Then a real customer pays on a flaky connection, the redirect never fires, the webhook does, and — if you only wired the redirect — your system never granted the order. The customer is out the money and angry. Or the inverse: you granted on the redirect, someone discovered the URL pattern, and now strangers are claiming orders they never paid for.

Both come from the same root cause. Two signals that look alike got treated as interchangeable. They aren't. One is for eyes, one is for money.

Wire the redirect for the customer's comfort. Wire the webhook for your ledger. Verify the signature, fulfil on invoice.paid, stay idempotent, and keep listening for the reversal that the browser would never have told you about.

R. Adeyemi, halfin payments engineering

↳ end of articlehalfin journal · Apr 24, 2026