If your Polar webhook handler fails with WebhookVerificationError: No matching signature found, it most likely builds the wrong key from your webhook secret. Since 8 September 2026 Polar signs with one of two keys, depending on how and when the secret was created, and code that knows only one of them rejects every event signed with the other.
The fix is to try both keys, or to use an SDK version that does. This page shows which key your secret uses, why npm install @polar-sh/sdk can still get it wrong, and a check in plain Node that accepts both.
The error: No matching signature found
The message comes from standardwebhooks, the library that @polar-sh/sdk 0.49.0 uses to check Polar events, and one you may call yourself; 1.0.0-alpha.22 checks signatures itself and throws PolarWebhookVerificationError with the same message. It means the three headers were there and the timestamp was fresh, but none of the v1 signatures in webhook-signature matched the one computed with your key. The library's other messages (version 1.1.1, checked on 25 September 2026) point elsewhere:
| Message | What it means |
|---|---|
Missing required headers | webhook-id, webhook-timestamp or webhook-signature did not reach your code |
Invalid Signature Headers | webhook-timestamp is not a number |
Message timestamp too old or Message timestamp too new | the timestamp is more than 5 minutes away from your clock |
No matching signature found | no signature matched: a wrong key, or a body that is not the raw bytes Polar sent |
A body that was parsed as JSON and serialized again is the other common cause. The signature covers the exact bytes Polar sent, and a new serialization can change spaces and the order of keys. Check the raw body first, then parse it.
What changed on 8 September 2026
Polar moved its webhooks to the Standard Webhooks key format in pull request #14117, merged on 4 September 2026, with a cutoff of 8 September 2026, 00:00 UTC. The signature itself did not change, only the key it is computed with:
| Aspect | Polar HMAC (legacy) | Standard Webhooks |
|---|---|---|
| Which secrets | generated by Polar before the cutoff, and every secret you set yourself | generated by Polar on or after the cutoff |
| Key | the UTF-8 bytes of the whole secret, whsec_ included | the part after whsec_, base64-decoded |
| Signed text | <webhook-id>.<webhook-timestamp>.<raw body> | the same |
| Signature | HMAC-SHA256 in base64, sent as v1,<base64> in webhook-signature | the same |
Polar's documentation says it in two sentences (quoted as of 25 September 2026):
Secrets generated on or after 8 September 2026, 00:00 UTC are Standard Webhooks. Older secrets are Polar HMAC.
Which key your secret uses
- A secret Polar generated before 8 September 2026 uses the legacy key. It is
whsec_followed by 43 letters and digits. - A secret Polar generated on or after 8 September 2026 uses the standard key:
whsec_followed by base64. - A secret you set yourself uses the legacy key, whatever the date. Polar made this explicit in pull request #14400 on 11 September 2026: a merchant-provided secret always signs the legacy way.
The same day Polar added an audit script in pull request #14421. It finds endpoints marked as standard whose secret cannot sign that way, and returns them to legacy signing. The scheme follows the secret, and a check that tries both keys does not have to guess. Polar's documentation says the same of its own SDKs:
Polar SDKs 1.0.0-alpha.19 and later try both keys.
Why npm install @polar-sh/sdk can still fail
As of 25 September 2026, npm install @polar-sh/sdk installs 0.49.0, the version under the latest tag, published on 20 July 2026. Its validateEvent turns the secret into Buffer.from(secret, "utf-8").toString("base64") and hands that to standardwebhooks, which decodes it back into the UTF-8 bytes of the whole secret. That is the legacy key and only the legacy key. The next tag, 1.0.0-alpha.22 from 15 September 2026, tries both keys.
| Your secret | 0.49.0 (latest) | 1.0.0-alpha.22 (next) |
|---|---|---|
| Generated by Polar before 8 September 2026 | passes: legacy key | passes |
| Set by you, any date | passes: legacy key | passes |
| Generated by Polar on or after 8 September 2026 | fails: No matching signature found | passes: standard key |
We confirmed the table by running both versions on 25 September 2026, with a legacy and a standard secret. There are two ways out.
The first is to install the version that tries both keys, npm install @polar-sh/sdk@next, and pin the exact version, since it is an alpha. Its webhooks API changed: the helpers come from a dated entry point, validateEvent is async, and a bad signature throws webhooks.PolarWebhookVerificationError:
import { webhooks } from "@polar-sh/sdk/2026-04";
const event = await webhooks.validateEvent(body, headers, secret);
The second is to keep your SDK version and check the signature yourself, as below.
Verify both keys in Node
This check uses only node:crypto. It takes the body exactly as received, and tries the legacy key first, then the standard key, as the SDK does.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
// The keys a Polar secret can sign with: the UTF-8 bytes of the whole
// secret (Polar HMAC) and, for a whsec_ secret, the base64 after the
// prefix (Standard Webhooks).
function polarKeys(secret) {
const keys = [Buffer.from(secret, "utf8")];
if (secret.startsWith("whsec_")) {
const rest = secret.slice("whsec_".length);
const key = Buffer.from(rest, "base64");
// Buffer.from skips characters outside base64: keep the key only if
// it reads back as the same text.
const unpad = (text) => text.replace(/=+$/, "");
if (unpad(key.toString("base64")) === unpad(rest) && key.length >= 16) {
keys.push(key);
}
}
return keys;
}
// rawBody: the body exactly as received (string or Buffer), not JSON that
// was parsed and serialized again.
export function verifyPolarWebhook(rawBody, headers, secret, now) {
now ??= Math.floor(Date.now() / 1000);
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"] ?? "";
const header = headers["webhook-signature"];
if (!id || !/^\d+$/.test(timestamp) || !header) return false;
// fail closed: a clock that is not a number never turns the window off
const age = Math.abs(now - Number(timestamp));
if (!Number.isFinite(now) || !(age <= TOLERANCE_SECONDS)) return false;
const signed = Buffer.concat([
Buffer.from(`${id}.${timestamp}.`),
Buffer.from(rawBody),
]);
const received = header
.split(" ")
.filter((part) => part.startsWith("v1,"))
.map((part) => Buffer.from(part.slice(3), "base64"));
return polarKeys(secret).some((key) => {
const expected = createHmac("sha256", key).update(signed).digest();
return received.some(
(sig) => sig.length === expected.length && timingSafeEqual(sig, expected),
);
});
}
Two public test vectors show both paths. The legacy one takes its inputs from Polar's own webhook tests, with the signature computed from them; the standard one is the test vector of the Standard Webhooks specification:
// true: the legacy key
verifyPolarWebhook('{"foo":"bar"}', {
"webhook-id": "msg_1",
"webhook-timestamp": "1790035200",
"webhook-signature": "v1,pv2Zok291y6+jNyFm79yuOY06FPB7SKIaBr2n0SiidA=",
}, "whsec_legacyPolarToken", 1790035200);
// true: the standard key
verifyPolarWebhook('{"test": 2432232314}', {
"webhook-id": "msg_p5jXN8AQM9LWM0D4loKWxJek",
"webhook-timestamp": "1614265330",
"webhook-signature": "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=",
}, "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", 1614265330);
Three details matter. In Node, Buffer.from(text, "base64") skips characters that are not base64 instead of failing, so a secret that only looks like base64 would give a short, weak key; the round-trip check keeps such a key out. A clock that is not a finite number, such as Number(undefined), fails the check instead of switching the time window off. And the signatures are compared with timingSafeEqual, never with ===.
How PayHook reports it
PayHook, the webhook inspector we are building, tries both keys on every Polar event and names the one that matched: ok; Polar legacy HMAC key or ok; Standard Webhooks key. When nothing matches, it lists the keys it tried, such as signature mismatch (check secret); tried Polar legacy HMAC key, Standard Webhooks key, and it never shows the secret. PayHook is in early access; the home page has the details.
Sources
- Polar documentation, Webhook delivery, checked on 25 September 2026.
- polarsource/polar, pull request #14117 (merged on 4 September 2026), pull request #14400 and pull request #14421 (both merged on 11 September 2026), checked on 22 September 2026.
- Polar's signing code, tasks.py and constants.py, checked on 22 September 2026.
- The inputs of the legacy vector: Polar's webhook tests, Apache-2.0.
- The standard vector: the Standard Webhooks test suite.
- The error messages of
standardwebhooks1.1.1 for JavaScript: index.ts, checked on 25 September 2026. @polar-sh/sdkon npm: versions 0.49.0 and 1.0.0-alpha.22 and their tags, checked on 25 September 2026.