Skip to content
All posts

Avoid Stripe Webhook Pitfalls with a Handler You Add

DaveOTF Kit
8 min read
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 else

Order 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.

See the live demo

The shape of a correct handler

Stripe POST → raw body parser → constructEvent verifies signature → INSERT event.id ON CON

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:

  1. Signature verification is the first thing. Before any DB write, before any switch. A bad actor never reaches your business logic.
  2. 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.
  3. 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:

GapWhat goes wrongThe default
Mounting the raw parser globallyEvery endpoint gets a Buffer body, your JSON routes breakRaw parser only on /api/stripe/webhook
Forgetting STRIPE_WEBHOOK_SECRETconstructEvent throws at boot, webhook is silently deadEnv var validated at boot, fails loudly with a clear error
Doing heavy work inlineHandler takes 30s, Stripe times out, retries pile up200 returned fast, heavy work enqueued
Logging the whole payloadPII in logs, GDPR question marksOnly event.id and event.type logged by default
Trusting event.data.object.metadata blindlyAnyone who knows your endpoint can pass arbitrary metadataServer-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/webhook

The 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.completed

To 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

backendkitsstripe
OTF SaaS Dashboard Kit

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
Free starter

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.

Free with your email

Get the free AI configs pack

The AI files every kit ships with. Drop them in and your AI tool knows your project.

  • CLAUDE.mdProject context
  • .cursorrulesRules for Cursor
  • prompts/Task prompts

No spam. Unsubscribe any time.