Stripe Webhooks: The Parts That Break in Production

By Aakash Verma · · 5 min read

Stripe webhooks are where a payment integration either becomes boring or becomes a support queue. The checkout page is the easy part. Whether a paid customer actually gets access, whether a cancelled subscription actually ends, and whether a failed card actually locks anything, all of that happens in a POST request Stripe sends to your server, when Stripe feels like it, possibly twice, possibly in the wrong order.

I've wired payment providers into a lot of apps, and Stripe's webhook docs are honestly good. The trouble is that the important sentences are scattered across several pages. These are the ones I'd tape to the monitor. Everything below is checked against Stripe's webhook docs on October 16, 2026.

The dashboard moved, and there's a new payload type

If you last set up a webhook a couple of years ago, two things look different. Endpoints are now created as "event destinations" in Workbench, which replaced the old Developers Dashboard, and you can register up to 16 of them. And you pick a payload format: snapshot (the full object, the classic event.data.object) or thin (a small notification you use to fetch the current object). Stripe recommends thin for new integrations, and it needs its own endpoint if you also listen to snapshot events.

I like thin events for one reason: you always fetch the latest object, which quietly fixes half the ordering bugs below. If you're maintaining an old integration that reads data.object everywhere, there's no rush to migrate. Snapshot events aren't going anywhere.

Verify the signature against the raw body

Every delivery carries a Stripe-Signature header, an HMAC-SHA256 of the timestamp plus the exact bytes of the body, signed with your endpoint's whsec_ secret. The official libraries check it for you. The catch is the word "exact". If anything parses the JSON before your handler sees it, the bytes change and verification fails with an error that tells you nothing useful.

In Express, that means the webhook route gets a raw body parser and must sit before any global express.json():

app.post('/stripe/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send(`Bad signature: ${err.message}`);
  }
  queue.add('stripe-event', { id: event.id, type: event.type });
  res.sendStatus(200);
});

In a Next.js route handler, read await req.text(), not req.json(). In Laravel, take $request->getContent() and make sure the route is outside the CSRF middleware; Stripe's docs warn about this for frameworks that check CSRF tokens on every POST.

Two smaller things. The test-mode and live-mode secrets for the same URL are different, and so is the secret stripe listen prints locally, so "it works on my machine" with the wrong secret in production is common. And the libraries reject a timestamp more than 5 minutes off by default. Keep the server clock on NTP, and don't "fix" a failing check by setting the tolerance to 0: Stripe's docs point out that 0 turns the replay protection off entirely.

A terminal running stripe listen, annotated: invoice.paid arrives before customer.subscription.created, one event is delivered twice with the same id, and a delivery fails because the endpoint answered with a 301 redirect

Answer fast, do the work later

Stripe wants a 2xx quickly, before your slow logic runs, and a timeout counts as a failure. So the handler above verifies, hands the event ID to a queue (BullMQ on Redis in a Node app, Laravel's queue in a PHP one) and returns. The worker does the database writes, the emails, the provisioning.

It matters most on the 1st of the month. Every subscription renews at once, Stripe sends a wave of invoice.* events, and a handler that sends a receipt email inline will start timing out right when the money is coming in.

Assume duplicates and assume the wrong order

Stripe says plainly that an endpoint "might occasionally receive the same event more than once" and that it "doesn't guarantee the delivery of events in the order that they're generated". Creating a subscription can produce customer.subscription.created, invoice.created, invoice.paid and charge.created, in any order.

So:

  • Keep a table of processed event IDs and skip anything already in it. A unique index on the ID and an insert at the start of the job is enough.
  • Don't use the event's created time to sort or dedupe. It's in whole seconds, and several events share the same second.
  • Don't trust the payload to be the current state. When invoice.paid arrives before you've ever seen the subscription, fetch the subscription from the API and work from that. (Thin events do this by design.)

A very common version of this bug is a handler that sets status = 'active' on invoice.paid and status = 'past_due' on invoice.payment_failed. Deliver those two in the wrong order after a retry and the customer is locked out of a subscription they paid for. Read the subscription's real status instead of inferring it from which event arrived last.

The redirect that fails every delivery

This one is quiet. If your endpoint URL answers with any 3xx, Stripe counts it as a failure. It doesn't follow the redirect. A URL registered as http://, a missing trailing slash your framework "helpfully" redirects, or a bare domain that 301s to www, and every single event fails while your logs show nothing, because your app never ran. Register the exact final URL, then check the Event deliveries tab for the endpoint in Workbench. It shows the status code of each attempt.

When it was down anyway

In live mode Stripe retries a failed delivery for up to three days with exponential backoff. In a sandbox it's only three retries over a few hours, which is why a weekend outage in test mode looks so different from one in production. After that you can resend by hand: from the dashboard for up to 15 days after the event, or with stripe events resend <event_id> --webhook-endpoint=<endpoint_id> for up to 30 days. Because your handler dedupes by event ID, resending everything from the outage window is safe.

For local testing, stripe listen --forward-to localhost:4242/webhook plus stripe trigger invoice.paid covers most of it. Note that --forward-to only works in the CLI in your own terminal, not in the Workbench Shell.

If you'd rather not

None of this is hard, but it's exactly the part of a payments integration that gets skipped when a deadline is close, and it's the part your customers notice. If your Stripe setup is already live and you're not sure the webhooks are doing what you think, I'm happy to look. The first call is free.

Introductory offer

Free cloud cost optimization. You only pay from what I save.

I cut your AWS, cloud, Supabase or Vercel bill for free and keep half of what I save for 6 months. Save nothing, pay nothing. Code, architecture and infrastructure.

See the offer →