What Is a Stripe Webhook?
A Stripe webhook is an HTTP callback Stripe sends to your server when a payment event occurs — subscription created, invoice paid, payment failed — enabling your app to react to billing events in real time.
A Stripe webhook is an HTTP POST request that Stripe sends to a URL on your server whenever a billing event happens — a checkout completes, an invoice is paid, a payment fails, a subscription is cancelled. It is how your application learns the real outcome of a payment, asynchronously and reliably, instead of guessing from what the browser tells it.
What it actually is
Stripe's payment lifecycle is event-driven. You don't poll Stripe asking "did this charge go through?" — Stripe pushes the answer to you. Each event is a JSON object with a type (e.g. invoice.payment_succeeded), an id, and a data.object holding the affected resource (the subscription, invoice, or session). Your webhook endpoint receives it, verifies it, and updates your own database accordingly.
The key mental shift: the source of truth for billing state lives at Stripe, and the webhook is how that truth reaches your app. Your database is a cache of Stripe's state, kept current by events.
The events worth handling
Most apps only need a handful:
| Event | When it fires | What you typically do |
|---|---|---|
checkout.session.completed | A Checkout payment or subscription is confirmed | Provision access, mark the account paid |
invoice.payment_succeeded | A renewal is charged successfully | Extend the subscription period |
invoice.payment_failed | A charge fails (expired card, no funds) | Start dunning — email the user, flag the account |
customer.subscription.updated | Plan change, trial ends, status changes | Sync the new plan/limits |
customer.subscription.deleted | Subscription fully cancelled | Revoke access at period end |
Ignore the rest until you have a concrete reason to handle them. Stripe sends dozens of event types; subscribing to all of them is noise.
Why you can't just use the redirect
After Stripe Checkout, the customer is redirected to your success URL. It is tempting to grant access right there. Don't.
That redirect is best-effort, not guaranteed: the user closes the tab, a mobile browser kills the page, the network drops, or someone hand-edits the success URL. None of those are payment failures — the money still moved — but your app would never find out. The webhook fires regardless of what the browser does, which is exactly why subscription state must change on the webhook, never on the redirect. Use the success URL only for a friendly "thanks, you're all set" screen.
Verifying the signature
Anyone can POST JSON to a public URL. To prove a request genuinely came from Stripe, verify the Stripe-Signature header against your endpoint's signing secret:
const sig = request.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(
rawBody, // the raw request body, NOT parsed JSON
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
The single most common mistake here: passing already-parsed JSON instead of the raw body. Signature verification hashes the exact bytes Stripe sent, so a framework that auto-parses the body (Express's express.json(), some Next.js defaults) breaks it. Disable body parsing on the webhook route and pass the raw Buffer. If constructEvent throws, return 400 immediately and process nothing.
Idempotency is mandatory
If your endpoint returns anything other than a 2xx, Stripe retries — and it can also deliver the same event more than once even on success. So your handler will see duplicates. Make every handler idempotent: store each processed event.id, and skip an event you've already handled. Without this, one renewal can extend a subscription twice or send two receipts.
Return 2xx quickly. Do slow work (sending email, generating invoices) in a background job, not inline — Stripe times out the request and retries if you take too long.
Common mistakes, in order of how often they bite
- Changing billing state on the success-URL redirect instead of the webhook.
- Parsing the body before signature verification, so every event fails with a signature error.
- No idempotency, so retries double-apply.
- Doing heavy work synchronously and timing out.
- Forgetting to set the endpoint's signing secret per environment (test vs live secrets differ).
Key takeaways
- What: a server-to-server POST from Stripe announcing a billing event.
- Why: the only reliable way to know what really happened to a payment.
- Trust the webhook, not the browser. Verify the signature, use the raw body, return
2xxfast, and dedupe byevent.id.
Getting webhook handling right is the difference between a billing system that quietly stays correct and one that grants free access or charges twice. It's the part of a Stripe integration that's easy to demo and easy to get subtly wrong — the same plumbing behind the Stripe-backed subscription flow in BookBed (a booking SaaS on Flutter, Firebase, and Stripe). Need a payment integration reviewed or built end to end? Contact for a quote.