Stripe webhook signature verification is an HMAC-SHA256 comparison that takes five lines. Nearly every failure is plumbing rather than crypto: the body your handler passed to constructEvent was not the bytes Stripe signed.
By the end of this you will have an endpoint that verifies on the first try, survives a replayed request, and does the right thing when Stripe delivers the same event twice.
What you need:
- Node 18 or newer with the
stripepackage. - Express 4.17 or newer, which is where
express.raw()landed. Or Next.js on either router. Both are covered. - A Stripe account. Test mode is enough for all of this.
- The Stripe CLI, installed and logged in with
stripe login. - Somewhere to record a processed event ID. The examples use Postgres.
Budget about forty minutes. Steps 1 through 4 get a verified handler running. Step 5 is the part most tutorials skip and the part that decides whether you double-provision an account.
What the Stripe-Signature header carries
Every signed event arrives with a header that looks like this, on one line:
Stripe-Signature: t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v0=6ffbb59b...
t is the Unix timestamp of that delivery attempt. Each v prefix is a signature scheme, and the only valid live scheme is v1; the v0 value is a fake signature Stripe attaches to test events. The same page tells you to ignore every scheme that is not v1, because accepting a weaker one is a downgrade attack waiting to happen.
The signed payload is the timestamp, a literal ., and the raw request body. That is the whole construction:
signed_payload = `${t}.${rawBody}`
expected = HMAC_SHA256(signed_payload, whsec_...)
stripe.webhooks.constructEvent(payload, header, secret) does four things: parses t and the v1 values out of the header, recomputes that HMAC with your endpoint secret, compares it against each header signature in constant time, and rejects the delivery if t is further from now than the tolerance. DEFAULT_TOLERANCE in stripe-node is 300 seconds.
The timestamp is inside the signed string, so an attacker cannot backdate or postdate a captured request without invalidating the signature. That property is what makes the five-line check enough.
1. Reproduce the constructEvent raw body failure
Before fixing it, see the actual bytes move. In a Node REPL:
const raw = '{"amount": 1050, "note": "caf\\u00e9"}'; // what Stripe signed
const reserialized = JSON.stringify(JSON.parse(raw));
raw === reserialized; // false
Buffer.byteLength(raw); // 37
Buffer.byteLength(reserialized); // 30
The parse-then-stringify round trip dropped the three spaces after the colons and the comma, and collapsed the é escape into a two-byte é. Same JSON, seven fewer bytes, and the HMAC over the two strings shares nothing. That is exactly what express.json() does to your request before your handler sees it, and it is why you get:
Webhook signature verification failed. Err: No signatures found matching the expected
signature for payload. Are you passing the raw request body you received from Stripe?
The error text is the diagnosis. Stripe's troubleshooting page leads with the wrong endpoint secret, then the request body, and names whitespace, key reordering, JSON conversion, and encoding changes as the framework behaviours that break verification.
The trap catches people who think they have already handled it. In stripe-node issue #1254, Jonathan-Hofmann had bodyParser.raw({type: 'application/json'}) on the route and still failed, because the next line ran Buffer.from(JSON.stringify(req.body), 'base64').toString('utf8'). Their own read of the situation was "I am not sure if App Engine, from Google Cloud, is parsing the request body before I can even touch it." The rule that survives every framework: whatever you hand constructEvent must be a string or a Buffer that nothing has re-encoded. stripe-node says so in its own error text, that the payload must be "a string or a Buffer instance representing the raw request body".
2. Express: one route gets the raw bytes
Register express.raw() on the webhook path, and register express.json() after that route rather than before it. Middleware order in Express is evaluation order.
import express from "express";
import Stripe from "stripe";
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // a Buffer, not an object
req.get("stripe-signature"),
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error("stripe webhook rejected:", err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
res.status(200).json({ received: true });
void handleEvent(event).catch((e) => console.error(event.id, e));
}
);
app.use(express.json()); // everything else, mounted after the webhook route
Three details carry the weight. express.raw({ type: "application/json" }) leaves req.body as a Buffer for this route only. req.get("stripe-signature") reads the header case-insensitively, which matters behind proxies that lowercase it. And the 200 goes out before handleEvent runs, because Stripe's instruction is to "quickly return a successful status code (2xx) before any complex logic that could cause a timeout".
When it works you will see event.id in your logs and a 200 in the Stripe CLI output. When the secret is wrong you get the same "No signatures found" message as when the body is wrong, which is why step 1 exists: rule out the bytes before you go hunting for the secret.
If you deploy Express behind Cloudflare, an API gateway, or anything that decompresses and re-emits the body, verify in that environment too. A proxy that rewrites bytes fails identically to a body parser, and it will only show up in staging.
3. Next.js: verify the Stripe webhook signature on both routers
App Router route handlers do not parse the body for you. Read it as text and pass the string straight through.
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export const runtime = "nodejs";
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 });
}
await handleEvent(event);
return Response.json({ received: true });
}
Read the body once. Calling req.json() first and then req.text() throws, and reconstructing the text from the parsed object puts you back in step 1.
This version awaits the work before responding, unlike the Express one. On a serverless host that is the right trade, for the reason in step 5.
The Edge runtime is the caveat. constructEvent is synchronous and needs Node's crypto; on Edge, stripe-node throws and tells you to "Use await constructEventAsync(...) instead of constructEvent(...)". Either switch that one call, or pin the route with export const runtime = "nodejs" as above. A webhook handler that writes to your database has little to gain from Edge.
Pages Router parses bodies by default, so turn the parser off for the route and buffer the stream yourself. Stripe's own example does exactly this:
// pages/api/webhooks.ts
export const config = { api: { bodyParser: false } };
const buffer = (req: NextApiRequest) =>
new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const event = stripe.webhooks.constructEvent(
await buffer(req),
req.headers["stripe-signature"] as string,
process.env.STRIPE_WEBHOOK_SECRET!
);
res.json({ received: true });
}
Forgetting the config export is the single most common Pages Router failure. The handler looks right, the body is an object, and the error blames the signature.
4. Confirm it with stripe listen
Point the CLI at your local route:
stripe listen --forward-to localhost:4242/webhooks/stripe
It prints a signing secret on startup, and that secret does not change between restarts of the listen command. Put it in STRIPE_WEBHOOK_SECRET for local development. It is a different whsec_ from the one on your Dashboard endpoint, and mixing them up produces the same "No signatures found" error as a mangled body. Stripe calls this out directly: do not verify CLI-forwarded events with a Dashboard secret, or the reverse.
Then fire something in a second terminal:
stripe trigger checkout.session.completed
You are looking for three things: the CLI printing --> checkout.session.completed [evt_...] followed by [200], your own log line with the same evt_ ID, and no 400. If you want to see the failure path once, restart your server with a deliberately wrong secret and trigger again. Getting familiar with the 400 now is cheaper than meeting it during a launch.
stripe listen --events=checkout.session.completed,charge.dispute.created narrows the firehose while you work on one handler.
What the 300-second tolerance does, and what it does not
The tolerance rejects a signed request whose timestamp is more than five minutes from your server clock. That kills indefinite replay of a captured request. It does not kill replay. Anyone holding the exact bytes and header, from a leaked proxy log or a mirrored request, can resend them inside that window and your endpoint will verify them happily, because they are genuinely Stripe's signature over genuinely Stripe's payload.
The tolerance bounds the attack window. Idempotency is what stops the duplicate work, which is step 5.
Two things people get wrong here. Raising the tolerance to fix clock skew treats a symptom: a server whose clock is minutes off will also break TLS validation, JWT expiry, and your own rate limiting, and Stripe's guidance is to run NTP rather than widen the window. And setting the tolerance to 0 does not mean "strictest"; Stripe says outright that it disables the recency check entirely.
Stripe generates a fresh timestamp and signature for every delivery attempt, so a retry is not a byte-identical replay of the original request. You cannot dedupe on the signature. Dedupe on the event ID.
5. Make the handler idempotent, because Stripe retries for three days
Stripe retries delivery with exponential backoff for up to three days in live mode, and three times over a few hours for events created in a sandbox, until it sees a 2xx. A timeout, a deploy that drops a connection, a 500 from an unrelated bug: each one buys you a redelivery of an event you may have already half-processed. Ordering is not guaranteed either, and Stripe's own advice is to track event IDs rather than the created timestamp, since distinct events can share a second.
Claim the event before you act on it. A primary key does the work:
create table processed_events (
id text primary key,
received_at timestamptz not null default now()
);
async function handleEvent(event: Stripe.Event) {
const client = await pool.connect();
try {
await client.query("begin");
const { rowCount } = await client.query(
"insert into processed_events (id) values ($1) on conflict (id) do nothing",
[event.id]
);
if (rowCount === 0) {
await client.query("rollback");
return; // already handled, or in flight
}
switch (event.type) {
case "checkout.session.completed":
await provision(client, event.data.object as Stripe.Checkout.Session);
break;
case "charge.dispute.created":
await openDispute(client, event.data.object as Stripe.Dispute);
break;
}
await client.query("commit");
} catch (err) {
await client.query("rollback");
throw err;
} finally {
client.release();
}
}
The claim and the work share one transaction, so a crash mid-provision rolls both back and the retry picks it up. Two workers racing the same evt_ both attempt the insert; the second blocks on the row lock, then sees zero rows and returns. If you are reconciling disputes that arrive this way, the same claim keeps a single chargeback from being counted twice.
The claim table also covers you when you backfill by hand. Stripe keeps retrying events you processed out of band, and the guidance there is to return a successful response for an event you have already handled so the retries stop.
The fire-and-forget pattern from step 2 needs care on serverless. On Vercel or Lambda, the runtime can freeze your function the moment you return, so background work started after the response quietly disappears. Enqueue instead: write the event to a table or a queue inside the request, return 200, and process from a worker.
One verify helper for every webhook you receive
The shape you just implemented is not Stripe-specific. GitHub signs with X-Hub-Signature-256, Shopify with X-Shopify-Hmac-Sha256, and most other senders with some header carrying an HMAC over the raw bytes. The details that vary are the header name, whether a timestamp joins the signed string, and the encoding of the digest. The plumbing that breaks is always the same: something parsed the body first.
So write the raw-body capture once, per framework, and let each provider's own library do its own comparison.
Portreeve's review.resolved webhook uses the same construction: Portreeve-Signature: sha256=<hex> as HMAC-SHA256 over the raw body, verified in one call.
import { Portreeve } from "portreeve";
const event = Portreeve.verifyWebhook(
rawBody,
req.get("portreeve-signature"),
process.env.PORTREEVE_WEBHOOK_SECRET
);
if (event.type === "review.resolved" && event.resolution === "denied") {
await revokeAccount(event.external_user_id); // dedupe on event.event_id
}
Deliveries retry until they see a 2xx, so the handler needs the same event_id claim you wrote in step 5. That flow is covered in handling verdicts, and we walked through the revoke path itself in the fraud review webhook post.
Before you point it at production
Change three things. Swap the CLI's whsec_ for the Dashboard endpoint's secret, held in your secret manager rather than in the repo, and remember they differ between test and live mode for the same URL. Subscribe the endpoint to only the event types you handle, which Stripe recommends over listening to everything. Confirm the deployed route still receives raw bytes after whatever proxy sits in front of it.
Two shortcuts to refuse. Skipping verification during development because you will add it before launch leaves an endpoint that accepts any POST shaped like a checkout.session.completed, and the URL is not a secret. Catching the verification error and processing the event anyway to keep things moving is the same hole with a log line.
Then check the failure paths. In Workbench, the Event deliveries tab on the endpoint lists every attempt with its status code, so a run of 400s tells you verification is failing in production even though it passed locally. You can resend a specific event from the Dashboard for 15 days after creation, or with stripe events resend <event_id> --webhook-endpoint=<endpoint_id> for 30 days, which is the fastest way to test a fix against a real payload.
Stripe retries every non-2xx, so a handler that returns 500 on an event type it does not recognise turns each of those into three days of retries. Return 200 for events you do not handle. Keep the 400 for a signature that failed to verify; that gets retried too, and the run of 400s on the Event deliveries tab is the signal you want.
If revoking accounts after a fraud review is the reason you are wiring webhooks in the first place, Portreeve's free tier covers 1,000 screened events a month with no card. Create an account, then work in test mode and point an endpoint at the handler you just built.