The first wall most developers hit when shipping Stripe webhooks isn't writing the handler — it's reproducing production failures locally with the same conditions. The official docs walk you through stripe listen and call it a day, but the signature-verification gotchas, idempotency tests, and replaying real payloads rarely appear in a single guide.
I run Stripe Checkout across four sites, and every refactor used to leave me wondering: "Did that path silently drop an event?" Below are the three patterns I now reach for whenever I touch a webhook handler, paired with Claude Code prompts that take the rough edges off each one.
Pattern 1: Forward Events with stripe listen
This is the standard pattern from the docs, but let me walk through the failure mode I keep watching colleagues hit.
# Looks fine, returns 400 in practice
stripe listen --forward-to localhost:3000/api/webhookYour handler reads process.env.STRIPE_WEBHOOK_SECRET, which is set to your production secret. stripe listen issues a temporary signing secret (still starts with whsec_, but it's a different one), and the verification check fails every single request with a confusing 400.
The fix is to read the temporary secret first, write it to .env.local, then run the dev server with that environment.
# Print the temporary secret first
stripe listen --print-secret > .stripe-secret.txt
# Copy the whsec_... value into .env.local under STRIPE_WEBHOOK_SECRET
# Then in another terminal:
stripe listen --forward-to localhost:3000/api/webhookWhen I want Claude Code to handle this for me, the prompt I use is short:
Replace STRIPE_WEBHOOK_SECRET in .env.local with the output of
`stripe listen --print-secret`, start the dev server, and restore
the original value on exit.
The "restore on exit" line is the important part. Without it, you eventually commit a development-only secret to .env.local, then waste twenty minutes wondering why production stopped accepting webhooks.
Debugging signature mismatches
When stripe listen is forwarding correctly but the handler still returns 400, the cause is almost always one of three things: the dev server picked up the old environment value because you forgot to restart it after editing .env.local; the body is being parsed before the signature check (Next.js App Router needs the raw Request.text() body, not req.json()); or you have a proxy in front of the handler that strips the Stripe-Signature header. I once spent an evening on the third case before realizing my Cloudflare Workers wrangler config was lowercasing headers in dev mode.
Pattern 2: Replay Real Payloads From a File
stripe events resend <event_id> is great for one-off resends, but it's a poor fit when you want to fire the same event one hundred times, run on a flight with no Wi-Fi, or replay events inside CI.
For those situations, download the failing event JSON from the Stripe Dashboard and replay it against your local server with a self-signed signature.
// scripts/replay-webhook.ts
//
// Purpose: replay a saved Stripe webhook payload against the local dev
// server with a valid signature, without depending on `stripe listen`.
import fs from "node:fs";
import crypto from "node:crypto";
async function main() {
const payload = fs.readFileSync(
"./fixtures/checkout-completed.json",
"utf8",
);
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) throw new Error("STRIPE_WEBHOOK_SECRET is required");
const timestamp = Math.floor(Date.now() / 1000);
const signedPayload = `${timestamp}.${payload}`;
const signature = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
const sigHeader = `t=${timestamp},v1=${signature}`;
const res = await fetch("http://localhost:3000/api/webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Stripe-Signature": sigHeader,
},
body: payload,
});
console.log(res.status, await res.text());
}
main().catch((err) => {
console.error(err);
process.exit(1);
});Expected output:
200 {"received":true}
The reason I sign the payload manually is that the script no longer depends on the Stripe CLI being running. The same script works in CI, on an airplane, or while the Stripe API is having an outage. Ask Claude Code to "log the response body when the status is non-2xx" and you have a permanent reproduction harness for any flaky webhook bug.
In a real project I keep at least three fixtures around: checkout-completed.json, payment_intent.succeeded.json, and customer.subscription.updated.json. The script reads the filename from process.argv[2] so I can switch with pnpm tsx scripts/replay-webhook.ts checkout-completed.
Generating fixtures from production safely
The trickiest part of fixture-based testing is harvesting them in the first place. Production payloads carry real customer emails, names, and sometimes partial card metadata — checking those into git is a compliance violation waiting to happen. I now use a small Claude Code workflow to redact a downloaded payload before saving it: I drop the raw JSON into the working directory and ask "redact every email, name, and address in this payload to deterministic placeholders like customer-1@example.com, then save the result to fixtures/." Doing this manually is tedious enough that you skip it; doing it with Claude Code takes about ten seconds and the result is consistent across runs, which matters when you compare expected outputs in your idempotency tests.
Pattern 3: Test Idempotency and Retries Together
Stripe will retry the same event.id for up to three days. Idempotency isn't optional — it's the contract you implicitly accept by integrating the webhook. Verify it locally before it bites you in production.
The check I run is:
- Fire the same payload five times in a row using the script from Pattern 2
- Confirm the database (or KV) does not gain duplicate rows
- Force the first call to fail (e.g. mock the email send to throw) and confirm the second call recovers cleanly
A minimal handler that satisfies this looks like:
// app/api/webhook/route.ts (excerpt)
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const payload = await req.text();
const sig = req.headers.get("stripe-signature")!;
// Cloudflare Workers requires the async pair + SubtleCrypto provider
const event = await stripe.webhooks.constructEventAsync(
payload,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
undefined,
Stripe.createSubtleCryptoProvider(),
);
// Why we check duplication first:
// side effects like sending email or fulfilling a charge must
// never run twice for the same event.id.
const dupKey = `webhook:${event.id}`;
const seen = await env.KV.get(dupKey);
if (seen) {
return Response.json({ received: true, duplicate: true });
}
await env.KV.put(dupKey, "processed", { expirationTtl: 60 * 60 * 24 * 7 });
// ...switch on event.type and dispatch
return Response.json({ received: true });
}The prompt I give Claude Code for this is: "Add idempotency to this route. Then write a test that fires the same event.id five times and asserts that calls 2-5 return { duplicate: true }." Claude Code typically delivers both the route change and the test in a single pass, which beats writing the verification harness by hand.
A test sketch worth keeping in your repo:
// __tests__/webhook-idempotency.test.ts
import { describe, it, expect } from "vitest";
describe("webhook idempotency", () => {
it("returns duplicate=true on retries with the same event.id", async () => {
const responses: Response[] = [];
for (let i = 0; i < 5; i++) {
responses.push(await replayFixture("checkout-completed"));
}
const bodies = await Promise.all(responses.map((r) => r.json()));
expect(bodies[0]).toEqual({ received: true });
for (let i = 1; i < 5; i++) {
expect(bodies[i]).toEqual({ received: true, duplicate: true });
}
});
});The assertion you actually care about is the second one: only the first call should have side effects. If a teammate later wraps kv.put in a try/catch that swallows errors, the test will fail loudly the next CI run.
Checks I Run After "It Works"
Before I push the change to production, I always run through the same short checklist. Each item below has burned me at least once.
- Is
STRIPE_WEBHOOK_SECRETin.env.localset to the temporary value, not your production secret? - Does the handler return 4xx/5xx on real failures? Returning 200 unconditionally silently drops real production events because Stripe stops retrying.
- Is the duplicate-check TTL longer than Stripe's three-day retry window? A 24-hour TTL plus a flaky weekend equals double-charged customers.
- Does the handler complete inside the Cloudflare Workers CPU time limit (10 ms on the free plan, 30 s on paid)?
The "unconditional 200" trap is the one I now guard against with a CI check: grep -r "return new Response" src/app/api/webhook — if every webhook response is unconditional, the build fails and I have to look at it.
What to Try Next
The moment you have a script that can replay any past webhook locally, adding new payment flows stops feeling scary. Start with Pattern 2 today: write the replay-webhook.ts file once, save one fixture, and replay it. The handler suddenly becomes editable rather than radioactive.