# Avoid Stripe Webhook Pitfalls with Pre-wired Handlers

> Learn how pre-wired webhook handlers prevent common Stripe integration mistakes like signature verification and idempotency issues
> By Dave · 2026-07-18
> Source: https://otf-kit.dev/blog/the-webhook-you-will-get-wrong-once

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:

```ts
// 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.

## The shape of a correct handler



![Stripe POST → raw body parser → constructEvent verifies signature → INSERT event.id ON CON](https://cdn.otf-kit.dev/blog/the-webhook-you-will-get-wrong-once/inline-1.png)



Here's the handler the SaaS Dashboard kit ships — about forty lines, both failure modes closed:

```ts
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

```ts
// 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:

```bash
# 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:

```bash
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.

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.