Webhook Debugging Guide
Verifying Polar webhooks (Standard Webhooks)
Short answer
Polar follows the Standard Webhooks spec rather than inventing its own scheme. Every request carries three headers:
webhook-id— a unique id for this deliverywebhook-timestamp— Unix seconds when it was signedwebhook-signature— one or morev1,<base64 hmac>values, space-separated
The signed content is {webhook-id}.{webhook-timestamp}.{raw body}, HMAC-SHA256, base64-encoded.
Use Polar's SDK where you can
from polar_sdk.webhooks import validate_event, WebhookVerificationError
try:
event = validate_event(
payload=raw_body,
headers=request.headers,
secret=webhook_secret,
)
except WebhookVerificationError:
return Response(status_code=403)
validateEvent (Node) and validate_event (Python) implement the Standard Webhooks check for you, including the timestamp tolerance, and raise on any mismatch.
The secret format gotcha
Polar webhook secrets created on or after 8 September 2026 are Standard Webhooks secrets, prefixed whsec_, and are used exactly as issued. Secrets created before that date are plain strings and have to be base64-encoded before verification, because that's what the older Polar dashboard generated and what the Standard Webhooks libraries expect as input. In short: if it starts with whsec_, use it as-is; otherwise, base64-encode it first.
const secret = raw.startsWith('whsec_')
? raw
: Buffer.from(raw, 'utf8').toString('base64');
Timestamp tolerance
Like Stripe, Standard Webhooks rejects a webhook-timestamp more than about 5 minutes from the current time. A payload you saved and are POSTing back later for testing will fail this check even with the correct secret — see the replay guide for how to re-sign it.
The easier way: WebhookMon
WebhookMon implements Standard Webhooks verification for Polar directly, handles both secret formats automatically, and states the verdict as a sentence: wrong secret, stale timestamp, or a header it doesn't recognize. Replay re-signs with a current timestamp when you need to test a handler again.