- Node.js
- Backend
- Alerts
Verify webhook signatures in Node.js: Stripe, GitHub, Polar
Verify webhook signatures in Node.js with node:crypto: raw body handling, constant-time checks, replay windows and tested code for Stripe, GitHub and Polar.
- Author
- Extenify Team
- Published
- Reading time
- 13 min read

On this page
To verify webhook signatures in Node.js, you recompute an HMAC of the request body with your shared secret and compare it with the signature header. That takes five lines. It still fails for a lot of people, because the body you hash must be the exact bytes the provider sent, and most frameworks parse JSON before your handler runs. The single most viewed question on the subject, Stripe's "No signatures found matching the expected signature for payload", has close to 90,000 views on Stack Overflow, and the answer is nearly always the raw body.
This guide explains how webhook signatures work, how to get the raw body in Express and the Next.js App Router, and how to compare signatures without leaking timing information. It then gives tested verification code for the three schemes you are most likely to meet: GitHub, Stripe and Standard Webhooks, which Polar uses. It also covers replay protection, duplicate deliveries, secret rotation and the September 2026 change to how Polar signs webhooks. All code runs on Node.js 22 with node:crypto only, and was checked on October 20, 2026 against GitHub's published test vector, headers generated by the official stripe package and signatures from the reference standardwebhooks library.
Key takeaways
- Hash the raw request body. Re-serialized JSON (
JSON.stringify(req.body)) changes whitespace or key order, and the signature no longer matches.- Compare with
crypto.timingSafeEqual, and check lengths first: it throwsERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTHon buffers of different sizes.- Check the timestamp. Stripe's libraries reject anything older than 5 minutes by default, and the timestamp is part of the signed content, so an attacker cannot change it.
- Deduplicate on the delivery or event ID. Providers retry, and a valid signature does not mean a new event.
- Mind the key format: Stripe uses the whole
whsec_string as the key, Standard Webhooks decodes the base64 part afterwhsec_. Polar secrets created before 8 September 2026 follow a third rule.
How webhook signatures work
A webhook is an HTTP POST from someone else's server to a public URL of yours. Anyone who knows the URL can send a request to it, so the provider signs each delivery with a secret that only the two of you share. The scheme is almost always HMAC-SHA256: the provider computes HMAC(secret, signed_content), puts the result in a header, and you compute the same value and compare.
What changes between providers is the header, what goes into the signed content and how the result is encoded:
| Provider | Header | Signed content | Encoding | Timestamp |
|---|---|---|---|---|
| GitHub | X-Hub-Signature-256: sha256=... |
The raw body | Hex | None |
| Stripe | Stripe-Signature: t=...,v1=... |
timestamp.body |
Hex | In the header, signed |
| Standard Webhooks (Polar and others) | webhook-id, webhook-timestamp, webhook-signature: v1,... |
id.timestamp.body |
Base64 | Separate header, signed |
The details come from GitHub's validation guide, Stripe's manual verification section and the Standard Webhooks specification. The specification is worth knowing even if you never implement it: it standardizes the three headers, the msg_id.timestamp.payload content and a whsec_ secret holding 24 to 64 random bytes, so one verifier works for every provider that follows it.
If a provider ships an official SDK with a verification function (Stripe's constructEvent(), Polar's validateEvent()), use it. Write your own when there is no SDK, when you want one verifier for several providers, or when you need to understand why the SDK says no.
Step 1: Get the raw request body
The HMAC is computed over bytes. If your framework parses the JSON and you re-serialize it, the bytes change: whitespace disappears, unicode escapes change, numbers lose trailing zeros. In our test, a Stripe payload that was parsed and re-stringified failed the official library's check with exactly the error above: "No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe?"
Express. Use express.raw() on the webhook route only, and register it before any global express.json(). Stripe's own troubleshooting page says the same thing: "make sure app.use(express.json()) appears after your webhook route."
// server.mjs: Express 5 webhook endpoint that verifies before it parses.
import express from 'express';
import { verifyStandardWebhook, standardWebhookKey } from './verify-webhook.mjs';
const app = express();
const seen = new Set(); // use a database with a unique index in production
// 1. Raw body only on the webhook route, registered before any JSON parser.
app.post('/webhooks/billing', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => {
if (!Buffer.isBuffer(req.body)) return res.status(400).send('expected a raw body');
// 2. Verify the signature on the exact bytes that arrived.
if (!verifyStandardWebhook(req.body, req.headers, standardWebhookKey(process.env.WEBHOOK_SECRET))) {
return res.status(401).send('invalid signature');
}
// 3. Deduplicate on the delivery id, then acknowledge fast.
const id = req.get('webhook-id');
if (seen.has(id)) return res.sendStatus(204);
seen.add(id);
const event = JSON.parse(req.body.toString('utf8'));
queueMicrotask(() => handleEvent(event)); // hand off to a job queue in production
res.sendStatus(204);
});
// 4. JSON parsing for every other route comes after.
app.use(express.json());
function handleEvent(event) {
console.log('handled', event.type);
}
export default app;
Next.js App Router. Route handlers receive a standard Request, so read the body once as bytes and parse it yourself afterwards:
// app/api/webhooks/billing/route.js
import { verifyStandardWebhook, standardWebhookKey } from '@/lib/verify-webhook';
export async function POST(request) {
const rawBody = Buffer.from(await request.arrayBuffer()); // never request.json() first
const headers = Object.fromEntries(request.headers); // header names arrive lowercased
if (!verifyStandardWebhook(rawBody, headers, standardWebhookKey(process.env.WEBHOOK_SECRET))) {
return new Response('invalid signature', { status: 401 });
}
const event = JSON.parse(rawBody.toString('utf8'));
// ...deduplicate on headers['webhook-id'], enqueue the work...
return new Response(null, { status: 204 });
}
Behind a proxy or API gateway, check that nothing in front of Node rewrites the body either. Stripe's page includes a body mapping template for AWS API Gateway for exactly this reason.
Step 2: Compare signatures in constant time
A plain === stops at the first character that differs, so the time it takes leaks how much of a guess was right. GitHub's guide is blunt about it: "Never use a plain == operator." Node has crypto.timingSafeEqual(), with one sharp edge: it throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH ("Input buffers must have the same byte length") when the inputs differ in length. An attacker controls the header, so a short signature would turn into a 500 error instead of a 401. Check the length first:
function safeEqual(a, b) {
const left = Buffer.from(a);
const right = Buffer.from(b);
return left.length === right.length && timingSafeEqual(left, right);
}
Leaking the length is harmless here, because the length of a SHA-256 signature is public.
Step 3: Verify webhook signatures for each scheme
One file covers all three schemes. Each function takes the raw body as a Buffer, returns true or false, and never throws on bad input:
// verify-webhook.mjs: HMAC webhook signature checks with node:crypto only.
import { createHmac, timingSafeEqual } from 'node:crypto';
// Constant-time comparison. timingSafeEqual throws on different lengths, so check that first.
function safeEqual(a, b) {
const left = Buffer.from(a);
const right = Buffer.from(b);
return left.length === right.length && timingSafeEqual(left, right);
}
// GitHub: X-Hub-Signature-256: sha256=<hex HMAC of the raw body>
export function verifyGitHub(rawBody, signatureHeader, secret) {
if (typeof signatureHeader !== 'string' || !signatureHeader.startsWith('sha256=')) return false;
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
return safeEqual(expected, signatureHeader);
}
// Stripe: Stripe-Signature: t=<unix seconds>,v1=<hex HMAC of "t.body">[,v1=...]
export function verifyStripe(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
if (typeof signatureHeader !== 'string') return false;
const pairs = signatureHeader.split(',').map((part) => part.split('='));
const timestamp = Number(pairs.find(([key]) => key === 't')?.[1]);
const signatures = pairs.filter(([key]) => key === 'v1').map(([, value]) => value ?? '');
if (!Number.isInteger(timestamp) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
return signatures.some((signature) => safeEqual(expected, signature));
}
// Standard Webhooks (Polar and other providers that follow the spec):
// webhook-signature: v1,<base64 HMAC of "id.timestamp.body"> [v1,<...>]
// The key is the base64 part of the whsec_ secret. Pass several keys while you rotate.
export function standardWebhookKey(secret) {
return Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
}
export function verifyStandardWebhook(rawBody, headers, keys, toleranceSeconds = 300) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatureHeader = headers['webhook-signature'];
if (!id || !timestamp || !signatureHeader) return false;
const seconds = Number(timestamp);
if (!Number.isInteger(seconds) || Math.abs(Date.now() / 1000 - seconds) > toleranceSeconds) return false;
const received = signatureHeader
.split(' ')
.map((entry) => entry.split(','))
.filter(([version]) => version === 'v1')
.map(([, signature]) => signature ?? '');
return [keys].flat().some((key) => {
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest('base64');
return received.some((signature) => safeEqual(expected, signature));
});
}
Some notes on each scheme:
- GitHub signs only the body and sends no timestamp, so the signature alone cannot stop a replay; deduplicate on the
X-GitHub-Deliveryheader instead. GitHub publishes a test vector you should put in your unit tests: secretIt's a Secret to Everybody, payloadHello, World!, expected signature757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17. The code above returnstruefor it andfalsewhen one character of the payload changes. - Stripe may send several
v1signatures. When you roll an endpoint secret and keep the old one active for up to 24 hours, "Stripe generates one signature for each secret", so accept a match on any of them. Stripe also adds a fakev0signature to test events; its docs say to "ignore all schemes that aren'tv1" to prevent downgrade attacks, which the filter above does. The key is the wholewhsec_...string as text, which is whyverifyStripepasses the secret straight tocreateHmac. Headers made with the library'sgenerateTestHeaderString()verified, and so did one with twov1values. - Standard Webhooks separates entries in
webhook-signaturewith spaces, and the key is the base64-decoded part afterwhsec_. Signatures produced by the referencestandardwebhookspackage (version 1.1.1) verified, and changing the body, thewebhook-idor the timestamp made them fail. Passing an array of keys lets you accept an old and a new secret during a rotation.
Polar's September 2026 signing change
Polar signs webhooks with the Standard Webhooks headers, but its delivery docs now distinguish two kinds of secret. Secrets "generated on or after 8 September 2026, 00:00 UTC are Standard Webhooks": pass them to a Standard Webhooks library as they are. Older secrets use what Polar calls Polar HMAC, where the "Key is the UTF-8 bytes of the full whsec_... string"; Polar's advice is to "Base64-encode that string before you hand it to a Standard Webhooks library."
With the verifier above, that means:
const secret = process.env.POLAR_WEBHOOK_SECRET;
const keys = [
standardWebhookKey(secret), // secrets created on or after 8 September 2026
Buffer.from(secret, 'utf8'), // older secrets: the whole whsec_ string is the key
];
const ok = verifyStandardWebhook(rawBody, headers, keys);
Trying both keys is what Polar's own SDKs do: the docs say "Polar SDKs 1.0.0-alpha.19 and later try both." If you use validateEvent() from @polar-sh/sdk, upgrade past that version and you are covered. If you copied a custom verifier before September, it keeps working for your old secret but rejects every delivery the day you create a new one. In our test, a signature made with a legacy-style key passed with the two-key list and failed with the Standard Webhooks key alone.
Step 4: Reject replays and stale deliveries
A valid signature proves the provider sent the message, not that it is the first time you have seen it. Someone who captures one delivery can send it again. Two defences work together.
A time window. Stripe's docs explain that because the timestamp "is part of the signed payload, it's also verified by the signature, so an attacker can't change the timestamp without invalidating the signature." Its libraries use "a default tolerance of 5 minutes", and Stripe warns against a tolerance of 0, which "disables the recency check entirely". The functions above use 300 seconds. Keep your server clock synced with NTP, or legitimate deliveries start failing. Retries are not affected: Stripe generates "a new signature and timestamp for the new delivery attempt."
Deduplication. Inside the window, record each ID you process and skip repeats. The Standard Webhooks spec suggests using webhook-id "as an idempotency key", Stripe recommends logging event IDs because "Webhook endpoints might occasionally receive the same event more than once", and GitHub sends X-GitHub-Delivery. In MongoDB, a unique _id does the work, and a TTL index cleans up:
// One-time setup: drop recorded IDs after 7 days.
await db.collection('webhook_deliveries').createIndex({ receivedAt: 1 }, { expireAfterSeconds: 7 * 24 * 3600 });
async function firstTimeSeen(deliveryId) {
try {
await db.collection('webhook_deliveries').insertOne({ _id: deliveryId, receivedAt: new Date() });
return true;
} catch (error) {
if (error.code === 11000) return false; // duplicate key: already processed
throw error;
}
}
Answer a duplicate with a 2xx, not an error, so the provider stops retrying it.
Step 5: Respond fast and do the work later
Providers time out and retry slow endpoints. Polar times out after 10 seconds, recommends responding "within 2 seconds", retries up to 10 times, and disables an endpoint after "10 consecutive failed deliveries". Stripe retries for up to three days in live mode and asks you to "quickly return a successful status code (2xx) before any complex logic that could cause a timeout." Verify, deduplicate, store or enqueue the event, return 204, and do the real work in a background job. A daily Node.js cron job that reconciles your records with the provider's API is a good safety net for anything a webhook missed.
Return 401 for a bad signature and 400 for a malformed request, and log both with the delivery ID but without the body or the secret. A spike in signature failures is worth an alert of its own: it usually means a deploy broke raw body handling or someone rotated a secret, and our guides to sending alerts with a Discord webhook or a Telegram bot show two quick ways to get that alert in front of someone.
Debugging a signature mismatch
When every delivery fails, work through this list in order:
- Wrong secret. Print the first few characters of the secret your code uses and compare with the dashboard. Stripe notes that a secret from
stripe listendiffers from the dashboard endpoint secret, and both start withwhsec_. - Parsed body. Log
Buffer.isBuffer(body)and the body length before verifying. If it is an object, a JSON parser ran first. - Wrong key encoding. Standard Webhooks wants the base64-decoded bytes after
whsec_; Stripe and GitHub want the secret as text. Mixing them up fails every time. - Header name. Node lowercases incoming header names, so read
req.headers['webhook-signature'], notWebhook-Signature. - Clock drift. If only some deliveries fail, compare the header timestamp with your server time.
- Body changed in transit. A proxy, CDN or middleware that decompresses, re-encodes or trims the body breaks the signature even though the JSON looks identical.
Webhooks and store data
Webhooks are how billing providers, Git hosts and many APIs tell your SaaS backend that something changed. App stores and extension stores do not offer them for public listing data, which is why tracking a rating, a review or a competitor's new version usually means polling and diffing every day. Extenify does that polling for you: it is a cross-platform extension dashboard for the Chrome Web Store, Edge Add-ons, Firefox Add-ons, Google Play and the App Store, with competitor extension monitoring and app store rating alerts delivered to email, Slack, Discord, Telegram or Microsoft Teams. If you would rather build your own, our guides to diffing two JSON snapshots in Node.js and App Store review alerts cover the core of it. Plans start with a 7-day free trial on the pricing page.
Frequently asked questions
How do I verify a webhook signature in Node.js?
Read the raw request body as a Buffer, compute crypto.createHmac('sha256', key).update(signedContent).digest() in the encoding the provider uses, and compare it with the signature header using crypto.timingSafeEqual after checking the lengths match. The signed content and key format depend on the provider: the raw body for GitHub, timestamp.body for Stripe, id.timestamp.body for Standard Webhooks.
Why does Stripe say "No signatures found matching the expected signature for payload"?
The body, the secret or the header is not what Stripe sent. Most often the body was parsed as JSON before verification, so constructEvent() hashed different bytes. Use express.raw({ type: 'application/json' }) on the webhook route, or await request.text() or arrayBuffer() in a Next.js route handler, and confirm you are using the secret for that specific endpoint.
Do I need the raw body to verify a webhook?
Yes. The signature covers the exact bytes the provider sent. Parsing and re-serializing JSON produces different bytes even when the data is the same, and the check fails.
Is it safe to compare signatures with ===?
No. A normal string comparison returns as soon as it finds a difference, which leaks timing information. Use crypto.timingSafeEqual, and check that both buffers have the same length first, because it throws when they do not.
What is the webhook-signature header?
It is the signature header from the Standard Webhooks specification, used by Polar and other providers. It holds one or more space-separated entries such as v1,<base64 signature>, computed over webhook-id.webhook-timestamp.body with the secret from your dashboard.
How long should the timestamp tolerance be?
Five minutes is the common default and the one Stripe's libraries use. Shorter windows reject deliveries from servers with slightly wrong clocks; much longer windows give a captured request more time to be replayed. Never set it to zero.
Summary
To verify webhook signatures in Node.js, get the raw body before any JSON parser touches it, compute the provider's HMAC over the exact signed content with the right key format, and compare in constant time after a length check. GitHub signs the body, Stripe signs timestamp.body and Standard Webhooks signs id.timestamp.body; one small file with node:crypto handles all three. Then reject stale timestamps, deduplicate on the delivery ID, answer within a couple of seconds and do the work in the background. If you receive Polar webhooks with your own verifier, check which kind of secret you have: since 8 September 2026, new secrets follow the Standard Webhooks key format.
Image credits
- Cover photo: The inside of a bank vault door by Topher on the WordPress Photo Directory, CC0.


