The charge is not the end of the payment


Taking a payment looks finished when the card goes through. The customer sees a success state. Stripe has the charge. Everyone relaxes. Then a webhook arrives late, or twice, or not in the order you assumed, and the row in your database still says the account is unpaid. The customer has paid. The product does not believe them. That is the bug they will write to you about, and they are right.

I have wired Stripe into more than one product, including the background work that keeps the charge and the database in step. The useful lesson is that the checkout session is the easy half. The system underneath is a set of jobs that run when nobody is watching: webhooks, retries, and a clear record of what the product thinks the subscription is.

I do not trust the browser to be the source of truth. The success page can render before the event that updates the account has been processed. If the page itself writes “this user is paid” because Stripe.js returned ok, you now have two writers. One of them is a tab the user can close. The webhook, or a job that reconciles from Stripe, should be the writer. The page can say “we are confirming this” and then show the state the database actually has.

Idempotency is the part that sounds abstract until the same event is delivered twice. A webhook handler that adds a month of access every time it runs will double-grant on a retry. The handler has to be able to see an event it has already applied and do nothing. Store the event id. Store the resulting state. Make the update something you can run again without changing the outcome. That is less exciting than the checkout UI, and it is the difference between a launch that survives the first real card and one that does not.

Failure needs a place to sit. A webhook that throws and disappears is how you get a paid customer on a free plan for a week. I want the failure in logs I actually look at, with enough of the event to retry it on purpose. On one platform that meant CloudWatch and a job I could run again, not a console line on a server I would not SSH into. Reporting belongs here too. If finance asks what was charged yesterday, the answer should come from the same records the product uses to unlock features, or you will spend the afternoon explaining why the two numbers disagree.

The same shape shows up outside payments. An import from a supplier, a status sync, a form that must arrive in an inbox: the request can succeed while the system behind it is still catching up. I design that catch-up as a job with a status, not as a hope that the request handler finished everything inline. The user can be told the truth: received, processing, done, failed. Those four words prevent a lot of “it charged me and nothing happened.”

When I scope a product that takes money, I put this work in the first version. Not a polished billing portal. The event, the row, the retry, and a way to see they match. The charge is the start of the payment. The product is finished when it agrees.