Stripe Webhook Retry Logic: Building Idempotent Billing That Never Double-Charges

Build idempotent Stripe webhook handlers that survive retries. Production TypeScript with event.id PK, transactional state, and Stripe CLI test recipes.

Huifer
Huifer
May 19, 20267 min read
Also available in:中文 · Deutsch

Written by Huifer, solo developer and maintainer of TanStack Ship. I shipped a Stripe-integrated billing module on TanStack Ship in September 2025 and within ninety days hit a duplicate-delivery incident that credited eighteen customers twice. The root cause was a missing idempotency check on event.id. The fix was one SQL table and four lines of handler code. This post walks through the pattern I now ship by default — including the Drizzle-typed transactional state mutations that close the race condition behind fast code paths.

Verified sources: Stripe Webhooks Documentation · Stripe Webhooks Best Practices · Stripe Signature Verification · Stripe Retry Behavior · Stripe Idempotent Requests · Drizzle ORM · Cloudflare D1 · TanStack Ship GitHub · Last updated: 2026-05-15 · Changelog


TL;DR: Stripe webhooks are delivered at-least-once — every handler must be idempotent. The minimum viable fix is an event.id primary-key table plus a transactional state-mutation step. Below: the production-grade TypeScript handler I ship with TanStack Ship, the Drizzle schema for the idempotency table, the test recipe using Stripe CLI, and the four lessons from my December 2025 incident that credited 18 customers twice.


What "At-Least-Once" Delivery Actually Means in Practice

Stripe's webhooks documentation states that "events are delivered at-least-once." That sentence is the difference between a billing system that works and one that double-charges customers on a flaky network. Every retry is not an exceptional case — it is part of the contract.

According to Stripe's retry behavior docs, Stripe retries failed deliveries with exponential backoff for up to 72 hours. The retry sequence: immediate, 1 second, 5 seconds, 30 seconds, 2 minutes, 10 minutes, 30 minutes, 2 hours, 6 hours, 12 hours. A handler that returns 2xx succeeds; anything else triggers the next retry.

In practice this means three things for the handler:

  1. Any non-idempotent business logic will run twice on the same event.id. Credits apply twice. Emails send twice. Subscriptions downgrade twice.
  2. Stripe's API calls are independently idempotent when you pass an Idempotency-Key header per the idempotent requests docs, but the handler's local side effects are not unless you build idempotency yourself.
  3. The race condition is invisible at low load and visible only under retry storms — the exact opposite of when you want to debug it.

I learned this the hard way in December 2025. The postmortem is here; the production-grade pattern is below.

The Minimum Viable Idempotency Implementation

There are three pieces to a correct Stripe webhook handler. Each one solves a distinct failure mode. Skip any one and you ship a billing bug.

Piece 1: Signature verification (mandatory, not optional)

Always verify the signature. Stripe's signature verification guide describes the HMAC-SHA256 scheme in full. In production:

typescript
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-09-30.clover",
  typescript: true,
});

export async function verifyWebhookRequest(
  request: Request,
  signingSecret: string,
): Promise<Stripe.Event> {
  const signature = request.headers.get("stripe-signature");
  if (!signature) throw new Error("Missing stripe-signature header");

  const payload = await request.text();
  // stripe.webhooks.constructEvent throws on bad signature
  return stripe.webhooks.constructEventAsync(
    payload,
    signature,
    signingSecret,
    undefined,
    Stripe.createSubtleCryptoProvider(),
  );
}

The handler must read the raw body as text (not JSON) before signature verification. If your framework auto-parses JSON before signature verification runs, the signature will fail. Cloudflare Workers, TanStack Start, and Next.js all need explicit raw-body handling — see Cloudflare's request body docs for the Worker-specific gotcha.

Piece 2: Idempotency table keyed on event.id

The first thing the handler does — before any business logic — is INSERT INTO webhook_events. If the insert fails on primary key constraint, the event was already processed; return 200 immediately.

typescript
// schema/webhook-events.ts (Drizzle + D1 SQLite)
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";

export const webhookEvents = sqliteTable("webhook_events", {
  eventId: text("event_id").primaryKey(),
  eventType: text("event_type").notNull(),
  receivedAt: integer("received_at").notNull(),
  processedAt: integer("processed_at"),
  status: text("status", { enum: ["pending", "completed", "failed"] }).notNull(),
});

export type WebhookEvent = typeof webhookEvents.$inferSelect;
sql
-- Migration: 0001_webhook_events.sql
CREATE TABLE webhook_events (
  event_id TEXT PRIMARY KEY,
  event_type TEXT NOT NULL,
  received_at INTEGER NOT NULL,
  processed_at INTEGER,
  status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'failed'))
);

CREATE INDEX idx_webhook_events_status_time
  ON webhook_events(status, received_at);

The status column gives you monitoring for free — count of pending events older than 5 minutes is your retry-storm detector.

Piece 3: Transactional state mutations

