Back to Blog
Security

Polar Now Signs Webhooks Two Ways, and Your Secret's Creation Date Decides Which

Since September 8, 2026, a regenerated Polar webhook secret signs with Standard Webhooks while older secrets keep the legacy HMAC key. Same header, same whsec_ prefix, different key bytes. How a routine rotation breaks verification, and a verifier that survives it.

WebhookVault Team · Webhook Infrastructure Experts10 min read
Close-up of a dark monitor showing colour-highlighted SVG and HTML markup at an angle, the lines to the right fading out of focus

A routine secret rotation that fails every delivery

Someone on your team regenerates the Polar webhook secret. They paste the new value into your secret manager, restart the service and go to lunch. From that moment every incoming webhook fails signature verification. Your handler returns 401. Polar retries. Your code didn't change, the payload didn't change, and the header you verify is still called webhook-signature.

The secret changed meaning. On September 8, 2026, Polar switched new and regenerated webhook secrets to Standard Webhooks signing. Secrets created before that moment keep the old scheme. Both look identical: a string that starts with whsec_. Your handler can't tell them apart by looking. And if it follows the instructions Polar published for the old scheme, it rejects everything signed under the new one.

There's no new header here, no deprecation email to grep for, no deadline. Just a key derivation that now depends on when somebody last clicked a button.

What Polar changed on September 8

Polar's webhook docs now describe two schemes, split by the secret's generation time. Secrets generated before September 8, 2026, 00:00 UTC use what Polar calls Polar HMAC: the key is the UTF-8 bytes of the full whsec_… string, and the docs tell you to base64-encode that string before handing it to a Standard Webhooks library. Secrets generated on or after the cutoff use Standard Webhooks proper. You pass the whsec_… secret to the library as-is.

The changelog entry, merged into Polar's docs on September 25 in pull request #14868, adds the detail operators need. To adopt the new scheme you regenerate the endpoint's secret. Regenerations before the cutoff kept legacy signing. Regenerations on or after it get Standard Webhooks. There is no required migration deadline, and Polar says it will keep supporting the legacy approach for as long as necessary.

That sounds kind. It's also why this will bite people for years: an untouched endpoint keeps working until the day anyone rotates its secret.

Polar's own SDKs handle it: versions 1.0.0-alpha.19 and later try both keys. Use the SDK, keep it current, and you're fine. Verify by hand, pin an older SDK, or wrap the Standard Webhooks library yourself following the old docs, and you're exposed.

Two keys hiding behind one whsec_ string

Look at what a Standard Webhooks library does with a secret. The spec defines it as the prefix whsec_ followed by the base64 encoding of the raw key bytes. The library strips the prefix and base64-decodes the rest, and those decoded bytes are the HMAC-SHA256 key. The signed content is the message id, the timestamp and the raw body joined with dots. The signature goes into the webhook-signature header as v1, followed by the base64 digest, and several signatures can sit in that header, separated by spaces.

Under the legacy scheme, Polar used the whole string as the key, prefix included. Literally the characters w, h, s, e, c, _ and onwards, as UTF-8 bytes.

So one secret value gives you two candidate keys:

  • Strip whsec_, base64-decode the remainder. That's the Standard Webhooks key.
  • Take the full string as UTF-8. That's the Polar legacy key.

They are unrelated byte sequences. An HMAC from one will never match an HMAC from the other. And nothing in the request says which one the sender used. Same header name, same v1, tag, same payload.

Why the base64 step existed at all

The old instruction to base64-encode the secret first looks bizarre until you trace it. Base64-encode the full string whsec_abc… and the result no longer starts with whsec_. The library finds no prefix to strip, base64-decodes the whole thing, and gets back exactly the UTF-8 bytes of the original string. You've tricked a spec-compliant library into using the raw string as the key.

Clever, for a sender that called itself Standard Webhooks while deriving its key differently. It's also the kind of trick that gets copied into a handler once, with a comment like "Polar needs this", and never questioned again. That line is now a landmine. It's correct for every secret generated before September 8 and wrong for every secret generated after.

