JavascriptTypescriptReact

Stripe Webhook Idempotency, the Production Pattern

Build a Stripe webhook handler that survives retries, replays, out-of-order events, and signature failures without double-granting a subscription.

Last updated: 1 Aug 2026

A Stripe webhook arrow hitting an events table gateway while a duplicate arrow bounces off a unique-key constraint.

Stripe webhook idempotency is the production pattern that turns a flaky payments integration into a handler you can leave alone. The shape is small: a uniqueness constraint on Stripe event.id, an events table written before any business logic, a transactional state change, and a 200 response only after the row is committed. Skip it, and one slow deploy double-grants a subscription.

TL;DR, the Stripe webhook idempotency rule

A correct Stripe webhook handler does seven things, in this order, every time. Read the raw request body so the signature verifies against the exact bytes Stripe sent. Verify the signature with stripe.webhooks.constructEvent. Insert the event into a table whose stripe_event_id column is UNIQUE before doing any business work. Let the duplicate-key error be the dedup signal on retries and replays. Derive state by refetching the current object from the Stripe API rather than trusting the payload, because events arrive out of order. Wrap the insert and the side effect in a single transaction (or use a two-phase commit pattern where you cannot). Return 200 only after the row is committed, and never before. Get those seven steps right and the handler becomes replay-safe by construction.

Why the tutorial handler eventually double-grants

The handler that ships in every Stripe tutorial verifies the signature, parses the event, runs the side effect, and returns 200. It works in a demo. A manual smoke test in staging also passes, because nobody on the QA team replays the same event twice in the same minute. Then the handler double-grants the first time production traffic hits it, because the demo never exercised the two cases that matter: a Stripe retry and an out-of-order delivery. Stripe documents both as expected behaviors under the webhook best practices guide, and they are the reason the production pattern always includes an events table.

Here is the version a new repo usually ships with.

server/api/stripe/webhook.ts (the tutorial handler)

import express from 'express'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!

export const webhookRouter = express.Router()

