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:

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.

Edge case: if your dedup key is set BEFORE the work runs and the work fails, you can't retry. Either (a) set the key after work succeeds, or (b) make the work itself idempotent (Resend supports an idempotency key on email sends). The "set first, work second" pattern is simpler and good enough if you have monitoring on send failures.

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:

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

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