Any state change in the business logic must be wrapped in a transaction with the idempotency row update. The two writes either both succeed or both roll back. This is the part that closes the race condition that the December 2025 incident hit on the downgrade tag.

typescript
// billing/handle-invoice-paid.ts
import { db } from "../db";
import { webhookEvents, credits } from "../schema";
import { eq } from "drizzle-orm";

export async function handleInvoicePaid(event: Stripe.Event) {
  const invoice = event.data.object as Stripe.Invoice;
  const customerId = invoice.customer as string;

  await db.transaction(async (tx) => {
    // 1. Mark the event completed (idempotency claim)
    await tx
      .update(webhookEvents)
      .set({ status: "completed", processedAt: Date.now() })
      .where(eq(webhookEvents.eventId, event.id));

    // 2. Apply the business logic atomically
    await tx
      .insert(credits)
      .values({
        customerId,
        amountCents: invoice.amount_paid,
        stripeEventId: event.id,  // FK to the idempotency table
        createdAt: Date.now(),
      })
      .onConflictDoNothing({ target: credits.stripeEventId });
  });
}

The stripeEventId foreign key on credits is a second-level idempotency guard — even if a future bug skips Piece 2, the INSERT ... ON CONFLICT DO NOTHING prevents the duplicate credit. Per the Stripe webhook best-practices guide, the event.id is the canonical identifier for this pattern.

Putting It Together: The Production Handler

The full handler — verify signature, insert idempotency row, dispatch by event type, transactional business logic — runs about 60 lines and ships in src/server/billing/webhook-handler.ts:

typescript
// src/server/billing/webhook-handler.ts
import Stripe from "stripe";
import { db } from "../db";
import { webhookEvents } from "../schema/webhook-events";
import { handleInvoicePaid } from "./handle-invoice-paid";
import { handleSubscriptionUpdated } from "./handle-subscription-updated";
import { handleCustomerDeleted } from "./handle-customer-deleted";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-09-30.clover",
});

export async function POST(request: Request) {
  const event = await verifyWebhookRequest(
    request,
    process.env.STRIPE_WEBHOOK_SECRET!,
  );

  // Step 1: idempotency claim. PRIMARY KEY enforces uniqueness.
  try {
    await db.insert(webhookEvents).values({
      eventId: event.id,
      eventType: event.type,
      receivedAt: Date.now(),
      status: "pending",
    });
  } catch (err: any) {
    if (err?.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
      // Already received — short-circuit with 200
      return new Response("OK (duplicate)", { status: 200 });
    }
    throw err;
  }

  // Step 2: dispatch by event type, each handler is transactional
  try {
    switch (event.type) {
      case "invoice.paid":
        await handleInvoicePaid(event);
        break;
      case "customer.subscription.updated":
      case "customer.subscription.deleted":
        await handleSubscriptionUpdated(event);
        break;
      case "customer.deleted":
        await handleCustomerDeleted(event);
        break;
      default:
        // Unhandled event type — mark completed so it doesn't retry
        await db
          .update(webhookEvents)
          .set({ status: "completed", processedAt: Date.now() })
          .where(eq(webhookEvents.eventId, event.id));
    }
  } catch (err) {
    // Mark failed so retry can re-attempt
    await db
      .update(webhookEvents)
      .set({ status: "failed", processedAt: Date.now() })
      .where(eq(webhookEvents.eventId, event.id));
    return new Response("Handler error", { status: 500 });
  }

  return new Response("OK", { status: 200 });
}

The three failure modes this closes:

  • Replay of the same event.id → caught by PRIMARY KEY in Step 1, returns 200 in <5ms.
  • Concurrent delivery of the same event.id → caught by PRIMARY KEY in Step 1.
  • Race condition inside the business logic → caught by the db.transaction() block in Step 2.

Reproducing and Testing the Pattern

The Stripe CLI lets you replay any event against your local handler. The test plan is in three commands:

bash
# 1. Forward live webhooks to localhost
stripe listen --forward-to localhost:8787/webhooks/stripe

# 2. Trigger a one-time invoice.paid event
stripe trigger invoice.paid

# 3. Replay the same event_id five times — only the first should run business logic
stripe events resend evt_1ABC...  # repeat 5x

With the pre-fix handler: 5 processing runs, 5 sets of business logic executed. With the post-fix handler: 1 processing run, 4 short-circuits returning 200 OK in under 50ms each. I run this exact sequence against every PR that touches the billing module.

The Stripe CLI docs walk through stripe listen and stripe trigger in detail. The events resend command is documented under the Stripe events API.

The Four Lessons From the December 2025 Incident

1. Always assume webhooks retry

Stripe's "at-least-once" guarantee is not a fallback for edge cases — it is the API contract. Treat every handler as a concurrent handler for the same event.id. The idempotency table is not optional; it is the minimum viable implementation.

2. Idempotency is a database constraint, not a code pattern

Application-level checks ("have I seen this event_id before?") are not idempotent under concurrent delivery. The check-then-write pattern races. The PRIMARY KEY constraint enforces idempotency at the database layer where there is no race. Use it.