The fix and the workaround look like opposite bugs. Read the current docs, remove the encoding step, and you break every endpoint still on a legacy secret. Keep it, and you break every endpoint rotated after the cutoff. Run production and staging as separate endpoints and you can have both kinds at once.

The failure runs in both directions

We covered a hard cutover in /blog/webhook-signature-scheme-cutover, where HighLevel moved from an RSA header to an Ed25519 header on a fixed date. Painful, but legible. A new header appeared, an old one stopped arriving, and one look at a raw request told you what happened.

Polar's split is worse because it works per secret. Two endpoints on the same account, verified by the same code, can need different key derivations indefinitely. The switch is triggered by something your own team does, often for security reasons, often by someone who doesn't own the webhook handler.

Think about who can regenerate a secret in your organisation. Dashboard admins. The person handling a suspected leak with a runbook like the one in /blog/webhook-secret-rotation-compromise. A script that rotates credentials on a schedule. Any of them can flip an endpoint from one scheme to the other without touching code, and each will reasonably believe they changed nothing.

Ten failures and the endpoint goes dark

To the sender, a verification failure looks like any other failure. Your handler returns 401 or 403, Polar counts it as an error and retries. Polar's docs say it retries up to 10 times with exponential backoff, times out requests after 10 seconds, and automatically disables an endpoint after 10 consecutive failed deliveries.

Retrying is pointless here. The signature is wrong on attempt ten for the same reason it was wrong on attempt one. The sender can't know that, so retries burn through their budget while your logs fill with identical rejections. Then the endpoint is switched off and you're in the situation from /blog/webhook-endpoint-disabled-sustained-failures: no deliveries, no errors, nothing to alert on, until a customer asks why their subscription never activated.

Ten consecutive failures isn't much. On a busy account you can hit it within minutes of a rotation. Recovering them means going to the provider's event log, the backstop we argued for in /blog/webhook-events-api-pull-backstop.

Verify against both derivations, on purpose

Do what Polar's SDK does: try both keys. Do it deliberately, in your own verifier, and record which one matched. A minimal version in TypeScript with only Node's crypto module:

import { createHmac, timingSafeEqual } from "node:crypto";

type Derivation = "standard" | "polar-legacy";

function candidateKeys(secret: string): Array<[Derivation, Buffer]> {
  const keys: Array<[Derivation, Buffer]> = [];
  if (secret.startsWith("whsec_")) {
    // Standard Webhooks: strip the prefix, base64-decode the rest.
    keys.push(["standard", Buffer.from(secret.slice(6), "base64")]);
  }
  // Polar legacy: the full string, prefix included, as UTF-8 bytes.
  keys.push(["polar-legacy", Buffer.from(secret, "utf8")]);
  return keys;
}

export function verifyPolarWebhook(
  rawBody: string,
  headers: Record<string, string | undefined>,
  secret: string,
  toleranceSeconds = 300,
): Derivation {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  const sigHeader = headers["webhook-signature"];
  if (!id || !ts || !sigHeader) throw new Error("missing signature headers");

  const age = Math.abs(Date.now() / 1000 - Number(ts));
  if (!Number.isFinite(age) || age > toleranceSeconds) {
    throw new Error("timestamp outside tolerance");
  }

  const signedContent = `${id}.${ts}.${rawBody}`;
  const presented = sigHeader
    .split(" ")
    .filter((part) => part.startsWith("v1,"))
    .map((part) => Buffer.from(part.slice(3), "base64"));

  for (const [derivation, key] of candidateKeys(secret)) {
    const expected = createHmac("sha256", key).update(signedContent).digest();
    const match = presented.some(
      (sig) => sig.length === expected.length && timingSafeEqual(sig, expected),
    );
    if (match) return derivation;
  }
  throw new Error("signature did not match any key derivation");
}

The body has to be the raw bytes as received, not re-serialised JSON. The timestamp check runs before any HMAC work, so a replayed request fails fast whichever key it would have matched. The comparison is timing-safe and length-checked. And the function returns the derivation that matched instead of a bare boolean. You'll want that.

