FifeRouter

31 August 2026 · money mistakes

Why the balance moves on the webhook and never on the redirect

A redirect is the customer's browser claiming a payment happened. A signed webhook is Stripe saying so. We shipped the rule and then found out how it fails.

When someone finishes paying, Stripe sends their browser back to a URL you chose. It is right there, it carries a session id, and crediting the account at that moment is the obvious implementation.

It is also the oldest way to give away free credit, and it takes about ten seconds to exploit once anybody notices. The success URL is a URL. Anyone can visit it.

The rule

Credit moves when a signed webhook arrives from Stripe, verified by HMAC over the raw body with a timestamp tolerance, and never when a browser returns. The success page says so:

Confirming your payment with Stripe. The balance updates when Stripe confirms it — not when your browser came back here — so give it a moment and reload.

That wording is deliberate. It would be easy to show an optimistic new balance and let the webhook catch up. But at that moment the webhook may not have arrived, and telling the truth about it costs one sentence, where explaining later why the number was briefly wrong costs considerably more.

Then it failed, and not in the way we prepared for

The first real top-up: £10, live card, payment completed. The redirect landed on the confirming page. The balance stayed at zero, correctly, because no webhook had arrived.

None ever did. The event destination had been created with no events subscribed to it. Stripe accepted the endpoint happily and sent it nothing.

Everything worked exactly as designed. The design just had a state nobody had thought about: money taken, nothing credited, and no error anywhere — the webhook handler was never called, so it had no opportunity to complain.

What the design got right anyway

Three things meant this was recoverable rather than a mess.

Nothing was credited on the redirect. If it had been, the ledger would have been right by accident and wrong the moment the webhook problem was fixed and the event replayed.

Events are account-level. Stripe keeps them independently of endpoints, so the original checkout.session.completed could be resent to the corrected destination. No second payment.

Crediting is idempotent on dedupe_key. Resending is safe, and resending twice is safe, enforced by a unique index rather than by anybody remembering.

And one thing it got wrong

The refund path had no guard against reversing a payment that was never credited. Refunding that £10 while it sat uncredited would have posted a reversal against a zero balance and left the account at −£10 — a debt the customer did not owe, on a payment they got nothing for.

The reversal path assumed a credit always precedes a refund. True in the normal flow, and it stopped being true the moment the webhook stopped delivering, which is a fair description of most assumptions that turn into incidents.

The fix was a headroom rule: a reversal may take back at most what an account's top-ups have actually put in. Written up separately, because the interesting part is not the rule but that the reachable-states list changed when something else broke.

The thing worth copying

Crediting on the webhook is standard advice and most people follow it. The part that is less standard, and that we only learned by doing it wrong, is that the absence of a webhook is a state your system is in, not an error it receives.

Nothing was logged, nothing was red, no handler ran. The only signal was a customer looking at a balance of zero after paying — which is a monitoring gap disguised as a Stripe configuration mistake.

The watchdog now reports the ledger's last movement alongside routing errors, so "money went in and nothing happened" has somewhere to show up other than somebody's memory of what they clicked.


← All posts