• 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
The inside of an old bank vault door, showing polished steel bolts, a lever arm and the dials of its time lock

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 throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH on 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 after whsec_. 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-Delivery header instead. GitHub publishes a test vector you should put in your unit tests: secret It's a Secret to Everybody, payload Hello, World!, expected signature 757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17. The code above returns true for it and false when one character of the payload changes.
  • Stripe may send several v1 signatures. 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 fake v0 signature to test events; its docs say to "ignore all schemes that aren't v1" to prevent downgrade attacks, which the filter above does. The key is the whole whsec_... string as text, which is why verifyStripe passes the secret straight to createHmac. Headers made with the library's generateTestHeaderString() verified, and so did one with two v1 values.
  • Standard Webhooks separates entries in webhook-signature with spaces, and the key is the base64-decoded part after whsec_. Signatures produced by the reference standardwebhooks package (version 1.1.1) verified, and changing the body, the webhook-id or 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:

  1. 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 listen differs from the dashboard endpoint secret, and both start with whsec_.
  2. Parsed body. Log Buffer.isBuffer(body) and the body length before verifying. If it is an object, a JSON parser ran first.
  3. 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.
  4. Header name. Node lowercases incoming header names, so read req.headers['webhook-signature'], not Webhook-Signature.
  5. Clock drift. If only some deliveries fail, compare the header timestamp with your server time.
  6. 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

Put every store in one dashboard tonight.

Sign in with Google, paste one store link, and your unified view is live in under a minute. Then 7 days of Pro free, no credit card required, and you never touch your app's code.

Start free trial