PayHook

Polar webhook signature: the September 2026 key change and legacy secrets

Why @polar-sh/sdk can answer "No matching signature found" for a Polar webhook secret issued after 8 September 2026, which key your secret uses, and how to verify both.

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:

MessageWhat it means
Missing required headerswebhook-id, webhook-timestamp or webhook-signature did not reach your code
Invalid Signature Headerswebhook-timestamp is not a number
Message timestamp too old or Message timestamp too newthe timestamp is more than 5 minutes away from your clock
No matching signature foundno 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:

AspectPolar HMAC (legacy)Standard Webhooks
Which secretsgenerated by Polar before the cutoff, and every secret you set yourselfgenerated by Polar on or after the cutoff
Keythe UTF-8 bytes of the whole secret, whsec_ includedthe part after whsec_, base64-decoded
Signed text<webhook-id>.<webhook-timestamp>.<raw body>the same
SignatureHMAC-SHA256 in base64, sent as v1,<base64> in webhook-signaturethe 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 secret0.49.0 (latest)1.0.0-alpha.22 (next)
Generated by Polar before 8 September 2026passes: legacy keypasses
Set by you, any datepasses: legacy keypasses
Generated by Polar on or after 8 September 2026fails: No matching signature foundpasses: 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

Back to the blog