Avoid Stripe Webhook Pitfalls with a Handler You Add
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 covers two webhook failure modes and a handler pattern you add yourself, then keep stable as the rest of your stack changes.
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.
12 production screens. Login and database wired.
The SaaS Dashboard Kit includes login, a database, and a working dashboard. Live demo at saas.otf-kit.dev.
The shape of a correct handler

Here is an example handler you can add, following the Stripe webhook path in the SaaS Dashboard kit's customization guide. This code is illustrative; the handler is not included in the kit:
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
The handler you add should also close a few smaller gaps teams hit in a first implementation:
| 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 are straightforward safeguards to include in the handler you implement.
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.
The SaaS Dashboard kit's customization guide says it does not ship a payment integration. It documents a path to add per-user paid plans: add a subscription table, implement a Checkout route and webhook, then connect a client upgrade flow. If you add billing, implement and test the handler in your app; the example code below is not functionality included in the kit.
Before relying on a starter in production, verify which features are shipped and which steps its customization guide asks you to implement.
Agent tools can help you implement the changes; the delivered code and its documented extension path determine what your app actually includes.
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 to add this handler to SaaS Dashboard, follow the kit's documented customization path and implement the pieces in your app. Adapt and test the pattern above: raw body, constructEvent, ON CONFLICT DO NOTHING, fast 200, queue the heavy work. The shape doesn't change either way.
Sources
- SaaS Dashboard kit customization guide, Payments section — read 9 October 2026. The guide states that the kit does not ship a payment integration and documents steps for adding one.
Ship the product, not the setup.
- 12 production screens — auth, team, analytics, and settings
- Login and a real database are wired in
- AI configs pre-tuned so your agent extends instead of regenerates
Ship your first screen tonight — free starter in your inbox
The quickstart, the SDK repo and the live kit demos, on this screen and in your inbox.
No spam. Unsubscribe any time.