Summary
Webhook deliveries fail HMAC signature verification for any consumer that implements the Standard Webhooks spec's key-derivation step exactly (strip whsec_ prefix, base64-decode the remainder to get the raw HMAC key). This is not sandbox-specific and not caused by secret rotation or stale copies; it reproduces on every single delivery.
Root cause
In _webhook_event_send (server/polar/webhook/tasks.py):
b64secret = base64.b64encode(event.webhook_endpoint.secret.encode("utf-8")).decode("utf-8")
wh = StandardWebhook(b64secret)
signature = wh.sign(str(event.id), ts, event.payload)
The full literal secret (including the whsec_/polar_whs_ prefix) is base64-encoded before being handed to the standardwebhooks library. That library's constructor checks secret.startswith("whsec_") to decide whether to strip a prefix, then always base64.b64decode()s the result to get the raw key. Because we pre-encode the whole secret, the prefix check never matches, and the subsequent decode simply recovers the original literal bytes.
Net effect: the actual HMAC key used to sign deliveries is the literal UTF-8 bytes of the full secret string, prefix included (e.g. whsec_abc123...), not base64decode(secret[len("whsec_"):]) as the Standard Webhooks spec (and our own whsec_ prefix, added in #12712 explicitly for spec alignment) implies.
Our secrets are also not actual base64-encoded key material to begin with (generate_token() in server/polar/kit/crypto.py produces a plain alphanumeric token + checksum suffix), so a spec-compliant base64.b64decode() of the remainder produces a completely different, incorrect key.
Our own SDKs (sdk/python/polar/webhooks.py, sdk/typescript/src/webhooks.ts, added in #13256) happen to work because they use the literal secret bytes directly, with no prefix-stripping or decoding, matching the server's actual (accidental) behavior. Any generic Standard Webhooks-compliant library (svix, standardwebhooks, or a hand-rolled implementation per our own docs) will compute a different key and fail verification on 100% of deliveries.
Reproduction data (from a customer report, sandbox org, format=Raw/JSON)
webhook-id: 90769209-241e-4b4f-994f-6ea65d230f58
webhook-timestamp: 1785577257
body length: 4982 bytes
body SHA-256: cc09b4010eb860f4a3f3b56d4876e6efbbb74d5c3fc946e3f2940d2126eb1f0d
signature computed per spec (strip prefix, b64-decode remainder as key): SqSkePlSookG3PE0x4Eo4rgyrX7PFs4dSMG+YQ0OVcA=
signature we actually sent: v1,42cJwYRO5PTc/rcUaXTcsxYSBqc94pvoPoj2miSnk/0=
All other candidate explanations were ruled out: single endpoint (no secret ambiguity), secret re-copied fresh from settings after re-enable, decodes to a well-formed 32-byte value, dozens of distinct events across multiple types all fail identically. This matches the code-path analysis above, not a sandbox quirk, stale-secret, or disable/re-enable rotation issue (confirmed re-enabling an auto-disabled endpoint does not rotate the secret).
Suggested fix
Sign with the raw literal secret bytes directly (no base64.b64encode before constructing StandardWebhook), so the library's own prefix-strip + base64-decode logic operates on the real secret as intended, OR document clearly that Polar's webhook-signature key derivation is "use the literal secret string bytes" rather than the spec's "strip prefix, base64-decode remainder," and update docs/SDKs accordingly. The former is preferable since it's what the whsec_ prefix (added for spec alignment in #12712) is supposed to signal to integrators using generic Standard Webhooks tooling.
Impact
Any merchant/integrator verifying webhooks with a generic Standard Webhooks-compliant library instead of Polar's own SDK will see 100% of deliveries fail signature verification, in both sandbox and production environments.
Sent by @allison-polar from Sandbox webhook HMAC signature failure investigation.
Summary
Webhook deliveries fail HMAC signature verification for any consumer that implements the Standard Webhooks spec's key-derivation step exactly (strip
whsec_prefix, base64-decode the remainder to get the raw HMAC key). This is not sandbox-specific and not caused by secret rotation or stale copies; it reproduces on every single delivery.Root cause
In
_webhook_event_send(server/polar/webhook/tasks.py):The full literal secret (including the
whsec_/polar_whs_prefix) is base64-encoded before being handed to thestandardwebhookslibrary. That library's constructor checkssecret.startswith("whsec_")to decide whether to strip a prefix, then alwaysbase64.b64decode()s the result to get the raw key. Because we pre-encode the whole secret, the prefix check never matches, and the subsequent decode simply recovers the original literal bytes.Net effect: the actual HMAC key used to sign deliveries is the literal UTF-8 bytes of the full secret string, prefix included (e.g.
whsec_abc123...), notbase64decode(secret[len("whsec_"):])as the Standard Webhooks spec (and our ownwhsec_prefix, added in #12712 explicitly for spec alignment) implies.Our secrets are also not actual base64-encoded key material to begin with (
generate_token()inserver/polar/kit/crypto.pyproduces a plain alphanumeric token + checksum suffix), so a spec-compliantbase64.b64decode()of the remainder produces a completely different, incorrect key.Our own SDKs (
sdk/python/polar/webhooks.py,sdk/typescript/src/webhooks.ts, added in #13256) happen to work because they use the literal secret bytes directly, with no prefix-stripping or decoding, matching the server's actual (accidental) behavior. Any generic Standard Webhooks-compliant library (svix,standardwebhooks, or a hand-rolled implementation per our own docs) will compute a different key and fail verification on 100% of deliveries.Reproduction data (from a customer report, sandbox org, format=Raw/JSON)
All other candidate explanations were ruled out: single endpoint (no secret ambiguity), secret re-copied fresh from settings after re-enable, decodes to a well-formed 32-byte value, dozens of distinct events across multiple types all fail identically. This matches the code-path analysis above, not a sandbox quirk, stale-secret, or disable/re-enable rotation issue (confirmed re-enabling an auto-disabled endpoint does not rotate the secret).
Suggested fix
Sign with the raw literal secret bytes directly (no
base64.b64encodebefore constructingStandardWebhook), so the library's own prefix-strip + base64-decode logic operates on the real secret as intended, OR document clearly that Polar'swebhook-signaturekey derivation is "use the literal secret string bytes" rather than the spec's "strip prefix, base64-decode remainder," and update docs/SDKs accordingly. The former is preferable since it's what thewhsec_prefix (added for spec alignment in #12712) is supposed to signal to integrators using generic Standard Webhooks tooling.Impact
Any merchant/integrator verifying webhooks with a generic Standard Webhooks-compliant library instead of Polar's own SDK will see 100% of deliveries fail signature verification, in both sandbox and production environments.
Sent by @allison-polar from Sandbox webhook HMAC signature failure investigation.