All articles

tutorial

How to Test Stripe Webhooks on Localhost

Receive real Stripe webhooks on localhost, verify the Stripe-Signature correctly, and replay events without making real charges.

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:

  1. You parsed the body to JSON before verifying. Use express.raw(...) on the webhook route so req.body is the raw Buffer. A global express.json() will silently break verification.
  2. Wrong signing secret. The whsec_... must match this specific endpoint. Each endpoint has its own secret.
  3. 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


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

Try it in 30 seconds

Capture your first webhook — from any provider — with one command. No account required.

npx @anonymilyhq/cli listen 3000Open Dashboard →