Create a test URL

Test Stripe webhooks

Stripe's CLI (stripe listen) is the usual way to test webhooks locally. A temporary URL covers the other cases: seeing exactly what a registered event destination delivers, comparing snapshot and thin payloads, or checking a signature by hand without running your server.

Create a test URL

Point your Stripe webhook at it and see a real event.

What Stripe sends

Every event is a POST with a JSON body. A snapshot event, trimmed and with placeholder IDs:

{  "id": "evt_000000000000000000000000",  "object": "event",  "type": "payment_intent.succeeded",  "created": 1760011200,  "livemode": false,  "data": {    "object": {      "id": "pi_000000000000000000000000",      "object": "payment_intent",      "amount": 2000,      "currency": "usd",      "status": "succeeded"    }  }}

Thin events are much smaller: they name the event and the related object, and your handler fetches the rest from the API.

Headers

Header Example value Meaning
Stripe-Signature t=1492774577,v1=5257a869…08bd,v0=6ffbb59b…9a39 A timestamp and one or more signatures, on one line.

t is a Unix timestamp. v1 is the live signature scheme; there can be more than one v1 while you roll a secret, because Stripe signs once per active secret. Test events also carry a fake v0, which you ignore so nobody can downgrade you to it.

Verify the signature

Split the header on , and each part on =, keeping t and every v1. Build the signed payload from the timestamp, a . and the raw body; compute its HMAC-SHA256 with the endpoint's signing secret (the full whsec_… value) as hex; compare it with each v1 in constant time; and reject the event if t is too far from now. Stripe's libraries default to a 5-minute tolerance and are the recommended way to do this. The manual versions below show what they check.

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"strconv"
	"strings"
	"time"
)

func verifyStripe(body []byte, header, secret string, tolerance time.Duration) error {
	var ts string
	var sigs []string
	for _, part := range strings.Split(header, ",") {
		k, v, _ := strings.Cut(part, "=")
		switch k {
		case "t":
			ts = v
		case "v1":
			sigs = append(sigs, v)
		}
	}
	unix, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || len(sigs) == 0 {
		return errors.New("malformed Stripe-Signature")
	}
	if d := time.Since(time.Unix(unix, 0)); d > tolerance || d < -tolerance {
		return errors.New("timestamp outside tolerance")
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(ts + "."))
	mac.Write(body)
	expected := []byte(hex.EncodeToString(mac.Sum(nil)))
	for _, s := range sigs {
		if hmac.Equal(expected, []byte(s)) {
			return nil
		}
	}
	return errors.New("no matching v1 signature")
}
import { createHmac, timingSafeEqual } from "node:crypto";

// body must be the raw request bytes (a Buffer from express.raw), not parsed JSON.
export function verifyStripe(body, header, secret, toleranceSec = 300) {
  let t;
  const v1 = [];
  for (const part of (header ?? "").split(",")) {
    const [k, v] = part.split("=", 2);
    if (k === "t") t = v;
    if (k === "v1") v1.push(v);
  }
  if (!t || v1.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
  const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.`).update(body).digest("hex"));
  return v1.some((s) => {
    const b = Buffer.from(s);
    return b.length === expected.length && timingSafeEqual(b, expected);
  });
}

Respond quickly

Return a 2xx before any complex logic that could time out. Stripe retries failed deliveries for up to three days in live mode, and doesn't deliver events in the order they happened, so track event IDs to skip duplicates. A redirect counts as a failure: register the final URL.

Common mistakes

  • Letting a framework parse the body first. Stripe's docs single this out: mount express.raw, not express.json, on the webhook route.
  • Hashing the body alone. The signed string is t.body.
  • Mixing up secrets. stripe listen prints its own; each registered endpoint has another, and test and live mode differ.
  • Setting the tolerance to 0. That disables the recency check rather than making it strict.

Try it with a real event

Create a test URL, register it as an event destination's endpoint URL in Workbench, and run stripe trigger payment_intent.succeeded. The signed string includes the timestamp, so the signature verifier has no Stripe preset; use the code above against your capture instead.