Why Stripe Fires 6+ Webhook Events Per Checkout (and How to Handle Each)
A single Stripe Checkout session generates between 4 and 8 webhook events, all delivered within ~1 second of payment success. If you're naive about it, you'll fire 6 customer emails for a single purchase, charge a customer twice for "$1 retry tests," or skip the event you actually wanted. Below: every event you'll see, which one to act on, and how to handle the rest without duplicates.
What you'll see in your logs
For a typical successful checkout, the events arrive in this order, often within a single second:
19:29:14.98 POST /api/stripe-webhook 200 charge.succeeded
19:29:12.10 POST /api/stripe-webhook 200 payment_intent.succeeded
19:29:12.03 POST /api/stripe-webhook 200 payment_intent.created
19:29:11.95 POST /api/stripe-webhook 200 payment.succeeded (legacy)
19:29:11.89 POST /api/stripe-webhook 200 checkout.session.async_payment_succeeded
19:29:11.85 POST /api/stripe-webhook 200 checkout.session.completed
Plus, depending on your subscription / customer setup, you may also see:
customer.created(if Stripe created a new customer object during checkout)customer.updated(if the customer's email or address changed)invoice.paid(if the checkout was for a subscription)invoice.payment_succeeded(subscription, again)charge.captured(if you use auth-then-capture flow)
That's why the burst can hit 8+ for subscriptions or auth-then-capture, and 4-6 for one-time charges with the simplest setup.
Which event should fire your email?
For one-time payments (most indie hacker setups), you want one of two events depending on what you're paying for:
| Use case | Event to act on | Why |
|---|---|---|
| Customer paid via Stripe Checkout | checkout.session.completed |
Single source of truth for "checkout successfully completed." Fires after Stripe collects payment and before any webhook ordering chaos. |
| You charge programmatically (no Checkout) | payment_intent.succeeded |
Use when you call stripe.paymentIntents.create directly without going through Checkout. |
| Subscription start | customer.subscription.created |
Fires once when a new subscription begins. |
| Subscription billing renewal | invoice.paid |
Fires every billing cycle. Use this for receipt emails, not charge.succeeded (the charge fires per-invoice anyway). |
Pick exactly one. All other events for the same checkout are noise — return 200 and move on.
The wrong way: handling every event type
// BAD — fires customer email 6 times for one checkout
export default async function handler(req, res) {
// ... sig verification ...
const event = JSON.parse(rawBody.toString());
// Handle every event type; oops
await sendCustomerEmail(event.data.object.customer_email);
return res.status(200).json({ received: true });
}
This dispatches an email on every event in the burst — checkout completion, payment intent created, payment intent succeeded, charge succeeded, etc. Customer gets 6 emails. They don't unsubscribe; they refund.
The right way: filter by event type
export default async function handler(req, res) {
// ... sig verification ...
const event = JSON.parse(rawBody.toString());
// We only care about successful checkouts
if (event.type !== 'checkout.session.completed') {
return res.status(200).json({ received: true, ignored: event.type });
}
const session = event.data.object;
await sendCustomerEmail(session.customer_details.email);
await sendFounderEmail({ amount: session.amount_total, /* ... */ });
return res.status(200).json({ received: true });
}
The ignored: event.type response is useful in your Vercel logs — you can see which events arrived without doing the work.
Idempotency: what if Stripe retries?
Even with the right event type filter, you can still send duplicate emails if Stripe retries the same event. Stripe retries failed deliveries on a 5-min, 1-hr, 2-hr, 4-hr, 8-hr backoff for up to 3 days. If your endpoint returns 500 once and 200 the second time, your retry-of-retry logic might trigger duplicate sends.
The fix: idempotency keyed on the Stripe event ID.
// Pseudo-code; adapt to your DB / KV
const seen = await kv.get(`stripe:event:${event.id}`);
if (seen) {
return res.status(200).json({ received: true, dedup: true });
}
await kv.set(`stripe:event:${event.id}`, true, { ex: 86400 * 7 });
// Now do the work. If anything fails, you can retry safely
// because the dedup key is set.
For low-volume indie products, even a simple in-memory or file-based dedup works. For higher volume, use Vercel KV, Upstash, Redis, or a database table with a uniqueness constraint on event ID.
What about ordering?
Stripe does NOT guarantee event delivery order. charge.succeeded can arrive before checkout.session.completed for the same checkout. If your handler depends on a specific order — for example, "create customer record on customer.created, then fire email on checkout.session.completed using that record" — you have a race condition.
Two solutions:
Solution 1: Use Stripe API expand to get everything from a single event.
// On checkout.session.completed, expand line_items + customer
const session = await stripe.checkout.sessions.retrieve(
event.data.object.id,
{ expand: ['line_items', 'customer'] }
);
// Now you have everything in one place
Solution 2: Make handlers idempotent and tolerant of out-of-order events. Each event handler should be able to run independently. If "create customer record" runs second, that's fine — the email handler just looks up the customer record by Stripe customer ID, finds it, and proceeds.
What about the "$1 retry test" double-charge?
If you're testing a webhook with a $1 live charge and refunding it, you'll see:
checkout.session.completed(amount_total: 100)charge.succeeded(amount: 100)charge.refunded(amount: 100)charge.dispute.created(only if you actually disputed it; rare)
The refund event has the SAME charge ID as the original. If your handler treats charge.refunded as a new sale, you've just emailed the customer "your purchase is complete" twice. Filter explicitly:
if (event.type === 'charge.refunded') {
// Send refund-confirmation email or update internal state.
// Do NOT treat this as a new sale.
}
Summary checklist
- [ ] Filter for exactly the event type you care about; return 200 +
ignoredon the rest - [ ] Implement idempotency keyed on Stripe event ID (event.id)
- [ ] Don't depend on event delivery order — use API expand or write handlers tolerant of out-of-order events
- [ ] Handle
charge.refundedseparately from initial sale events - [ ] Always return 200 even on downstream failure (per the 400-fix article)
- [ ] Log
event.typein your access logs so you can audit which events arrived
Want this implemented in your repo without writing it yourself?
$199 flat (first 3 clients, then $399). Includes proper event-type filtering, idempotency, customer + founder email automation. Verified live before delivery.
See the offer →Free webhook signature diagnostic if you're still in the 400-error stage: /webhook/diagnostic/
Working source for the webhook this article extracts patterns from: github.com/bshelby88/sentry-forge-landing/api/stripe-webhook.js