Stripe webhook signature verification
The signed string is not the body. It is the timestamp, a dot, and then the raw body, so the header reads t=1789167242,v1=ce7a1138…. Sign those 126 bytes and your receiver answers 200. Sign the 115 bytes of the body alone and it answers 400 on every delivery, with a correct HMAC.
Why check this
Run this before a payment integration takes its first live event, and again whenever the endpoint moves behind a new proxy. The failure it prevents is the one that looks like a broken secret and is not: a receiver that computes a perfectly correct HMAC over the wrong bytes and rejects every event, so paid orders never reach the fulfilment queue. The scheme is published, so the whole check runs locally, against a secret you pick, with no account and no sandbox credentials.
Prerequisites
- Node 22 and the three files below.
stripe-recv.jsis the receiver,bodyonly-recv.jsis the same receiver with one line wrong,sign-stripe.mjsbuilds the header. - curl, for the three deliveries.
- A local secret. This page uses
whsec_test_do_not_use_a_real_one. The scheme is documented in Verify webhook signatures manually, and nothing here needs an account. - An event body saved as
event.jsonwith no trailing newline, 115 bytes:{"id":"evt_3Ab","type":"payment_intent.succeeded","data":{"object":{"id":"pi_3Ab","amount":2599,"currency":"usd"}}}. - Ports 8923 and 8928 free. Confirm with
netstat -ano | grep 8923, and stop both receivers afterwards.
// stripe-recv.js - verifies a Stripe-format signature over "<timestamp>.<raw body>".
const http = require('node:http');
const crypto = require('node:crypto');
const SECRET = process.env.WEBHOOK_SIGNING_SECRET || 'whsec_test_do_not_use_a_real_one';
const TOLERANCE = 300;
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks);
const header = String(req.headers['stripe-signature'] || '');
const t = /(?:^|,)t=(\d+)/.exec(header);
const v1 = /(?:^|,)v1=([0-9a-f]{64})/.exec(header);
if (!t || !v1) { console.log('header unparsable: ' + header); return res.writeHead(400).end('no t= or v1=\n'); }
const signed = Buffer.concat([Buffer.from(t[1] + '.', 'utf8'), raw]);
const want = crypto.createHmac('sha256', SECRET).update(signed).digest('hex');
const a = Buffer.from(v1[1], 'utf8'), b = Buffer.from(want, 'utf8');
const match = a.length === b.length && crypto.timingSafeEqual(a, b);
const age = Math.floor(Date.now() / 1000) - Number(t[1]);
const fresh = Math.abs(age) <= TOLERANCE;
console.log(`t=${t[1]} age=${age}s signed=${signed.length}B match=${match} fresh=${fresh}`);
if (!match) return res.writeHead(400).end('signature mismatch\n');
if (!fresh) return res.writeHead(400).end(`timestamp outside the ${TOLERANCE}s tolerance\n`);
res.writeHead(200).end('accepted\n');
});
}).listen(8923, '127.0.0.1', () => console.log('receiver on http://127.0.0.1:8923/stripe'));
// bodyonly-recv.js - the same receiver with one line wrong: it signs the body alone.
const http = require('node:http');
const crypto = require('node:crypto');
const SECRET = 'whsec_test_do_not_use_a_real_one';
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks);
const v1 = /(?:^|,)v1=([0-9a-f]{64})/.exec(String(req.headers['stripe-signature'] || ''));
const want = crypto.createHmac('sha256', SECRET).update(raw).digest('hex');
console.log(`sent=${v1 ? v1[1] : '(none)'}\nwant=${want}`);
res.writeHead(v1 && v1[1] === want ? 200 : 400).end();
});
}).listen(8928, '127.0.0.1', () => console.log('body-only receiver on http://127.0.0.1:8928/stripe'));
// sign-stripe.mjs - builds a Stripe-format signature header for a body on disk.
import { createHmac } from 'node:crypto';
import { readFileSync } from 'node:fs';
const secret = process.env.WEBHOOK_SIGNING_SECRET || 'whsec_test_do_not_use_a_real_one';
const raw = readFileSync(process.argv[2]);
const t = process.argv[3] || String(Math.floor(Date.now() / 1000));
const signed = Buffer.concat([Buffer.from(`${t}.`), raw]);
console.error(`signed payload: ${signed.length} bytes, the ${t.length}-character timestamp and a dot in front of the ${raw.length} bytes of the body`);
console.log(`t=${t},v1=${createHmac('sha256', secret).update(signed).digest('hex')}`);
Steps
- Step 1.
Start the receiver in its own terminal.
node stripe-recv.js | tee stripe.logreceiver on http://127.0.0.1:8923/stripe - Step 2.
Start the broken one beside it, in a third terminal. Running both is the point of the procedure: the difference between them is one argument to one function.
node bodyonly-recv.js | tee bodyonly.logbody-only receiver on http://127.0.0.1:8928/stripe - Step 3.
Build the header. The signer prints the length of what it signed to stderr and the header value to stdout.
T=$(date +%s) node sign-stripe.mjs event.json "$T"signed payload: 126 bytes, the 10-character timestamp and a dot in front of the 115 bytes of the body t=1789167242,v1=ce7a11382df9cb865d353ffbed572a155bd41663d5fed32ec1f1a1c7878644f8126 bytes against the 115 the sender transmits. Those 11 extra bytes are the whole procedure.
- Step 4.
Deliver the event to the receiver that signs the timestamp with the body.
SIG=$(node sign-stripe.mjs event.json "$T" 2>/dev/null) curl -s -w '%{http_code}\n' -X POST 'http://127.0.0.1:8923/stripe' -H 'content-type: application/json' -H "stripe-signature: $SIG" --data-binary @event.jsonaccepted 200 - Step 5.
Send the identical request to the receiver that signs the body alone.
curl -s -o /dev/null -w '%{http_code}\n' -X POST 'http://127.0.0.1:8928/stripe' -H 'content-type: application/json' -H "stripe-signature: $SIG" --data-binary @event.json400Its terminal shows why, and the two values have nothing in common:
sent=ce7a11382df9cb865d353ffbed572a155bd41663d5fed32ec1f1a1c7878644f8 want=4e802723de48f92965b5c0e3992668245f692e4ca6cfe6f37ded898db01e8fc6This receiver has the right secret, the right algorithm, the right encoding and the right body. It will reject every event it is ever sent, and the error it reports is the same one a wrong secret reports.
- Step 6.
Change the amount from 2599 to 199 and send the header from step 3 unchanged.
curl -s -w '%{http_code}\n' -X POST 'http://127.0.0.1:8923/stripe' -H 'content-type: application/json' -H "stripe-signature: $SIG" --data-raw '{"id":"evt_3Ab","type":"payment_intent.succeeded","data":{"object":{"id":"pi_3Ab","amount":199,"currency":"usd"}}}'signature mismatch 400 - Step 7.
Read the receiver log. The
signedcolumn separates a tampered body from a receiver that signs the wrong string.cat stripe.logreceiver on http://127.0.0.1:8923/stripe t=1789167242 age=0s signed=126B match=true fresh=true t=1789167242 age=2s signed=125B match=false fresh=trueThe second delivery signed 125 bytes rather than 126, because the tampered body is one byte shorter. A receiver that prints only
match=falsesends you looking for a secret that was never wrong.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 200 and match=true | The receiver signs the timestamp and the body | Move on to the tolerance window and to duplicate events. |
| 400 on every delivery, match=false | The signed string is wrong, usually the body alone | Concatenate the timestamp, a dot, then the raw bytes, in that order. |
| signed is 11 bytes more than the body | The prefix is present | A 10-digit timestamp plus a dot. A different number here means the timestamp was formatted in milliseconds. |
| signed equals the body length | The prefix is missing | The receiver hashes only the body. This is the bug the third terminal reproduces. |
| header unparsable | The header never arrived, or a proxy rewrote it | Log the raw header value before parsing it. |
| 400 with fresh=false and match=true | The signature is correct and the timestamp is old | That is the replay window, not a signature problem. |
Common mistakes
What to check next
- Webhook replay attack: what the timestamp in this header is for, and what a tolerance window does and does not stop.
- Webhook signature verification failed: the other reason a correct HMAC covers the wrong bytes.
- How to check webhook signature: the bare scheme, without a timestamp.
- Webhook idempotency: what still has to be true after a signature verifies.
- How to test webhook delivery: retries and delivery ids around the check.
FAQ
How do I test Stripe webhooks locally?
Sign a payload with a secret you choose and post it to your own endpoint, as steps 3 and 4 do. That covers the signed string, the tolerance and the handler. A live account and its forwarding tool are needed only for the final pass with real events.
Where does the Stripe webhook signing secret come from?
The endpoint's page in the provider dashboard, one secret per endpoint, different in test and live mode. This procedure uses a local secret instead, because the scheme is what is being checked and the secret is an input to it.
What exactly gets signed?
The timestamp from the header, an ASCII dot, then the raw request body, concatenated as bytes. Step 3 prints the total: 126 bytes for a 115-byte body with a 10-digit timestamp.
Is a matching signature enough to process the event?
No. It proves the body and the secret. Freshness needs the timestamp check, and duplicate delivery needs the event id. A verified event can still be the third copy of one you already booked.
Can I verify without the provider's library?
Yes. The scheme is published and the receiver on this page is 26 lines. Use the library in production where it exists, and keep this receiver for isolating a failure the library reports as a single boolean.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
- Checker: webhook-signature compute and compare an HMAC-SHA256 signature for a payload and secret (Stripe, GitHub, generic formats) with WebCrypto
- Payment gateway testing checklist
- All payments and webhooks checks
intermediate10 minpublished updated Maks Verny