Does accepting two keys weaken anything? Not in a way that matters. Both keys come from the same secret, and an attacker without that secret can't produce a valid HMAC under either. You're accepting two readings of one credential. No second credential gets added.

Record which derivation matched

Trying both keys keeps deliveries flowing. Logging the winner tells you where you stand. Emit a metric or a structured log field per endpoint: signing_derivation=standard or signing_derivation=polar-legacy. Now you can answer questions that used to be guesswork. Which endpoints are still on legacy secrets? Did last night's rotation actually move production? Did staging and production drift apart?

You also get a clean alert. If the derivation changes for an endpoint nobody planned to rotate, someone regenerated a secret outside your change process.

Track verification failures as their own signal too. A rising rate of bad signatures is a different problem from a rising rate of 500s, and it needs a different responder. Lump both into "webhook errors" and a rotation mistake hides in ordinary noise until the endpoint is already disabled.

Rotation is a deploy, not a settings change

Regenerating a webhook secret feels like configuration. New value in, old value out, no code. With Polar it can also change the algorithm your handler has to run. So treat it like a deploy.

Before rotating, confirm the verifier in production handles both derivations. Write the rotation date next to the secret in your secret manager, because the generation time is now the only thing that decides the scheme. If the provider gives you no overlap window where old and new secrets both work, let your handler accept a list of secrets during the switch and try every combination of secret and derivation. The loop above extends to that easily.

And bring the person who owns the webhook handler into the rotation. A runbook that lets someone rotate a signing secret without telling the team that verifies signatures is the bug.

The same prefix means different things at different providers

Polar isn't unique here, which is why the lesson travels. The whsec_ prefix is common, and it doesn't mean the same thing everywhere. Stripe's endpoint secrets start with whsec_ too, and Stripe's docs say to use the endpoint's signing secret as the HMAC key: the full string. Standard Webhooks libraries, and providers built on them, strip the prefix and base64-decode. One prefix, two different sets of key bytes.

If one shared helper verifies signatures for several providers, check what it does with the secret. A helper written against Stripe computes the wrong key for a Standard Webhooks sender, and the other way round. Test any new provider against a real signed request before shipping. A sample from the docs doesn't count.

What exactly the key bytes are is the least documented part of most signing schemes. Header names get a table. Signed content gets a diagram. Key derivation gets one sentence, and that sentence is where the bugs live. We wrote about the wider move toward the Standard Webhooks format in /blog/standard-webhooks-asymmetric-signature-verification. This is the unglamorous side of it: the path from almost-standard to actually-standard runs through your secret.

When to delete the fallback

Accepting two derivations is a bridge. Once your metrics show every endpoint matching only the standard derivation for a sustained stretch, and every secret in your secret manager is dated after the September 8 cutoff, remove the legacy branch. Code that quietly tolerates two schemes has a habit of tolerating a third nobody intended.

The legacy scheme has no deadline. Nothing will ever move your endpoints off it except you.

Frequently asked questions

How do I tell whether my Polar secret uses the legacy scheme or Standard Webhooks? You can't tell from the string, since both formats start with whsec_. What decides it is when the secret was generated: before September 8, 2026, 00:00 UTC means legacy, on or after means Standard Webhooks. If you don't know the generation date, verify a real delivery against both derivations and see which one matches.

Will Polar force legacy endpoints to migrate? Not according to its current docs. Polar says there is no required migration deadline and that it will keep supporting legacy signing for as long as necessary. An existing endpoint only moves to Standard Webhooks signing when its secret is regenerated.

Is upgrading the Polar SDK enough to fix this? If you verify through the official SDK, yes: versions 1.0.0-alpha.19 and later try both keys. It doesn't help if you verify with a generic Standard Webhooks library, a shared helper or hand-written HMAC code. Those need the dual-derivation logic added explicitly.

What should I do if the endpoint was already disabled after a bad rotation? Fix the verifier first so new deliveries succeed, and only then re-enable the endpoint. Then reconcile the events you missed against Polar's API instead of assuming they'll arrive again, because you shouldn't count on deliveries that used up their retries during the outage being resent.

Related posts