Stripe webhooks: the parts the quickstart skips
By Ahmad Badr
Most Stripe integrations are built from the quickstart: create a Checkout Session, listen for checkout.session.completed, mark the order paid. That works in the demo. In production, the money keeps moving after that first event, and if your system isn't listening, your numbers slowly stop matching your bank.
Subscribe to the whole lifecycle
A payment isn't one event. Bank debits settle days later, customers get refunded, and cards get disputed. If your endpoint only receives the first event, your system will keep reporting money you no longer have.
- checkout.session.async_payment_succeeded and async_payment_failed: bank debits that settle after checkout completes.
- charge.refunded: full and partial refunds, including ones issued from the Stripe dashboard.
- charge.dispute.created and charge.dispute.closed: Stripe withdraws the funds when a dispute opens and returns them only if you win.
- customer.subscription.updated and deleted: plan changes, failed renewals, and cancellations.
Keep that list next to the code that handles it. Event selection lives in the Stripe dashboard, not in your repository, so it drifts the moment someone adds a handler and forgets to tick the box.
Point it at the exact URL
Stripe does not follow redirects. If your apex domain redirects to www, or http redirects to https, a webhook pointed at the wrong variant fails every time, and nothing on your site tells you. Use the final, canonical URL, then confirm a 200 in the endpoint's delivery log rather than assuming it works.
Assume events arrive twice, late, and out of order
Stripe delivers at least once and retries for days. That means duplicates, and it means an older event can land after a newer one. Two habits handle most of it:
- Record each event ID you've processed and skip repeats, so a retry can't double-count a payment.
- Make updates move forward only. A refund total should only ever grow, so a late, older refund event can't roll it back.
A webhook handler should be safe to run twice and harmless to run late.
Record what was actually charged
After a discount or promo code, the amount charged differs from the invoice total. Store the charged amount from Stripe and compute refunds and revenue against it. Otherwise a full refund of a discounted payment looks partial, and your revenue report is off by exactly the discount.
Test it without a live account
Webhook logic is the part of billing that's hardest to test by hand, because the interesting cases (a dispute opening and then being won, a refund arriving before the payment record) are slow or awkward to reproduce. We run our billing suite against the real schema in an in-memory Postgres, with Stripe faked at the boundary, so every one of those cases runs on each pull request in seconds.
None of this is exotic. It's the difference between a checkout that works and a billing system you can trust at month end.
Have a project like this?
We help teams architect, build, and ship software that keeps running. Let's talk about what you're building.
Start a conversation