webhookRouter.post(
  '/stripe/webhook',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['stripe-signature'] as string

    let event: Stripe.Event
    try {
      event = stripe.webhooks.constructEvent(req.body, signature, endpointSecret)
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${(err as Error).message}`)
    }

    if (event.type === 'customer.subscription.created') {
      await grantSubscription(event.data.object as Stripe.Subscription)
    }

    res.json({ received: true })
  },
)

That handler is one network blip away from a double-grant. The signature check is correct, the body is raw, the secret is right. None of that protects you when Stripe retries the same event because your grantSubscription call took nine seconds to complete on a cold start and the platform returned a 5xx. Two retries deep, the user has been granted access three times, and the support ticket lands the next morning.

Stripe retries, your slow handler, and the duplicate event

Stripe retries every webhook delivery that does not get a 2xx response within the timeout window, and it keeps retrying for up to three days on an exponential schedule. The handler does not have to crash for this to fire. A Lambda cold start that pushes the response past the platform's 10-second cap is enough. One transient 503 from a backend dependency is enough. Anything that causes the response to come back as 5xx, or not come back at all, queues the same event for redelivery with the same event.id.

The naive handler treats each delivery as a fresh event. On the first call, the subscription is granted, the response is slow, Stripe times out and retries, the second call grants the subscription again. By the time you read the Slack message, the customer is paying once and consuming three seats. Retries are the headline failure, and they happen often enough that any handler without dedup will hit one within its first week of real traffic.

Out-of-order events from parallel webhooks

The second failure is quieter and harder to debug. Stripe does not guarantee the order in which webhook deliveries arrive, and that holds even when the underlying events happened in a clear sequence on Stripe's side. Three events can fire on the same subscription within seconds: customer.subscription.created, then customer.subscription.updated flipping the status to active, then customer.subscription.deleted. Your endpoint can receive them in any order, and a retried delivery from earlier in the day can land in between two fresh ones. The Stripe events API documents this directly.

If your handler derives state from event.data.object, the last event wins on your side, even when it was the first event on Stripe's side. Your database now shows the subscription as active, but the user canceled it ten minutes ago on Stripe. The production pattern is to never trust the payload as the source of truth, and instead refetch the current state inside the handler. Retries and out-of-order arrival force the handler to be replay-safe and order-independent.

The events table, the foundation of replayable handlers

The events table is the smallest structural change that fixes both failures at once. A single Postgres table with a uniqueness constraint on the Stripe event.id does three jobs at once. First, it is the dedup boundary, so any retried event is rejected by the database before the business logic runs. Second, it is the audit log, so every payment-affecting event lives next to a timestamp the on-call engineer can search. Third, it is the replay surface, so a failed handler can be re-run against the original payload without asking Stripe to redeliver. Once you have the table, the rest of the handler shape falls out of it.

Schema with a UNIQUE constraint on event.id

The shape is small. Start with a Postgres migration that creates the table and the unique constraint in one step.

migrations/2026_05_27_create_stripe_events.sql

create table stripe_events (
  id uuid primary key default gen_random_uuid(),
  stripe_event_id text not null unique,
  event_type text not null,
  payload jsonb not null,
  received_at timestamptz not null default now(),
  processed_at timestamptz,
  process_error text
);

create index stripe_events_received_at_idx on stripe_events (received_at desc);
create index stripe_events_processed_at_idx on stripe_events (processed_at)
  where processed_at is null;

Every column earns its place. stripe_event_id is the dedup key, and the UNIQUE constraint is what enforces the rule at the database layer rather than relying on application code to remember. event_type makes the audit log searchable by category without parsing JSONB on every query. payload is the full event as Stripe sent it, which is the only way to replay a handler later without asking Stripe to redeliver. received_at and processed_at together let you distinguish "we got the event but never finished it" from "we got the event and handled it cleanly", and process_error keeps the failure reason on the same row instead of in a separate log. The partial index on processed_at IS NULL is the seam your replay job uses to find work that needs retry, and it stays tiny because most rows complete within seconds.

Insert-first, process-second

The handler inserts the row, then runs the business logic, then marks the row as processed. Order matters. Running the business logic first and the insert second means a retry reruns the side effect after a partial failure. The opposite order leaves the row sitting there with processed_at null on a partial failure, and a separate worker can pick it up later.

server/api/stripe/webhook.ts

import express from 'express'
import Stripe from 'stripe'
import { db } from '@/db'
import { handleEvent } from '@/server/api/stripe/handlers'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!

export const webhookRouter = express.Router()

webhookRouter.post(
  '/stripe/webhook',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['stripe-signature'] as string

    let event: Stripe.Event
    try {
      event = stripe.webhooks.constructEvent(req.body, signature, endpointSecret)
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${(err as Error).message}`)
    }

    try {
      await db.transaction(async tx => {
        const inserted = await tx.query(
          `insert into stripe_events (stripe_event_id, event_type, payload)
           values ($1, $2, $3)
           on conflict (stripe_event_id) do nothing
           returning id`,
          [event.id, event.type, event],
        )

        if (inserted.rowCount === 0) {
          return
        }

        const eventRowId = inserted.rows[0].id
        await handleEvent(tx, event, eventRowId)

        await tx.query(
          `update stripe_events set processed_at = now() where id = $1`,
          [eventRowId],
        )
      })
    } catch (err) {
      await db.query(
        `update stripe_events
         set process_error = $2
         where stripe_event_id = $1`,
        [event.id, (err as Error).message],
      )
      return res.status(500).json({ error: 'handler_failed' })
    }

    res.json({ received: true })
  },
)