3. Race conditions hide behind fast code paths

The downgrade tag bug in the December incident only manifested under slow-write conditions from an unrelated D1 hot spot. Code that "always works" in local testing may not work under load. The fix for this failure mode is atomic operations everywhere — lookup → check → write is always wrong, regardless of how fast the database usually is.

4. Customer trust is the cost you cannot refund

The $1,247 in duplicate credits from my December incident was reversible. The support ticket tone for the following two months was not. The minimum viable idempotency implementation costs one SQL table and four lines of handler code. The trust cost of not having it is measured in months of support friction and silent churn from customers who never opened a ticket but quietly canceled.

How I monitor this in production

Three Cloudflare Workers logs + one D1 query tell me whether the pattern is holding:

  • webhook_events.status='pending' count older than 5 minutes — alert at > 5.
  • Stripe Dashboard webhook delivery success rate — alert if it drops below 99% over 24 hours.
  • D1 query: SELECT event_type, COUNT(*) FROM webhook_events WHERE status='failed' AND received_at > strftime('%s','now','-7 day') GROUP BY event_type — review weekly.
  • Replay-attempt count from stripe events resend log entries — any non-zero count is a manual investigation.

When This Pattern Is Not Enough

Idempotency on event.id prevents duplicate business-logic runs for the same event. It does not protect against:

  • Long-running retries. Stripe's 72-hour retry window means a pending event can sit for hours. Add a periodic reaper that marks 24-hour-old pending rows as failed for manual review. The TanStack Ship billing module ships this reaper as a Cloudflare Workers Cron Trigger running every 15 minutes.
  • Multi-currency reconciliation. The handler credits the customer in the invoice currency but your internal ledger might assume a single currency. Handle FX at the source — never inside the webhook handler. Use a separate fx_rates table updated from a daily cron and reference it inside the handler transaction.
  • Refunds and disputes. These are separate event types (charge.refunded, charge.dispute.created) with their own idempotency needs. Each one gets its own PRIMARY KEY table column. Without that, a refund retry can double-credit the customer's original payment method.
  • Webhook endpoint downtime longer than 72 hours. If your endpoint is down past Stripe's max retry window, events are marked as failed in the dashboard and never re-delivered. Build a daily reconciliation job that pulls missed events from the Stripe events API keyed on created timestamp.

For these edge cases, the Stripe Billing customer portal is the right next layer — see the Billing customer portal docs for the integration guide.

Frequently Asked Questions

What is idempotency in Stripe webhooks?

Idempotency means processing the same webhook event exactly once even if Stripe delivers it multiple times. Per the Stripe webhook docs, every handler must implement idempotency using event.id as the dedup key. Without it, a single transient 502 from your handler triggers a retry that runs the business logic twice — duplicating credits, emails, and subscription state changes.

How long should I retain Stripe webhook events for idempotency?

24 hours is the minimum to cover Stripe's retry window. The retry timeline peaks at 12 hours with up to 72 hours of total backoff; 24 hours covers the vast majority of cases. For higher-volume merchants, retain 7 days to handle clock skew and manual replays from the Stripe Dashboard.

Should I use Stripe's idempotency keys for outbound API calls too?

Yes. According to the idempotent requests docs, passing Idempotency-Key on POST requests makes Stripe's side of the API safe to retry. Use event.id as the idempotency key when the call originates from a webhook — that way a retry from your handler maps to the same Stripe API call.

What HTTP status should my webhook handler return?

Return 200 only when the event has been fully processed and the database row is completed. Return 500 (or any 4xx/5xx) to trigger Stripe's retry. Returning 200 for an unhandled event type is a common bug — it tells Stripe "success" but leaves the event unprocessed, so the retry never fires. A 4xx should be reserved for permanent failures (bad signature, malformed payload) because Stripe will not retry those after enough attempts; if you want a retry, return 5xx.

Can I disable webhook retries while I debug?

Yes. The Stripe Dashboard webhook endpoint editor lets you disable the endpoint entirely. While disabled, Stripe queues events for up to 30 days, after which they are dropped. Use this for short debugging windows only — disabling for hours at a time means events pile up. For a multi-day debugging effort, point the endpoint at a staging server instead.

The Bottom Line

Stripe webhooks are at-least-once. The minimum viable fix is event.id PRIMARY KEY + transactional state mutation + signature verification. The handler is 60 lines, the schema is one table, and the test is three Stripe CLI commands.

If you are integrating Stripe without idempotency, this post is your warning. The full pattern ships with TanStack Ship's billing module — see the pricing page for the module breakdown or the features overview for what is included. The Stripe-vs-Paddle comparison is in the billing infrastructure guide, and the waitlist-to-revenue attribution post walks through how a Stripe webhook integrates into a 90-day launch plan.


Next step: See TanStack Ship's billing module → · Read the December 2025 incident postmortem → · Clone the free starter →