Stripe webhooks: the complete testing guide

To test Stripe webhooks, point a test-mode endpoint at a URL you can watch (a free webhooks.sh bin, no signup) or run stripe listen --forward-to localhost, then verify Stripe-Signature: HMAC-SHA256 over the timestamp and raw body with your whsec_ secret, 5-minute tolerance.

Stripe is usually the first webhook integration a codebase gets, and the first one to silently break in production. This guide covers the whole loop: pointing Stripe at a URL you can watch, reading the events that arrive, and verifying signatures the way Stripe expects — no skipped steps, no "it worked on my machine".

Get a URL you can watch

You need a public HTTPS endpoint before Stripe will send anything. Two options:

  • A throwaway inspection bin. Open view.webhooks.page, create a bin (no account), and copy the ingest URL. Every request Stripe sends lands there live — method, headers, full body, timing.
  • The Stripe CLI. stripe listen --forward-to localhost:3000/webhooks/stripe forwards events to your local app. Great for driving your own handler; less great for seeing the raw request exactly as Stripe sent it.

The two complement each other: the bin shows ground truth on the wire, the CLI drives your code. When behavior differs between them, trust the wire.

Point Stripe at it

In the Stripe Dashboard: Developers → Webhooks → Add endpoint, paste your bin URL, and pick the events you care about (payment_intent.succeeded, checkout.session.completed, customer.subscription.* are the usual suspects). Stripe shows the endpoint's signing secret — a whsec_… string — after creation. Save it; you need it for verification.

Then trigger traffic. Either perform an action in test mode (create a checkout session, attach a card) or use the CLI:

stripe trigger payment_intent.succeeded

The event appears in your bin within a second or two, with every header intact.

What Stripe sends

Every delivery is a POST with Content-Type: application/json and a body shaped like:

{
  "id": "evt_1…",
  "object": "event",
  "type": "payment_intent.succeeded",
  "data": { "object": { … } },
  "livemode": false,
  "created": 1756…
}

The header that matters is Stripe-Signature:

Stripe-Signature: t=1696167045,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

t is the timestamp of the attempt, v1 the HMAC-SHA256 signature. On retries, multiple v1 values can appear — one per endpoint secret, if you have several.

Verify the signature (the part everyone gets wrong)

The signed payload is not the request body. It is the timestamp, a dot, then the raw, unparsed body:

signed_payload = "${t}." + rawBody
expected = HMAC_SHA256(signing_secret, signed_payload)  // hex

Compare expected against every v1 value in the header with a constant-time comparison, and reject the request if t is more than a few minutes old (Stripe's default tolerance is 5 minutes) — that window is your replay-attack protection.

Node/Express, done right:

import crypto from "node:crypto";
import express from "express";

const app = express();

// Raw body, not express.json() — parsing destroys the bytes you must verify.
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("Stripe-Signature") ?? "";
  const pairs = header.split(",").map((kv) => {
    const i = kv.indexOf("=");
    return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
  });
  const t = pairs.find(([k]) => k === "t")?.[1] ?? "";
  const v1s = pairs.filter(([k]) => k === "v1").map(([, v]) => v); // rotation/retries can send several

  const signed = t + "." + req.body.toString("utf8");
  const expected = crypto
    .createHmac("sha256", process.env.STRIPE_WEBHOOK_SECRET!)
    .update(signed)
    .digest();

  const valid = v1s.some((v) => {
    const got = Buffer.from(v, "hex");
    return got.length === expected.length && crypto.timingSafeEqual(got, expected);
  });

  const age = Math.abs(Date.now() / 1000 - Number(t));
  if (!valid || age > 300) return res.sendStatus(400);

  const event = JSON.parse(req.body.toString("utf8"));
  // …handle event.type…
  res.sendStatus(200);
});

Don't want to hand-roll it? Stripe's official SDKs (stripe.webhooks.constructEvent in Node) do exactly this, including the timestamp check.

To sanity-check your understanding before wiring it into code, paste a captured request into our HMAC signature verifier — it runs entirely in your browser.

Failures, retries, and duplicates

  • Respond fast. Anything but a 2xx — or no response within ~20 seconds — counts as a failure. Do slow work (emails, provisioning) asynchronously after acknowledging.
  • Retries are real. Stripe retries failed deliveries for up to 3 days with exponential backoff. Your handler must be idempotent: store the id (evt_…) of processed events and skip duplicates.
  • Order is not guaranteed. customer.subscription.updated can arrive before customer.subscription.created. Fetch current state from the API instead of assuming event order.

Common pitfalls

  • Verifying the parsed body. JSON re-serialization changes key order and whitespace. Verification fails. Always verify the raw bytes.
  • Body-parser middleware ordering. A global express.json() mounted before your webhook route eats the raw body. Mount express.raw() on the webhook route first.
  • Test vs live secrets. Each endpoint has separate test-mode and live-mode signing secrets. A test event will never verify against the live secret.
  • CLI secret vs endpoint secret. stripe listen prints its own whsec_… for the forwarded session — different from the Dashboard endpoint's secret. Use the right one for the source you're testing.

The fast feedback loop

  1. Create a bin at view.webhooks.page and paste it into a Stripe test-mode endpoint.
  2. stripe trigger a few event types; watch them arrive with headers intact.
  3. Copy a real Stripe-Signature + body into the verifier and confirm your mental model.
  4. Move the URL into your app (or keep the bin and upgrade to a persistent endpoint when you're tired of re-pasting).

Once it works against the wire, it works everywhere — Stripe's delivery is the boringly reliable part. It's your handler that needs the testing.

Last updated

Try it in 10 seconds.

Create a free bin at view.webhooks.page — no signup, no install. Point your sender at the URL and watch requests land live. Bins expire after 48 hours.