Stripe Webhook Events: Which Ones Your App Must Handle
Which Stripe webhook events your app must handle: checkout, subscription, refund and dispute events, plus how to handle duplicates and out-of-order delivery.

On this page · 7 sections
- Which Stripe webhook events does a simple checkout need?
- Which events matter for subscriptions and failed renewals?
- What about refunds and disputes?
- Why do events arrive out of order or more than once?
- How do I make my event handler idempotent?
- Which events should I ignore?
- Want a second opinion on your webhook handler?
Key takeaways
- Start with a short list of events and add more only when a feature needs them.
- Grant access from webhooks, not from the success page the customer is redirected to.
- Stripe can send the same event twice and in any order, so store event IDs and make handlers idempotent.
- Subscriptions need failed-payment and cancellation events, not only the first payment.
- Refunds and disputes must update your own records, or your books and access drift apart.
- Ignore events you do not act on, but still return a 200 for them.
The stripe webhook events your app must handle depend on what you sell, but the core set is small: checkout.session.completed (or payment_intent.succeeded) for one-time payments, invoice.paid and invoice.payment_failed for subscriptions, customer.subscription.updated and customer.subscription.deleted for access, and charge.refunded plus charge.dispute.created for money going back out.
Everything else is optional. The real work is not subscribing to many events, it is handling the few you need correctly: verifying them, tolerating duplicates and out-of-order delivery, and never trusting the browser to say a payment succeeded. This is the logic generic security scanners miss, and AI-built apps often get it wrong.
Which Stripe webhook events does a simple checkout need?
For a one-time purchase through Stripe Checkout, the main event is checkout.session.completed. It tells you the customer finished the session. If you use delayed payment methods such as bank debits, also listen for checkout.session.async_payment_succeeded and checkout.session.async_payment_failed, because completed does not always mean paid. Check the payment_status field on the session before fulfilling.
If you build your own flow with Payment Intents, use payment_intent.succeeded and payment_intent.payment_failed.
- checkout.session.completed: create the order, grant access, send the receipt.
- checkout.session.async_payment_succeeded: fulfill orders that were pending.
- checkout.session.async_payment_failed: cancel the pending order and tell the user.
- checkout.session.expired: release reserved stock, if you reserve any.
A common mistake I see in AI-generated code is fulfilling the order on the redirect to the success URL. A user can reach that URL without paying, or close the tab before reaching it. The webhook is the source of truth.
Which events matter for subscriptions and failed renewals?
Subscriptions are where most bugs live, because the first payment is only the beginning. Renewals happen weeks later with no user present, and your app finds out only through events.
The minimum subscription set
- checkout.session.completed: link the Stripe customer and subscription IDs to your user.
- invoice.paid: the payment worked; extend access for the new period.
- invoice.payment_failed: the renewal failed; notify the user and mark the account as past due.
- customer.subscription.updated: plan changes, cancellation scheduled, status changes such as past_due or unpaid.
- customer.subscription.deleted: the subscription ended; remove access.
Decide what failure means
Stripe retries failed payments according to your retry settings in the dashboard. Your app needs a policy: does a user lose access on the first failure, or after retries are exhausted? Write that down, then map it to the subscription status. I prefer to derive access from the subscription status field rather than from a pile of flags updated by individual events, because it is easier to reason about and to repair.
Also consider customer.subscription.trial_will_end if you offer trials. It fires a few days before the trial ends, which is a good moment to email the user.