Three behaviors come out of that shape. The ON CONFLICT DO NOTHING clause is the duplicate detector: the second time Stripe sends the same event.id, the insert is a no-op, rowCount is zero, the handler skips the side effect and returns 200. Wrapping the insert, the side effect, and the processed_at write in one transaction makes them atomic, so a crash midway leaves the row visible but with processed_at still null, ready for the replay worker. On the 500 path, writing the error onto the row before returning means the audit log carries the failure reason without needing a separate logging pipeline. The unique constraint on stripe_event_id is the dedup boundary, and the row is the proof you processed the event.

Signature verification (and the one mistake that breaks it)

Signature verification is the boundary that keeps a public webhook endpoint from being a free attack surface. Stripe signs every webhook delivery with the endpoint secret, and the Node SDK exposes stripe.webhooks.constructEvent to verify the signature and parse the payload in one step. Full mechanics live in the webhook signatures docs. The one mistake almost every team makes is feeding the verifier the parsed JSON object instead of the raw request bytes.

The wrong version looks like the right version. A route running after express.json() parses the body into an object, constructEvent receives that object after a JSON.stringify, and the signature still verifies in development. It breaks the moment a property reorders between Stripe and your stringifier, or the moment a number loses a trailing decimal in the JSON round-trip, or the moment a future Stripe payload includes a Unicode character that your JSON encoder normalizes differently. The verifier needs the exact bytes.

server/api/stripe/webhook.ts (the signature step, isolated)

import express from 'express'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!

export const webhookRouter = express.Router()

