Debug Stripe Webhooks With Claude Code and MCP
Your Stripe webhook handler is returning 400 Webhook Error: No signatures found matching the expected signature for payload. It worked last sprint. Your unit tests still pass. A teammate swears it works on their branch. Classic.
This guide walks through a real debugging session for exactly that bug—the raw-body vs parsed-body mistake that causes the vast majority of Stripe signature failures—using Claude Code connected to Hooklistener over MCP. You'll see the exact prompts to type, which MCP tools Claude Code calls, and how to prove the fix with a replay of the original event. No dashboard tab-switching, no copy-pasting payloads into chat.
What we cover:Connecting Claude Code to Hooklistener via OAuth, capturing a live Stripe CLI event, inspecting the stripe-signature header and raw body, finding the Express middleware bug, fixing it, and verifying with a replay. For a full Stripe webhook implementation reference (event types, retries, production checklists), see our Stripe webhooks implementation guide.
Setup: Connect Claude Code to Hooklistener (2 Minutes)
Hooklistener runs an MCP server at https://app.hooklistener.com/api/mcp (Streamable HTTP transport). It exposes 46 tools, and Claude Code only needs one command to connect:
claude mcp add --transport http hooklistener \
https://app.hooklistener.com/api/mcpNo API key in the command. The server supports OAuth 2.0—discovery, dynamic client registration, and PKCE are all handled automatically. Start Claude Code, run /mcp, select hooklistener, and authenticate. Your browser opens, you sign in to your Hooklistener account, and you're done. Access tokens last an hour and refresh automatically for 30 days.
A free Hooklistener account is enough for everything in the main walkthrough—debug endpoints and request inspection are on every plan. (There's a legacy API-key fallback with --header "Authorization: Bearer hklst_...", but it's deprecated; use OAuth.) Setup for Cursor, Windsurf, and Codex CLI is covered in our MCP setup guide.
The Scenario: Signature Verification Fails, But Only Sometimes
Here's the setup. You have an Express app handling payment_intent.succeeded events. Somewhere in the last few weeks, a refactor added a global express.json()middleware. Since then, live Stripe events 400 with a signature verification error—but the failure looks intermittent from where you sit. Your tests pass (they call constructEventwith a string directly, bypassing the middleware). Your teammate's branch predates the refactor, so it works on their machine. Stripe keeps retrying the failed deliveries, so events show up in the dashboard hours late, which muddies the picture further.
Quick refresher on why raw bytes matter. Stripe signs every webhook with your endpoint's signing secret (whsec_...) and puts the result in the stripe-signature header, which looks like this:
stripe-signature: t=1749542400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt is a Unix timestamp and v1 is an HMAC-SHA256 signature computed over {timestamp}.{raw request body}—the exact bytes Stripe sent, not a semantically-equivalent re-encoding. (The header can carry multiple signatures, e.g. while you're rolling secrets.) If your framework parses the body into an object and you re-serialize it with JSON.stringify, the whitespace and formatting almost never byte-match what Stripe signed, so verification fails. stripe.webhooks.constructEventneeds the raw payload. That's the whole bug—but let's prove it instead of guessing.
The Debugging Session
Step 1: Create a Capture Endpoint
First, get a URL that captures everything Stripe sends, byte for byte. In Claude Code:
> Create a Hooklistener debug endpoint called "Stripe debug"
and give me its webhook URL.Claude Code calls create_endpoint, then get_endpointto fetch the endpoint's public webhook URL, and hands it back to you in the conversation. That URL now records every request it receives—headers, query params, and the raw body exactly as sent.
Step 2: Fire a Real Test Event With the Stripe CLI
In one terminal, point the Stripe CLI at your capture endpoint so triggered events get delivered there:
stripe listen --forward-to https://your-endpoint-url.example
# Prints: Your webhook signing secret is whsec_xxxx (^C to quit)Note the whsec_secret it prints—while testing through stripe listen, that's the secret your handler must verify against, not the one from a dashboard-configured endpoint. Back in Claude Code, tell it to watch for the event:
> Wait for the next request on the Stripe debug endpoint.
I'm about to trigger a test event.Claude Code calls wait_for_request, which blocks until a request lands on the endpoint (default 30 seconds, up to 60). While it's waiting, fire the event from a second terminal:
stripe trigger payment_intent.succeededThe Stripe CLI creates a test-mode PaymentIntent and sends the resulting events through your listen session to Hooklistener. wait_for_request returns the captured request to Claude Code the moment it arrives.
Step 3: Inspect the Signature Header and Raw Body
> Show me the full request: the stripe-signature header
and the raw body. What exactly is Stripe signing?Claude Code calls get_request and gets the complete capture: every header including stripe-signature with its t= and v1=components, and the body exactly as Stripe formatted it. This is the step you can't do reliably with console.log(req.body)—by the time your logging runs, middleware may have already transformed the payload. The capture shows the ground truth.
Because Claude Code now has the signed bytes andyour source code in the same context, you can ask the productive question: “the signature covers these exact bytes— does my handler ever see them?”
Step 4: Replay the Event Against Your Local Handler
To reproduce the failure on demand, replay the captured event at your local server. Your app runs on localhost, so expose it with a Hooklistener CLI tunnel:
hooklistener tunnel --port 4242
# Forwarding: https://abc123.hook.events → http://localhost:4242> Forward that captured request to
https://abc123.hook.events/webhooks/stripeClaude Code calls forward_request, which replays the capture—original headers and body included—against your handler. Your server responds 400 Webhook Error: No signatures found matching the expected signature for payload. Now the bug reproduces in one prompt instead of one stripe trigger per attempt. (See the tunnel command guide for subdomains, Docker hosts, and other options.)
Step 5: Get the Verdict
> Diagnose that request. Then look at server.js and tell me
why signature verification fails.Claude Code calls diagnose_request, which returns a structured health verdict for the capture: findings, the forward attempts and their status codes, and suggestions. The failed forward to your handler shows up as an error finding with the 400 response attached.
Then it reads your code—this is where the MCP context pays off. It has the raw signed body from get_request, the 400 from the replay, and your Express setup in front of it. The diagnosis writes itself: app.use(express.json()) runs before the webhook route, so req.body is a parsed object by the time your handler runs, and the re-serialized string you pass to constructEventdoesn't match the bytes Stripe signed.
The Fix: Raw Body on the Webhook Route
Here's the broken version—the one the refactor produced:
const express = require("express");
const Stripe = require("stripe");
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
// BUG: parses the body for EVERY route, including the webhook.
app.use(express.json());
app.post("/webhooks/stripe", (req, res) => {
const sig = req.headers["stripe-signature"];
let event;
try {
// req.body is already a parsed object here. Re-serializing it
// does not reproduce the bytes Stripe signed.
event = stripe.webhooks.constructEvent(
JSON.stringify(req.body),
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
res.json({ received: true });
});And the fix: give the webhook route the raw bytes, and register it beforethe global JSON parser. Order matters—once a body parser has consumed the stream, downstream parsers skip it, so a route-level express.raw() can't undo a global express.json() that ran first.
const express = require("express");
const Stripe = require("stripe");
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
// Webhook route FIRST, with a raw-body parser scoped to it.
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["stripe-signature"];
let event;
try {
// req.body is a Buffer: the exact bytes Stripe signed.
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
switch (event.type) {
case "payment_intent.succeeded":
// fulfill the order
break;
}
res.json({ received: true });
}
);
// JSON parsing for every other route.
app.use(express.json());The same bug wears different costumes in other stacks: Next.js API routes need body parsing disabled (or the raw-body helpers your framework version provides), Fastify and NestJS need raw-body plugins or config on the webhook route. Whatever the framework, the rule is identical: constructEvent gets the raw payload or it throws.
Verify: Replay the Same Event, Expect 200
This is the satisfying part. You don't need to trigger a fresh event and hope—you replay the exact request that failed, against the fixed handler:
> Restart done. Forward the same captured request to the
tunnel URL again and tell me the response status.Claude Code calls forward_request again, your handler verifies the signature against the raw Buffer, and you get the 200 {"received": true}you were after. Same bytes, same signature, fixed parsing—a clean before/after proof.
Replay caveat:Stripe libraries reject signatures whose t= timestamp is too old—stripe-node's default tolerance is 300 seconds. If you replay a capture older than that, verification fails on the timestamp even with correct parsing. Replay promptly, or pass a larger tolerance to constructEventin a test-only code path (never loosen it in production—the tolerance is your replay attack protection).
Bonus: Diffing Requests and Regression Cover
Compare a working and a failing request
When the difference between requests isn't obvious— say, events from stripe listenverify but dashboard-configured deliveries don't (different signing secrets!)—ask Claude Code to compare them:
> Compare the request that verified with the one that
failed. What's different?It calls compare_requests with both request IDs and gets back an AI-assisted comparison: header differences, body differences, the lot. This tool is available on paid plans.
Keep it fixed: wait_for_request in CI
The same primitives that debugged this bug can guard against its return. A CI step can run stripe trigger against a dedicated endpoint and use wait_for_request plus forward_request to assert your handler still returns 200 with a live-shaped, correctly-signed payload—a smoke test your mocked unit tests can't replicate, since they were green this whole time. We cover that pattern in depth in agentic webhook testing.
Where This Workflow Shines (and Where It Doesn't)
Honest assessment: for a bug you've seen ten times, you don't need an AI—you'll grep for express.json and be done in two minutes. The MCP workflow earns its keep when you haven'tseen the bug before, because it collapses the evidence-gathering loop: the captured bytes, the signature header, the replay result, and your source code all live in one context, and the assistant reasons across all of them at once. That's the difference between guessing at fixes and proving one.
Two more caveats. wait_for_requestcaps at 60 seconds, so it's for interactive debugging and short CI waits, not long-poll monitoring. And an AI assistant can still propose a wrong fix confidently—the replay verification step exists precisely so you never have to take its word for it.
Related Reading
- Stripe Webhooks Implementation Guide — the full reference: setup, event types, signature theory, retries, and production best practices.
- Testing Stripe Webhooks With Hooklistener — the dashboard-first workflow, if you prefer clicking to prompting.
- The Hooklistener MCP Server — all 46 tools across endpoints, requests, automations, schedules, monitors, and more.
Debug Your Next Stripe Webhook in One Conversation
Everything in this session—capture endpoints, raw request inspection, waiting for live events, and replays—works on Hooklistener's free plan. Create an account at app.hooklistener.com, run claude mcp add --transport http hooklistener https://app.hooklistener.com/api/mcp, and the next time a Stripe webhook misbehaves, your debugger is already in your terminal.