Your Stripe webhook returns 200 OK and does nothing. Here's the 15-minute test before your first paid user

Your Stripe webhook returns 200 OK and does nothing. Here's the 15-minute test before your first paid user

Harsha Vardhan audited three indie SaaS products built with Cursor this month. All three had the same silent billing bug. The webhook handler caught the Stripe event, logged it, returned 200 OK, and then did nothing. Stripe marked the event delivered and moved on. No access was revoked, no subscription updated, nothing in the database moved. The founder did not know.

If that sounds like an edge case, it isn't. A separate audit of 279 AI-built SaaS products put the number at 64% with some form of critical billing or auth exposure. Half the apps on your feed right now are probably in that set.

Why Cursor and Claude Code specifically ship this bug

Ask the agent to "handle Stripe subscription events." It writes a webhook endpoint. The signature is correct. It parses the event, logs it, returns Response.json({ ok: true }, { status: 200 }). Every linter passes. Every type check passes. The agent says done.

What's missing is the part that changes your system. The customer.subscription.created case isn't wired to mark the user as paid. The customer.subscription.deleted case isn't wired to revoke access. Cursor and Claude Code both tend to write the skeleton of an event handler and leave the actual mutations as implicit. The code reads like it works because the happy path of "receive event" works. The sad path is that nothing happens to your database after.

I've shipped this bug myself and caught it the weekend before launch. It's the kind of thing where you refresh your Stripe dashboard, see three successful events, and think "great, we're live." Then you open the database and realize the three test subscriptions are all still marked as free.

A human reviewer would catch it. A first-time SaaS founder wouldn't. By the time you notice, you have paying customers with wrong access levels and a Stripe dashboard that insists everything is fine.

The 15-minute test

Not a security audit. The minimum you owe your first paying user.

Install the Stripe CLI (brew install stripe/stripe-cli/stripe on Mac, or grab the binary for your OS). Log in with stripe login.

Forward events from Stripe test mode to your local endpoint:

stripe listen --forward-to localhost:3000/api/webhooks/stripe

Copy the signing secret it prints. Put it in your .env where your webhook expects it.

In a second terminal, trigger the four events that actually matter:

stripe trigger customer.subscription.created stripe trigger customer.subscription.updated stripe trigger customer.subscription.deleted stripe trigger invoice.payment_failed

After each one, open your database and look. Not your logs. Your database. Did the users table update? Did subscription_status change? Did the free/paid flag flip? If your handler returned 200 but the row didn't change, it's a dead handler.

Fifteen minutes. You now know whether your billing plumbing is wired up or just sitting there pretending.

What else goes wrong in the same shape

Stripe webhooks are the most expensive version of this bug. They aren't the only one.

A vibe-coded app writes the signup flow, returns a JWT, and never actually enforces row-level security on the is_paid column in Supabase. Users can update their own flag from the browser. One audit on r/vibecoding this month found this in multiple Lovable showcase apps that were open for any anonymous visitor to flip to paid.

Auth session revocation is another. User cancels, the handler logs the cancel, but the session token stays valid for the next 30 days. They keep using the paid feature until their JWT expires on its own schedule.

Email verification callbacks have the same shape. The verify handler returns 200, the email_verified column stays false, and the new user gets stuck in a password reset loop that never resolves.

What these all share is the handler having the correct signature and the correct response code, and the agent deciding that's done. The mutation step is where you need a human looking.

Where I'd hedge

If you've reviewed every webhook handler line by line, this isn't about you. The people who catch these bugs tend to be the ones who've already been burned once. The failure mode is specifically the first-time SaaS founder who trusts the agent's "done" signal more than they should. If that's you, the fifteen-minute test above is worth more than any pattern library you could read this weekend.

The fix on the agent side is simpler than it sounds. Next time you're writing a webhook with Claude Code or Cursor, ask it: "After handling this event, which database rows change? Show me the write." If the agent can't answer that without re-reading the code, the handler isn't done.

The one thing you can't vibe-code

Billing. A 20-year engineer on r/replit posted this month that they looked at their own 400 lines of Stripe integration and couldn't confidently explain half of it. That's the red flag. The rest of your app can be agent-written and iteratively fixed. Billing is where one silent bug means either chargebacks you can't dispute or a free tier that wasn't supposed to be free.

Ship the test before the first paid user. Not after.