How to Test Stripe Webhooks on Localhost
Stripe sends webhooks to a public URL, but the payment logic you're building runs on localhost:3000. This tutorial gets real Stripe events hitting your local handler, shows you how to verify the Stripe-Signature header correctly (the #1 thing people get wrong), and how to iterate with replays instead of making a new charge every time.
It uses Anonymily, a universal webhook tool — the exact same flow works for GitHub, Shopify, Twilio, Slack, and any other provider.
The pain
To test a Stripe webhook handler you need a real payment_intent.succeeded (or checkout.session.completed) to actually arrive at your code. Locally that means:
- A public URL Stripe can reach (your laptop doesn't have one).
- A correct signature check — get the raw body wrong and every event returns
400. - A new payment for every test iteration, unless you can replay.
Let's solve all three.
Step 1 — Point Stripe at a capture URL
Start forwarding to your local port:
npx @anonymilyhq/cli listen 3000
It prints a persistent capture URL, e.g. https://hook.anonymily.com/ab12cd34. In the Stripe Dashboard go to Developers → Webhooks → Add endpoint, paste that URL, and subscribe to the events you care about (payment_intent.succeeded, checkout.session.completed, …).
Because the URL is persistent, you configure this once — it survives restarts.
Copy the endpoint's Signing secret (
whsec_...) from the Stripe dashboard. You'll need it to verify signatures.
Step 2 — Write a correct Stripe handler
The single most important rule: verify against the raw request body, not parsed-then-re-serialized JSON. Stripe's library handles the HMAC for you, but only if you give it the raw bytes.
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; // whsec_...
const app = express();
app.post(
"/",
// raw body is REQUIRED for signature verification
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["stripe-signature"];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.error("⚠️ signature verification failed:", err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
switch (event.type) {
case "payment_intent.succeeded":
console.log("💰 paid:", event.data.object.amount);
break;
case "checkout.session.completed":
console.log("🛒 checkout done:", event.data.object.id);
break;
default:
console.log("unhandled event:", event.type);
}
// respond fast; do heavy work in a background job
res.json({ received: true });
}
);
app.listen(3000, () => console.log("listening on :3000"));
Run it (STRIPE_WEBHOOK_SECRET=whsec_... node server.js) and keep the listen command running in another terminal.
Step 3 — Trigger an event without making a real charge
You can make a real test-mode payment, but you don't have to. Fire a realistic, correctly-signed synthetic Stripe event:
# see what's available
npx @anonymilyhq/cli trigger --list
# fire a signed payment_intent.succeeded at your hook
npx @anonymilyhq/cli trigger stripe payment_intent.succeeded --hook <hookId> --token <PAT>
Watch it flow through Anonymily to localhost:3000 and hit your handler. No real money moves.
Step 4 — Replay instead of re-triggering
Your handler threw on the first try? Fix the code and replay the exact event — same body, same Stripe-Signature — instead of creating another one:
npx @anonymilyhq/cli replay <hookId> <requestId>
If you modified the payload to test an edge case, re-sign it so the signature stays valid:
npx @anonymilyhq/cli replay <hookId> <requestId> --body '{"amount": 9999}' --resign
The #1 Stripe webhook bug: No signatures found matching the expected signature
If you see this error, it's almost always one of:
- You parsed the body to JSON before verifying. Use
express.raw(...)on the webhook route soreq.bodyis the rawBuffer. A globalexpress.json()will silently break verification. - Wrong signing secret. The
whsec_...must match this specific endpoint. Each endpoint has its own secret. - A proxy rewrote the body. Some middlewares re-encode the body; verify before anything touches it.
If you still can't tell why it failed, Anonymily's AI diagnosis reads the captured request, your 400 response, and the signature, and explains the exact cause — then drafts the fix.
Step 5 — Go live
When it works locally, add a production endpoint in Stripe pointing at your real domain, copy that endpoint's signing secret into your production env, and ship. The handler code is identical.
Next steps
- How to verify webhook signatures (HMAC) — the raw-body trap in depth, for every provider.
- How to test webhooks locally — the general version of this flow.
- The Complete Guide to Webhooks — retries, idempotency, ordering.
TL;DR
Point Stripe at a persistent capture URL, verify constructEvent against the raw body, and iterate with synthetic events + replay instead of real charges:
npx @anonymilyhq/cli listen 3000