Skip to content
5 stripe webhook bugs i shipped so you dont have to
article4 min read386 viewsOct 6, 2026

5 stripe webhook bugs i shipped so you dont have to

idempotency, ordering, timeouts and the wrong secret. all in production, all on a tiny SaaS with 30 customers

Chloe Barnes@chloebuilds

October 6, 2026 · 4 min read

#2 in Ledger Lines

386 views8 comments

my app takes payments through stripe billing, and stripe tells my app what happened (paid, failed, cancelled) through webhooks. webhooks look simple. a POST request arrives, you update a row. i have now shipped five different bugs in roughly 80 lines of code, so here they are, in the order they hurt me.

1. parsing the body before verifying the signature

stripe signs every webhook and you should check that signature, otherwise anyone who finds your endpoint can POST an "invoice.paid" and get a free account. the check needs the raw request body, byte for byte. my first version did await req.json() and later passed JSON.stringify(body) to the verify function. different whitespace, different bytes, every single signature failed, and i "fixed" it by... removing the check. please dont be me. in a next.js route handler read the text and verify that:

export async function POST(req: Request) {
  const body = await req.text()
  const signature = req.headers.get("stripe-signature") ?? ""
  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
  } catch {
    return new Response("invalid signature", { status: 400 })
  }
  // only now act on event
}

2. assuming each event arrives once

stripe can deliver the same event more than once. retries, network blips, whatever. my handler sent a "welcome, you're subscribed!" email on checkout.session.completed and one groomer got it three times and asked if she'd been charged three times. (she hadnt.) fix: store every event id with a unique constraint and skip the ones youve already handled.

create table stripe_events (
  id text primary key,
  processed_at timestamptz not null default now()
);

3. assuming events arrive in order

they dont, and the docs say so. i had customer.subscription.updated arrive before customer.subscription.created, my code tried to update a row that didnt exist yet and threw. even worse, an old "past_due" update arrived after a newer "active" one and briefly locked a paying customer out.

what fixed it for good was treating the webhook as a nudge, not as the truth. when any subscription event arrives i take the subscription id, fetch the current subscription from the API, and save that. one extra API call, zero ordering bugs since.

4. doing slow work inside the handler

stripe wants a 2xx back quickly. my handler was generating a PDF receipt and sending an email before responding. the day my email provider was slow, requests timed out, stripe marked them failed and retried, and with bug 2 still half fixed at the time you can guess what happened. now the handler verifies, records the event, puts a job on a queue and returns 200. the slow stuff happens after.

5. the wrong signing secret in production

every webhook endpoint has its own signing secret, and the stripe CLI's stripe listen gives you a different one again for local testing. i deployed with the CLI secret in my production env vars. every live webhook failed signature for two days and i only found out when stripe emailed me that my endpoint was failing. two days of payments my app didnt know about. i now have a tiny health check that alerts me if no webhook has been processed in 24 hours, which for a 30 customer business is a decent smoke alarm.

bonus: test the boring future

renewals, failed cards and trial endings happen weeks after signup, which is exactly when you've forgotten how your code works. stripe test clocks let you create a customer in test mode and move time forward, so you can watch a trial end and a renewal fail in 5 minutes instead of 30 days. i found a bug in my failed payment emails this way before any customer did, which made a nice change.

none of this is advanced. the happy path works on day one and the rest only shows up when real money is moving. if youre about to wire up payments for your side project, go through these five before your first customer does it for you.

Written by

Chloe Barnes

bootstrapping a tiny scheduling SaaS for dog groomers from manchester. ex agency dev. sharing the revenue numbers, the mistakes and the occasional win. tea > coffee

Get the best of #ledger_lines weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

8

Sign in to join the conversation.

Sign in to comment
Arjun Mehta@arjunmehta#1 in Ledger Lines
Very good list. One subtle thing on bug 2 now that you queue the work (bug 4): if you insert the event id when it arrives and the job fails later, the retry from Stripe gets skipped as a duplicate and the event is lost. Either insert the id in the same transaction as the processing, or keep a status (received / processed) and only skip events that are processed.
1 net votes
Chloe Barnes@chloebuilds#2 in Ledger Lines
ok that is a 6th bug and i shipped it while writing a post about bugs i shipped. fixing it tonight, moving the insert into the same transaction. thank you (i think)
2 net votes
Fatima Al-Sayed@fatima_appsec#3 in Ledger Lines
Good security basics in here. Two additions: never log the raw payload, it has customer emails and addresses in it. And constructEvent already checks the timestamp tolerance, don't disable it to 'fix' a clock problem, that check is what stops replayed requests.
1 net votes
Jake Morrison@jakemakesgames#6 in Ledger Lines
chloe out here handling idempotency and out of order events while i cant get my chickens to stop walking through fences. proud of u
2 net votes
Chloe Barnes@chloebuilds#2 in Ledger Lines
jake your chickens are a race condition. ship the game
2 net votes
Jake Morrison@jakemakesgames#6 in Ledger Lines
the chickens are a feature. its called emergent gameplay
3 net votes
Kevin Tran@kevtran_dev1 rep in Ledger Lines
saving this for when i build my first thing with payments. dumb question: is it bad to just poll the stripe api every few minutes instead of webhooks? webhooks seem scary
1 net votes
Arjun Mehta@arjunmehta#1 in Ledger Lines
Not dumb. Polling works at small volume but you will hit rate limits eventually, and you'll be late for things like failed payments. Webhooks plus 'fetch the current state' like Chloe describes is the sweet spot. Not scary, just never trust the order or the count.
1 net votes