CLAUDE LABJP
PRICING — September 1 was the scheduled date for the Sonnet 5 price increase, and it did not happen. The introductory $2/$10 per MTok now stands as the regular pricePARTNER — Salesforce and Anthropic announced Claudeforce, an expanded partnership. The Salesforce in Claude plugin ships with 37 prebuilt sales skills, from meeting prep to pipeline managementTRUST — Claudeforce serves Claude through Amazon Bedrock inside the Salesforce Trust Boundary, so data and inference never leave the security perimeter — an answer aimed squarely at regulated industriesBETA — Salesforce in Claude is with select pilot customers for now, with an open beta expected during SeptemberLIMITS — The 50% weekly-limit boost runs through September 13. From September 14 the permanent level is 25% above the pre-promotion baseline, roughly a 17% cut from todayRELEASE — Claude Code has shipped nothing since v2.1.251 on August 28. Against a pace of one release every 0.8 days, a four-day gap is among the longest yetPRICING — September 1 was the scheduled date for the Sonnet 5 price increase, and it did not happen. The introductory $2/$10 per MTok now stands as the regular pricePARTNER — Salesforce and Anthropic announced Claudeforce, an expanded partnership. The Salesforce in Claude plugin ships with 37 prebuilt sales skills, from meeting prep to pipeline managementTRUST — Claudeforce serves Claude through Amazon Bedrock inside the Salesforce Trust Boundary, so data and inference never leave the security perimeter — an answer aimed squarely at regulated industriesBETA — Salesforce in Claude is with select pilot customers for now, with an open beta expected during SeptemberLIMITS — The 50% weekly-limit boost runs through September 13. From September 14 the permanent level is 25% above the pre-promotion baseline, roughly a 17% cut from todayRELEASE — Claude Code has shipped nothing since v2.1.251 on August 28. Against a pace of one release every 0.8 days, a four-day gap is among the longest yet
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-code131stripe6webhook4tutorial3

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 $15 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
90 Days Building a Solo SaaS with Claude Code — And the Three Things I Got Wrong
A working log from 90 days of building a solo SaaS with Claude Code — design conversations, model routing, and Cloudflare Workers constraints. Revisited months later against primary docs, with three technical claims corrected in place.
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-08-28
A small script that catches invisible characters in plugin names before you install
Control characters and zero-width characters hiding in plugin or skill names are invisible in a terminal listing. I built six deliberately confusing names, compared what ls actually shows, and wrote a short check you can run before installing anything — plus an honest note on what that check misses.
📚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 →