Avoid Stripe Webhook Pitfalls with Pre-wired Handlers
Stripe webhooks are a forty-line file that takes forty hours to learn — not because the Stripe SDK is hard, but because two failure modes hide in plain sight. Most teams ship both, then patch them in production after a customer complains.
This post is the short version of what a pre-wired handler gets right by default, and why that default is the part of your stack that doesn't change when the model does.
The two failure modes, in order of how often they ship
Missing signature verification. Anyone who knows your webhook URL can POST to it. If your handler trusts the body and acts on event.type === 'checkout.session.completed', an attacker fills a fake order, your backend marks it paid, and now you're shipping a skateboard to a stranger on your dime.
The fix is stripe.webhooks.constructEvent(payload, signature, secret). It verifies the signature against your endpoint's signing secret using a constant-time compare, and throws if the timestamp is more than five minutes off (Stripe's default tolerance). That's the easy half.
The retry storm. Stripe retries any non-2xx response, every few hours, for up to three days. That's a feature, not a bug — a flaky deploy or a brief DB outage doesn't drop events. But it means your handler must be idempotent on event.id.
A non-idempotent handler looks correct in dev: you click "send test webhook" once, your order moves to paid. In prod it fires three times because Postgres blinked at minute two, and now you have three fulfillment rows and one very confused warehouse.
The fix is small: a webhook_events table with a unique constraint on event_id, and INSERT ... ON CONFLICT DO NOTHING before doing any work.
Why the raw body trips everyone
Express's default app.use(express.json()) parses every request as JSON and replaces req.body with a plain object. By the time your handler runs, the raw bytes are gone — constructEvent sees a stringified version of an object, not the original signature payload, and throws.
The fix is to mount the raw parser only on the webhook route, before the JSON middleware:
// raw body, only on the webhook route
app.post(
'/api/stripe/webhook',
express.raw({ type: 'application/json' }),
handleStripeWebhook
)
app.use(express.json()) // JSON parser for everything elseOrder matters. If express.json() runs first, req.body is already an object by the time handleStripeWebhook is invoked, and verification fails on every event. The error message is unhelpfully generic — No signatures found matching the expected signature for payload — which sends you on a twenty-minute goose chase through env-var typos before you realise it's the body parser.
11 production screens. Login, database, payments — all wired.
The SaaS Dashboard Kit ships everything already connected. Nothing to set up. Live demo at saas.otf-kit.dev.
The shape of a correct handler

Here's the handler the SaaS Dashboard kit ships — about forty lines, both failure modes closed:
import Stripe from 'stripe'
import { db } from '@/db'
import { webhookEvents, orders } from '@/db/schema'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
export async function handleStripeWebhook(req, res) {
const sig = req.headers['stripe-signature']
if (!sig) return res.status(400).send('Missing signature')
let event: Stripe.Event
try {
// req.body is a Buffer here, not a parsed object
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret)
} catch (err) {
console.error('[stripe] signature verification failed', err)
return res.status(400).send(`Webhook Error: ${(err as Error).message}`)
}
// Idempotency: insert event id; if it already exists, we're done.
const inserted = await db
.insert(webhookEvents)
.values({
id: event.id,
type: event.type,
payload: event,
receivedAt: new Date(),
})
.onConflictDoNothing()
.returning({ id: webhookEvents.id })
if (inserted.length === 0) {
// already processed — Stripe retried, that's fine
return res.json({ received: true, duplicate: true })
}
// Switch on event type, return fast.
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
await fulfillOrder(session.metadata.orderId, session.id)
break
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription
await cancelSubscription(sub.metadata.userId)
break
}
// every other type: accept and ignore
}
} catch (err) {
// We already wrote the event row. Log loudly so we can replay manually.
console.error('[stripe] handler failed', { eventId: event.id, err })
return res.status(500).send('handler failed')
}
return res.json({ received: true })
}Three things worth pointing out:
- Signature verification is the first thing. Before any DB write, before any
switch. A bad actor never reaches your business logic. - The idempotency check is the second thing. It uses
INSERT ... ON CONFLICT DO NOTHING. If the row already exists, return 200 immediately — no fulfillment, no charge. Stripe stops retrying. - The handler returns fast. Heavy work (sending emails, calling third-party APIs) should be queued, not done inline. A 200 in under five seconds keeps Stripe happy. A thirty-second handler means thirty seconds of piled-up retries.
The schema
// db/schema/webhooks.ts
import { pgTable, text, jsonb, timestamp } from 'drizzle-orm/pg-core'
export const webhookEvents = pgTable('webhook_events', {
id: text('id').primaryKey(), // Stripe event id, e.g. evt_1Oabc...
type: text('type').notNull(), // 'checkout.session.completed' etc.
payload: jsonb('payload').notNull(), // full event for replay
receivedAt: timestamp('received_at').defaultNow().notNull(),
processedAt: timestamp('processed_at'), // null until handler succeeds
})Two indexes worth having: type (for show me every customer.subscription.deleted this month) and receivedAt (for the operational question: are we falling behind?).
The things that aren't Stripe's fault
A pre-wired handler also closes a few smaller gaps people hit in their first attempt:
| Gap | What goes wrong | The default |
|---|---|---|
| Mounting the raw parser globally | Every endpoint gets a Buffer body, your JSON routes break | Raw parser only on /api/stripe/webhook |
Forgetting STRIPE_WEBHOOK_SECRET | constructEvent throws at boot, webhook is silently dead | Env var validated at boot, fails loudly with a clear error |
| Doing heavy work inline | Handler takes 30s, Stripe times out, retries pile up | 200 returned fast, heavy work enqueued |
| Logging the whole payload | PII in logs, GDPR question marks | Only event.id and event.type logged by default |
Trusting event.data.object.metadata blindly | Anyone who knows your endpoint can pass arbitrary metadata | Server-side validation against the order id in our DB before fulfillment |
None of these are exotic. They're the boring defaults that take an afternoon to learn and five minutes to ship pre-wired.
How to test it
Stripe ships a CLI that forwards real webhooks to your local machine. Three commands:
# install once
brew install stripe/stripe-cli/stripe
# log in (opens browser)
stripe login
# forward events to localhost:3000
stripe listen --forward-to localhost:3000/api/stripe/webhookThe CLI prints a whsec_... signing secret for the forwarded session — use that, not the one from your dashboard, when testing locally. To trigger a specific event:
stripe trigger checkout.session.completedTo test idempotency, trigger the same event twice. The first call inserts a row and fulfills; the second hits onConflictDoNothing, returns { duplicate: true }, and doesn't double-fulfill. That's the entire regression test for the retry-storm failure mode.
What this gets you
A handler that survives the four realistic production conditions: a Stripe-side retry during a brief DB blip, a forged POST from a curious attacker, a deploy that takes longer than Stripe's patience window, and an event type you don't care about that's arriving every minute because you subscribed to too many.
In all four, the same handler returns 200 quickly, logs only what should be logged, and never double-fulfills. That's the bar — not moved to prod, but didn't page me at 2am.
The OTF angle — durable defaults under the model churn
Here's the part that doesn't change when the model does: every production webhook handler needs signature verification, idempotency, and a fast 200. These are properties of the protocol, not properties of whatever AI tool wrote the code for you.
That's the bet the SaaS Dashboard kit makes. You install it, wire STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET, run the deploy script, and the handler is already there — verified, idempotent, tested against Stripe's CLI, logging only event.id and event.type. The kit also ships CLAUDE.md and .cursorrules so when you ask your coding agent to extend billing, it reads the existing handler conventions instead of inventing new ones. If you switch from Claude Code to Cursor to whatever ships next quarter, the webhook still works.
The same logic applies to the rest of the kit — auth flows, billing dashboards, subscription management, the deploy-to-custom-domain script. They're the parts of your app that shouldn't move every time the tool churn does.
The new tools are how you build the parts that change fast. The kit is what you stand on for the parts that shouldn't.
Stripe webhooks are easy to write and easy to write wrong. The two failure modes — signature verification and idempotency — aren't exotic; they're just not obvious until you've debugged them at 2am with a real customer double-charged.
If you want the handler without the 2am, the SaaS Dashboard kit ships it pre-wired — $99, you own the code, free MIT SDK on npm. If you want to write it yourself, copy the pattern: raw body, constructEvent, ON CONFLICT DO NOTHING, fast 200, queue the heavy work. The shape doesn't change either way.
Ship the product, not the setup.
- 11 production screens — auth, billing, team, analytics, settings
- Real database, payments, and login — all wired on day 1
- AI configs pre-tuned so your agent extends instead of regenerates