webhookRouter.post(
  '/stripe/webhook',
  express.raw({ type: 'application/json' }),
  (req, res, next) => {
    const signature = req.headers['stripe-signature']
    if (typeof signature !== 'string') {
      return res.status(400).send('Missing stripe-signature header')
    }

    try {
      const event = stripe.webhooks.constructEvent(req.body, signature, endpointSecret)
      res.locals.event = event
      next()
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${(err as Error).message}`)
    }
  },
)

The change is small and load-bearing. express.raw({ type: 'application/json' }) is applied to this route only, which keeps req.body as a Buffer for the webhook and leaves every other route consuming JSON normally. Header access reads stripe-signature as a string (lowercase, because Node normalizes header names). The verifier receives the raw buffer, the case-sensitive header, and the endpoint secret, and returns the parsed event. If you are running behind a reverse proxy that re-encodes request bodies (some API gateways do), disable that behavior on the webhook path or the signature will never verify, no matter how correct the rest of the code is. The exact-bytes rule is the whole point.

Writing the handler as an idempotent state machine

The handler stops being a series of conditional branches and starts being a state machine the moment you accept the events-table row as your source of work and the current Stripe object as your source of truth. An event is a notification that something changed. It is not a description of what it changed to. The Stripe events documentation makes this explicit, and it is the principle that makes out-of-order events stop mattering. Refetch the canonical object inside the handler, map its current state to your domain, and write the result. Older events become harmless because the refetch returns the same answer regardless of which event you process first.

Subscription state derived from the current Stripe object, not the event

Subscription handlers are where the principle pays off most. A customer.subscription.updated event can carry a status of active even when a later customer.subscription.deleted event has already landed and marked the subscription canceled. Trusting the payload writes active back over canceled, and the user keeps access they no longer pay for. The fix is to call stripe.subscriptions.retrieve inside the handler and use the returned object as the source of truth.

server/api/stripe/handlers/subscription.ts

import Stripe from 'stripe'
import type { PoolClient } from 'pg'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

const ACTIVE_STATUSES: ReadonlyArray<Stripe.Subscription.Status> = [
  'active',
  'trialing',
  'past_due',
]

export async function handleSubscriptionEvent(
  tx: PoolClient,
  event: Stripe.Event,
): Promise<void> {
  const subscriptionId = (event.data.object as Stripe.Subscription).id
  const current = await stripe.subscriptions.retrieve(subscriptionId)

  const planActive = ACTIVE_STATUSES.includes(current.status)
  const customerId = typeof current.customer === 'string' ? current.customer : current.customer.id

  await tx.query(
    `update users
     set plan_active = $2,
         plan_status = $3,
         plan_updated_at = now()
     where stripe_customer_id = $1`,
    [customerId, planActive, current.status],
  )
}

The behavior is order-independent by construction. Whether this handler runs in response to customer.subscription.created, customer.subscription.updated, or customer.subscription.deleted, it makes the same Stripe API call and writes the same canonical answer. A late retry of a customer.subscription.updated from yesterday now writes yesterday's answer, which is what Stripe currently reports as the truth. The same approach is recommended in the billing subscriptions webhook guide. One refetch per event is the cost. A subscription state that never drifts from Stripe is the payoff.

Charge events, granting credit safely

Charge events are where double-grants do real damage. A payment_intent.succeeded event triggers a credit grant, and a retry of the same event must not grant a second time. The events-table row id is the natural idempotency key. A credit-grants table that carries a foreign key to that row, with a uniqueness constraint on that foreign key, prevents two grants for the same event.

server/api/stripe/handlers/charge.ts

import Stripe from 'stripe'
import type { PoolClient } from 'pg'

export async function handlePaymentSucceeded(
  tx: PoolClient,
  event: Stripe.Event,
  eventRowId: string,
): Promise<void> {
  const paymentIntent = event.data.object as Stripe.PaymentIntent
  const customerId =
    typeof paymentIntent.customer === 'string'
      ? paymentIntent.customer
      : paymentIntent.customer?.id

  if (!customerId) return

  await tx.query(
    `insert into credit_grants (stripe_event_row_id, customer_id, amount_cents)
     values ($1, $2, $3)
     on conflict (stripe_event_row_id) do nothing`,
    [eventRowId, customerId, paymentIntent.amount_received],
  )
}

Two safeguards stack. The events table's UNIQUE on stripe_event_id blocks the retry from ever entering this handler, and the credit-grants table's UNIQUE on stripe_event_row_id is the second guard, in case a future code path calls this function more than once without going through the webhook entry. Because the grant is keyed on the events-table row, a single Stripe event grants at most one credit, even if a replay job re-enters the function or a developer manually triggers the handler from a debug console. An event triggers a refetch, the refetch is the source of truth, and the side effect is keyed on the event row id.

Replays in development, replays in production

Replays are the developer-experience superpower of this design. The handler that survives a Stripe retry in production also survives a developer hitting the same endpoint with the same payload from their laptop. No special "replay mode" is needed, because the dedup logic and the refetch logic are the same code path either way. Two tools cover the two replay scenarios you will use.

In development, the Stripe CLI forwards live test-mode events to your local handler and lets you replay specific events on demand. The stripe listen and stripe trigger commands are documented under the Stripe CLI listen guide.

# Forward test-mode webhooks to your local handler
stripe listen --forward-to localhost:3000/api/stripe/webhook

# Trigger a synthetic event of any supported type
stripe trigger customer.subscription.created

# Resend a specific event you already received
stripe events resend evt_1OXyAbCdEf12345

In production, the Stripe dashboard exposes a "Resend" button on every event in the webhook log. Clicking it sends the same payload, with the same event.id, to your endpoint. The dedup check rejects the duplicate, the response is 200, and nothing in your database changes. Resending from the dashboard is the safe way to confirm a handler is replay-safe before a customer ever experiences a retry. The same code path serves both development replays and production redeliveries.

Tests that catch what manual QA cannot

Manual QA does not catch idempotency bugs, because the failure mode requires two deliveries of the same event and a developer with one terminal cannot easily produce both at the same speed Stripe does. The three negative tests below catch every failure mode this article has named. Ship the handler with all three and the production gate is a green CI run, not a release-week prayer. The pattern is the same one I walk through in the AI code review checklist for React and Vue and in testing AI-generated code in React, and it applies to any boundary where retry semantics matter.

server/api/stripe/tests/webhook-replay.test.ts

import request from 'supertest'
import { app } from '@/server/app'
import { db } from '@/db'
import { signEvent } from './helpers'
import { handleEvent } from '@/server/api/stripe/handlers'

vi.mock('@/server/api/stripe/handlers', () => ({
  handleEvent: vi.fn().mockResolvedValue(undefined),
}))

test('replayed event is deduplicated, side effect runs once', async () => {
  const event = {
    id: 'evt_replay_1',
    type: 'customer.subscription.created',
    data: { object: { id: 'sub_1', customer: 'cus_1' } },
  }
  const { body, signature } = signEvent(event)

  const first = await request(app)
    .post('/api/stripe/webhook')
    .set('stripe-signature', signature)
    .set('content-type', 'application/json')
    .send(body)

  const second = await request(app)
    .post('/api/stripe/webhook')
    .set('stripe-signature', signature)
    .set('content-type', 'application/json')
    .send(body)

  expect(first.status).toBe(200)
  expect(second.status).toBe(200)

  const { rowCount } = await db.query(
    `select 1 from stripe_events where stripe_event_id = $1`,
    ['evt_replay_1'],
  )
  expect(rowCount).toBe(1)
  expect(vi.mocked(handleEvent)).toHaveBeenCalledTimes(1)
})

The replay test is the one that catches the double-grant scenario from the opening of this article. Two posts, same body, same signature. One row, one side-effect call, two 200 responses. Without the unique constraint, the second insert silently succeeds and the side effect runs twice. With it, the second response is still a 200 because the duplicate is the expected case, not an error. That distinction matters: returning 4xx on a duplicate would tell Stripe the event was rejected and trigger more retries, which is the opposite of what you want.

server/api/stripe/tests/webhook-signature.test.ts

import request from 'supertest'
import { app } from '@/server/app'
import { db } from '@/db'
import { signEvent } from './helpers'

test('tampered payload fails signature verification, no row written', async () => {
  const event = {
    id: 'evt_sig_1',
    type: 'customer.subscription.created',
    data: { object: { id: 'sub_2', customer: 'cus_2' } },
  }
  const { body, signature } = signEvent(event)
  const tampered = body.replace('sub_2', 'sub_999')

  const response = await request(app)
    .post('/api/stripe/webhook')
    .set('stripe-signature', signature)
    .set('content-type', 'application/json')
    .send(tampered)

  expect(response.status).toBe(400)

  const { rowCount } = await db.query(
    `select 1 from stripe_events where stripe_event_id = $1`,
    ['evt_sig_1'],
  )
  expect(rowCount).toBe(0)
})

The signature test guards the boundary that keeps the endpoint from being a free attack surface. A tampered payload produces a 400 and writes nothing to the database, which means the events table stays clean of forged events and the alerting stack does not get noise from probes. Run this test once per CI build and a regression where someone accidentally re-enables express.json() on the webhook route fails immediately, instead of surfacing weeks later as "webhooks suddenly stopped working".

server/api/stripe/tests/webhook-order.test.ts

import request from 'supertest'
import { app } from '@/server/app'
import { db } from '@/db'
import { signEvent } from './helpers'
import Stripe from 'stripe'

vi.mock('stripe', () => {
  const Stripe = vi.fn()
  Stripe.prototype.subscriptions = {
    retrieve: vi.fn().mockResolvedValue({
      id: 'sub_3',
      customer: 'cus_3',
      status: 'canceled',
    }),
  }
  Stripe.prototype.webhooks = {
    constructEvent: (raw: Buffer) => JSON.parse(raw.toString()),
  }
  return { default: Stripe }
})

test('older update event arrives after a delete, plan stays canceled', async () => {
  await db.query(`insert into users (id, stripe_customer_id, plan_active, plan_status)
                  values ('u1', 'cus_3', false, 'canceled')`)

  const lateUpdate = {
    id: 'evt_order_1',
    type: 'customer.subscription.updated',
    data: { object: { id: 'sub_3', status: 'active' } },
  }
  const { body, signature } = signEvent(lateUpdate)

  const response = await request(app)
    .post('/api/stripe/webhook')
    .set('stripe-signature', signature)
    .set('content-type', 'application/json')
    .send(body)

  expect(response.status).toBe(200)

  const user = await db.query(
    `select plan_active, plan_status from users where id = $1`,
    ['u1'],
  )
  expect(user.rows[0].plan_active).toBe(false)
  expect(user.rows[0].plan_status).toBe('canceled')
})

The out-of-order test is the one that proves the refetch principle is wired correctly. Even though the event payload says active, the mocked Stripe API returns canceled, and the handler writes canceled because it trusts the API over the payload. Remove the refetch and reintroduce direct payload reads, and this test fails immediately. That is the safety net: the test does not care whether the refetch is fast or slow or expensive; it cares that the canonical-source rule holds. Without these three tests, idempotency bugs ship by accident.

A reviewable Stripe webhook idempotency checklist

The checklist below fits in a PR template and mirrors the seven steps from the TL;DR. If a webhook PR is missing any line, it is not ready to merge.

  • Raw body parser on the webhook route only (express.raw({ type: 'application/json' })).
  • Signature header read as a string from stripe-signature, never trusted from query or body.
  • stripe.webhooks.constructEvent called with the raw buffer, not a re-stringified object.
  • stripe_events table with UNIQUE on stripe_event_id migrated and indexed.
  • Insert into stripe_events runs before any business logic, inside a transaction with the side effect.
  • Side effect keyed on the events-table row id, not on the Stripe event.id directly.
  • Subscription and customer state derived from stripe.subscriptions.retrieve (or equivalent), not from event.data.object.
  • 200 response returned only after the row is committed; 5xx on handler failure so Stripe retries.
  • Replay test asserts one row and one side effect for two deliveries with the same event.id.
  • Signature-mismatch test asserts 400 and zero rows written.
  • Out-of-order test asserts canonical state wins over a stale payload.
  • processed_at watchdog or replay worker runs against rows where processed_at IS NULL for more than N minutes.

Closing thought

The pattern is small but it is structural. Three load-bearing decisions carry it: the unique constraint, the insert-first ordering, and the refetch rule. Get those right and webhook reliability stops being a feature to build and becomes a property of the schema. The negative tests are the gate that keeps it that way. This pattern protects you from the failures Stripe causes by design (retries, redeliveries, out-of-order arrival); it does not protect you from a logic bug in the side effect itself. For the surrounding observability and error-handling work, hardening an AI-generated React app for production covers the wider production checklist, and vibe coding vs production coding in React is the broader argument about which corners do and do not bend under real traffic.

Found this useful?

Share
Thomas Findlay photo

About the author

Written by Thomas Findlay.

Thomas Findlay is a CTO, senior full-stack engineer, and the author of React - The Road To Enterprise and Vue - The Road To Enterprise. With 13+ years of experience, he has built and led engineering teams, architected multi-tenant SaaS platforms, and driven AI-augmented development workflows that cut feature delivery time by 50%+.

Thomas has spoken at international conferences including React Summit, React Advanced London, and Vue Amsterdam, and has written 50+ in-depth technical articles for the Telerik/Progress blog. He holds an MSc in Advanced Computer Science (Distinction) from the University of Exeter and a First-Class BSc in Web Design & Development from Northumbria University.

With a 5-star rating across 2,500+ mentoring sessions and 1,250+ reviews on Codementor, Thomas has helped developers and teams worldwide with architecture consulting, code reviews, and hands-on development support. Find him on LinkedIn, GitHub, or Twitter/X, or get in touch directly. Read the full bio →

Stop fighting your codebase. Start shipping.

Long-form articles like this one, plus a 400+ page book that takes you end-to-end through production React in TypeScript.