Your checkout success page should not touch your billing state

The billing bug almost every first SaaS ships, why the browser is the wrong source of truth, and the four things a Stripe webhook actually owes you.

May 28, 2026

There is a bug almost every first SaaS ships, and it looks completely reasonable while you are writing it.

The user pays. Stripe redirects them to /success. Your success page reads the session, sees that money changed hands, and flips a flag in your database to say this account is now a paying customer. Ship it. It works in testing. It works in the demo.

Then someone closes the tab on the Stripe checkout page half a second after paying, before the redirect fires. Or your success route throws because the database was busy. Or a curious user visits /success directly to see what happens. Now you have a customer who paid and has no access, or a freeloader who has access and never paid. Either way, the source of truth for who paid you is a page that only runs if the browser cooperates.

The browser does not work for you. Stripe does.

The fix is a rule worth writing on the wall: the only thing allowed to change billing state is the Stripe webhook. The success page says thank you and nothing else. It does not grant access, it does not read entitlements. It is a pretty dead end. Everything that matters happens server to server, where no tab can be closed and no user can wander in.

That rule sounds simple, then quietly demands four things from you.

Verify the signature. A webhook endpoint that trusts any POST is an open door to your billing logic. Stripe signs every event. Check the signature before you read a single field, and reject anything that fails. This is not the part to hand-wave.

Be idempotent. Stripe will send you the same event more than once. This is not a bug, it is the contract, because the network is unreliable and they would rather deliver twice than zero times. If your handler grants a month of access every time it sees checkout.session.completed, a retry just gave someone two months. Claim each event once, by id, and make the second arrival a no-op.

Handle the money going backwards. Most billing code is written as if payments only ever happen. Then you issue your first full refund and discover the customer still has everything they paid for, because nothing in your system listens for access to be taken away. A refund should revoke access the same way a payment grants it: automatically, through the same webhook path.

Enforce limits where the data lives. This is the one people skip. If your plan limit is enforced in the UI, you do not have a limit, you have a suggestion. Hiding the upload button on the free plan stops nobody who can open the network tab. The check has to sit next to the data. In BoiledPlate that means the row-level security policy itself looks at the plan before it lets a row through, so the free tier is enforced by Postgres and not by a disabled button.

I wired all of this once, got it wrong in two of the ways above, fixed it, and then made it the default so I never have to think about it again. The webhook is signature-verified and idempotent out of the box. A refund revokes access on its own. The success page is a dead end on purpose. The limits live in the policies.

You can build all of this yourself. I did. I just think you have better things to spend a weekend on than relearning that the browser does not work for you.

#stripe #billing #saas #postgres

Read more