What about refunds and disputes?
Money can leave after the sale, and your app should notice. Two groups of events cover it.
- charge.refunded: a full or partial refund was issued, often from the dashboard by someone on your team. Update the order, and decide whether to revoke access or credits.
- charge.dispute.created: a customer challenged the charge with their bank. Flag the account, gather evidence, and consider pausing delivery.
- charge.dispute.closed: the outcome is known (won or lost). Update your records accordingly.
You may never automate these fully, and that is fine. Even a simple handler that writes the event to a table and emails you is better than silence. Without it, a refunded customer keeps a paid plan, or you discover a dispute only when Stripe’s deadline for evidence has nearly passed. Dispute deadlines are real, so check Stripe’s current documentation for timing.
Why do events arrive out of order or more than once?
Stripe’s documentation states that it does not guarantee event delivery order, and that endpoints may receive the same event more than once. Retries happen when your server times out or returns a non-2xx status. So the assumptions many handlers make are wrong:
- You may get customer.subscription.updated before checkout.session.completed.
- You may get invoice.paid before your database knows the subscription exists.
- You may get the same checkout.session.completed twice, creating two orders.
Having worked on the cart and checkout team at Total Wine & More, a US retailer with 190 million site visits a year, I learned to treat duplicate and late messages as a normal condition, not an edge case. At that scale anything that can happen twice will. Small apps hit it too, just less often, and it tends to show up as a double order the week you get your first real traffic.
Practical defenses
- Return a 200 quickly, then do heavy work in a background job, so Stripe does not retry because you were slow.
- When an event references something you do not have yet, either fetch the object from the Stripe API or fail with a retryable status.
- Do not trust the payload as the latest truth. For critical decisions, retrieve the current object from Stripe.

How do I make my event handler idempotent?
Idempotent means running the handler twice has the same result as running it once. The simplest reliable pattern uses the event ID, which is unique per event.
- Verify the signature first using the raw request body and your endpoint secret. Reject anything that fails.
- Insert the event ID into a table with a unique constraint, in the same database transaction as your changes.
- If the insert fails because the ID exists, return 200 and do nothing.
- Otherwise apply the change and commit.
The unique constraint matters more than an if-check in code. Two deliveries arriving at the same moment can both pass a read check, but only one can win an insert.
Add a second layer on business objects: store the Stripe session or payment intent ID on the order with a unique constraint, so even a bug in event tracking cannot create two orders for one payment. Write handlers as state changes (set status to paid) rather than increments (add 30 credits) wherever possible; if you must grant credits, tie the grant to the event or invoice ID.
If your app is built on Supabase edge functions or a similar setup, the same rules apply. The database is the right place to enforce uniqueness, and the function should be treated as something that can run twice. For a real-world example of platform-level risk, read about Base44 security and the Wiz finding.
Which events should I ignore?
Stripe emits dozens of event types. In the dashboard you choose which ones go to each endpoint, so subscribe only to those you handle. A smaller list means less noise, fewer failures and less to test.
Commonly unnecessary for a basic app:
- customer.created, customer.updated, and most payment_method events, unless you sync them on purpose.
- charge.succeeded and payment_intent.created when you already act on checkout or invoice events. Handling both leads to double fulfillment.
- invoice.created, invoice.finalized and similar, unless you customize invoices.
- Events for products and prices, if you manage them only in the dashboard.
If an unexpected type reaches your endpoint anyway, log it and return 200. Returning errors for unknown events causes retries and can lead Stripe to disable the endpoint after repeated failures.
One caution: ignoring an event is a decision, not an accident. Keep a short list in your repo of which events you handle and why, so the next developer, or the next AI prompt, does not quietly change the behavior.
Want a second opinion on your webhook handler?
If you want a second pair of eyes on your Stripe setup, email me@filipeeduardo.dev with a description or the handler code, and I’ll tell you what I’d check first.
Frequently asked questions
Do I need webhooks if I use Stripe Checkout?
Yes. The redirect to your success page is not proof of payment. Webhooks are how you reliably learn that a payment completed, failed or was refunded.
What is the most important Stripe webhook event?
For one-time sales it is checkout.session.completed, with a check on payment status. For subscriptions it is invoice.paid, because it drives renewals, with invoice.payment_failed as a close second.
How do I stop duplicate orders from webhooks?
Store each event ID with a unique constraint and process it in the same transaction as your change. Add a unique constraint on the Stripe payment or session ID on the order as a second guard.
What should my endpoint return?
A 2xx status as soon as the event is verified and safely recorded. Return an error only when you want Stripe to retry, such as a temporary database failure. Check Stripe’s documentation for current retry behavior.
Can an AI builder set this up correctly?
It can produce a working first draft, but treat it as a draft. Review signature verification, idempotency and the access rules before real payments arrive.

