Stripe’s v1 signature is the HMAC-SHA256 of the t= timestamp, a dot and the raw request body, keyed with the endpoint’s signing secret and written as hex. Both travel in one header, Stripe-Signature: t=1792036800,v1=c5cdf494…. To verify, recompute it, compare in constant time, and reject a timestamp older than your tolerance. Stripe’s libraries default to five minutes.
const crypto = require('node:crypto');
const secret = 'whsec_quackyard_demo_secret';
const body = '{"id":"evt_test_1","object":"event","type":"invoice.paid"}';
crypto.createHmac('sha256', secret).update(`1792036800.${body}`).digest('hex');
// 'c5cdf49421bd98413dda875366e39931c1106ce3b64eb4c5da6327151ee7b0b4'
The tool below signs the same 1792036800.{"id":…} and shows the same value. The key is the whole secret as text, whsec_ included: without the prefix the HMAC comes out accd4205….
With Stripe’s library
constructEvent checks the signature and the age together, and throws on either. It needs the body as Stripe sent it, so give the route express.raw and register express.json() after it:
const express = require('express');
const Stripe = require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(req.body, req.get('Stripe-Signature'), process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
res.json({ received: event.type });
});
app.use(express.json());
What goes wrong, with stripe 23 for Node:
app.use(express.json())above the route parses the body first, andconstructEventthrowsWebhook payload must be provided as a string or a Buffer […] Payload was provided as a parsed JavaScript object instead.- Passing
JSON.stringify(req.body)signs new bytes. Stripe’s docs show event bodies spread over lines with indentation, whichJSON.stringifydrops, and an indented body fails withNo signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe? - Replaying this page’s example today:
Timestamp outside the tolerance zone. Itstis fixed, so the five minutes are long gone.
In Python (stripe 16), the call is stripe.Webhook.construct_event. Flask’s raw body is request.get_data(), FastAPI’s is await request.body():
import os
import stripe
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/webhooks/stripe")
def stripe_webhook():
try:
event = stripe.Webhook.construct_event(
request.get_data(), request.headers.get("Stripe-Signature"), os.environ["STRIPE_WEBHOOK_SECRET"]
)
except (ValueError, stripe.SignatureVerificationError):
abort(400)
return {"received": event["type"]}
Without the library
Split the header on , and each item on =. Keep t and every v1: while a rolled secret is still active, Stripe signs once per secret. Ignore v0 and any other scheme, as Stripe’s docs say, so nobody can downgrade you to one. This version rejects a timestamp more than five minutes from your clock either way; the libraries only reject old ones.
const crypto = require('node:crypto');
function verifyStripe(secret, rawBody, header, tolerance = 300) {
const pairs = (header ?? '').split(',').map((item) => item.split('='));
const t = pairs.find(([key]) => key === 't')?.[1] ?? '';
const signatures = pairs.filter(([key]) => key === 'v1').map(([, value]) => Buffer.from(value ?? ''));
if (!/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false;
const expected = Buffer.from(crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex'));
// timingSafeEqual throws on different lengths, so check them first.
return signatures.some((got) => got.length === expected.length && crypto.timingSafeEqual(got, expected));
}
import hashlib
import hmac
import time
def verify_stripe(secret: str, raw_body: bytes, header: str | None, tolerance: int = 300) -> bool:
pairs = [item.partition("=")[::2] for item in (header or "").split(",")]
timestamp = next((value for key, value in pairs if key == "t"), "")
signatures = [value.encode() for key, value in pairs if key == "v1"]
if not timestamp.isdecimal() or abs(time.time() - int(timestamp)) > tolerance:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest().encode()
return any(hmac.compare_digest(expected, signature) for signature in signatures)
hmac.compare_digest handles different lengths on its own. Use isdecimal() rather than isdigit(): "²".isdigit() is True, and int("²") raises.
The timestamp
The timestamp is inside the signed payload, so changing it breaks the signature, and an old one is a replay. Keep the server’s clock on NTP. Don’t set the tolerance to 0: Stripe’s docs warn that it turns the check off. A retry isn’t rejected as stale: Stripe gives each delivery attempt a new timestamp and signature.