CLAUDE LABJP
FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtaskLIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its ownMCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSPLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callbackSONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as OctoberFORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtaskLIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its ownMCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSPLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callbackSONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Articles/Claude Code
Claude Code/2026-05-01Intermediate

Testing Stripe Webhooks Safely with Claude Code: Three Practical Local Workflows

Reproduce Stripe webhook failures locally with confidence. Three practical patterns combining Claude Code with Stripe CLI, signed payload replay, and idempotency checks — with full working code.

claude-code129stripe6webhook4tutorial3

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/webhook

Your 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/webhook

When 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_SECRET in .env.local set 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Claude Code2026-05-06
Building a SaaS Solo with Claude Code in 90 Days — A Complete Record of Design, Implementation, and Monetization (2026)
A full behind-the-scenes account of building and monetizing a SaaS product using only Claude Code in 90 days — including tool choices, cost management, and every painful lesson learned.
Claude Code2026-04-28
Weekend MVP with Claude Code — From Zero to First Revenue in 48 Hours
Build a minimum viable product in a single weekend using Claude Code, integrate Stripe payments, and get your first paying customer — with actual prompts and commands shown step by step.
Claude Code2026-07-17
The Morning My Table Ended in "… 2,847 more rows" — Separating Render Caps from Token Cost in Tool Output
Claude Code 2.1.209 caps markdown tables at 200 rows plus a remainder count. Only the rendering is capped — the model still receives every row. Here is how to measure the gap and redesign tool output around aggregates.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →