Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

Your Stripe Webhook Works in Test Mode. Here's How It Breaks in Production.

A Stripe webhook can "work" for weeks. Test events go green, stripe trigger is happy, the first few real payments flip accounts to paid. Then one customer emails asking why they're still on the free plan after paying. St

A Stripe webhook can "work" for weeks. Test events go green, stripe trigger is happy, the first few real payments flip accounts to paid. Then one customer emails asking why they're still on the free plan after paying. Stripe says the event was delivered. Your database says nothing happened.

That's the thing with webhooks on a tiny SaaS. They don't fail loudly. They fail on a Tuesday, for one customer, and you find out from an email.

Here's what I check now, in the order I'd fix it if I was starting over. Most of this comes straight from Stripe's own webhook docs, which are honestly better than most blog posts on the topic. Most of us just don't read them properly the first time.

1. Verify the signature on the raw body

If you don't verify, anyone who finds your endpoint URL can POST a fake checkout.session.completed and get a paid account. So you verify the Stripe-Signature header with your whsec_ secret.

The trap is that verification needs the raw request body. If your framework parses JSON first (Express with express.json() on everything, for example), the bytes change and verification fails every time. People then "fix" it by turning verification off. Don't.

In Express it looks roughly like this:

app.post(
  "/stripe/webhook",
  express.raw({ type: "application/json" }),
  async (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");
    }
    // store it, answer fast, process later (see below)
  }
);

Two smaller things from the docs that are easy to miss. Your test and live endpoints have different secrets, so check which one is in prod env. And if you use Rails or Django, the CSRF protection can quietly reject Stripe's POSTs, so exempt that one route.

2. Assume every event can arrive twice

Stripe says it plainly: your endpoint might get the same event more than once. That sounds harmless until it's two welcome emails, two credits added, a usage counter bumped twice.

The cheapest fix I know is a table with a unique key:

CREATE TABLE stripe_events (
  id           text PRIMARY KEY,      -- evt_...
  type         text NOT NULL,
  payload      jsonb NOT NULL,
  received_at  timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz
);

Insert with ON CONFLICT (id) DO NOTHING. If nothing got inserted, you've seen it already, return 200 and move on. The docs also mention that sometimes Stripe creates two separate event objects for the same thing, so for the important handlers I also check the object id plus event type before doing anything with side effects.

3. Answer in under a second, do the work after

Stripe wants a 2xx back quickly, before your complex logic. If your handler sends an email, calls another API and updates three tables before responding, one slow call means a timeout, Stripe marks it failed and retries, and now you're in duplicate land again.

What I'd do: verify, insert into stripe_events, return 200. A small worker (or a cron every minute, on a tiny app that's fine) picks up rows where processed_at is null, handles them, and sets processed_at. If processing throws, the row just stays unprocessed and I can see it.

That table also turns into the best debugging tool you have. "Did we get the event?" becomes a query instead of a guess.

4. Don't trust the order

This is the one behind that "I paid and I'm still on free" email. Stripe doesn't guarantee events arrive in the order they happened. A new subscription can produce customer.subscription.created, invoice.created, invoice.paid and more, and they can show up shuffled. If your code expects the subscription row to exist before invoice.paid lands, sometimes it won't, the handler bails, and you still return 200.

Two rules handle it:

  • Don't use the event created timestamp to sort things out. It's in seconds and different events can share it. Stripe says to track event ids instead.
  • When an event references something you don't have yet, fetch the current object from the API instead of giving up. For billing state I'd go further and barely trust the payload at all: treat the event as a "something changed for customer X" ping and re-read the subscription from Stripe.

That second rule removes a whole class of bugs, because your database just mirrors whatever Stripe says right now.

5. Listen to fewer events, and watch the deliveries

It's tempting to subscribe to basically everything because it feels safer. It isn't, it's just more noise and more load. For a simple subscription SaaS I'd start with something like:

  • checkout.session.completed
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.paid
  • invoice.payment_failed

Add more when a real feature needs one.

And look at the Event deliveries tab for your endpoint once a week. It shows delivered, pending and failed, with the status code. A 3xx means you registered a URL that redirects (http to https, or www vs bare domain), and Stripe counts a redirect as a failure. In live mode Stripe keeps retrying for up to three days, which is generous, but it also means a broken deploy on Friday night can look fine until Monday.

I'd also put the webhook on whatever alerting you already have. I wrote up a small setup for that in how to monitor a side project while you're at your day job, and "unprocessed stripe_events older than 10 minutes" is now one of the checks.

The 30-minute version

If you only have half an hour this week:

  1. Confirm signature verification is on and uses the raw body.
  2. Add the stripe_events table with the event id as primary key.
  3. Move slow work out of the request.
  4. Make billing handlers re-fetch the subscription instead of trusting order.
  5. Open Event deliveries and look for anything that isn't 200.

None of this is fancy. It's just the stuff that separates "works in test mode" from "nobody emails you asking why they paid and got nothing". The invoice.payment_failed side of this is its own topic, I wrote about the failed-payment emails I send under 50 customers if you're setting that up next.

What's the weirdest webhook bug you've hit? I'm guessing someone has a redirect story.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.