Skip to content

Polar webhooks arrive twice and sign two ways, make the handler survive both

At-least-once delivery means duplicates and out-of-order events are normal traffic, and Polar changed its signing key format in September 2026. Verify with both keys, claim the webhook-id before any write, re-read subscriptions and let orders only move forward.

Polar4 min readships at docs/solutions/polar/webhook-idempotency.md

Tags: polar · webhooks · idempotency · standard-webhooks · signatures · reliability · postgres

A customer subscribes once and gets two receipts. Or a refunded lifetime deal unlocks again an hour later. Or every delivery to a brand new endpoint fails with "Invalid signature" while the code has not changed.

All three are ordinary Polar traffic handled carelessly. Polar delivers at least once, retries up to ten times with backoff, gives each attempt 10 seconds, and makes no promise about order.

1. Verify with the right key

Polar signs with Standard Webhooks headers: webhook-id, webhook-timestamp and webhook-signature (v1,<base64 HMAC-SHA256> of <id>.<timestamp>.<raw body>). What changed is the key:

Secret createdHMAC key
before 8 Sep 2026the UTF-8 bytes of the whole secret string
on or after 8 Sep 2026Standard Webhooks: the base64 after whsec_, decoded

validateEvent in @polar-sh/sdk 0.49 (the current stable release) only does the first, so it rejects every delivery to an endpoint created today. Polar's 1.0 SDKs, still in alpha, try both keys. This repo does the same in src/lib/billing/polar-webhooks.ts:

export function polarSigningKeys(secret: string): Buffer[] {
  const utf8 = Buffer.from(secret, "utf8");
  const keys = [utf8];
  const rest = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret;
  if (/^[A-Za-z0-9+/]+={0,2}$/.test(rest)) {
    const decoded = Buffer.from(rest, "base64");
    if (decoded.length > 0 && !decoded.equals(utf8)) keys.push(decoded);
  }
  return keys;
}

Then it compares every v1, signature in the header against every key with timingSafeEqual, and refuses a timestamp more than five minutes from now, which stops a captured delivery being replayed next week. Trying two keys is safe: each is a full secret, and a forger needs one of them.

Two rules that break verification when ignored:

  • Verify the raw bytes. request.text(), never request.json(). Parsing and re-serialising changes key order and whitespace.
  • Use the Raw endpoint format. Discord and Slack formats sign a chat message; the signature checks out and the body has no event in it.

2. Claim the webhook-id before doing anything

webhook-id is the same on every retry of one delivery, which is exactly what "the same event" means. The shared pipeline inserts polar:<webhook-id> into billing_processed_events first:

insert into billing_processed_events (id, type) values ($1, $2)
on conflict (id) do nothing returning id;
  • No row back: another process has it, or had it. Answer 200 duplicate.
  • Row back: this process owns the delivery. Translate, write, send mail.
  • The handler throws: delete the claim and answer 500, so Polar's retry runs the work again instead of meeting a claim and stopping.
  • It succeeds: stamp completed_at.

A claim that never completes is a handler that died after the insert. Polar got no 2xx, so it retries, and a retry more than 15 minutes after the claim takes it over and redoes the work (claimEvent in the store). A row still stuck after that got no retry that late: bring its work back with billing:reconcile. billing:prune-events reports those rows; do not delete them to "clean up".

3. Decide what to trust in the payload

The claim stops the same delivery twice. It does nothing about two different deliveries arriving in the wrong order. Here the answer depends on the object:

  • Subscriptions go back and forth (active, canceled, uncanceled, past due, revoked). A subscription.updated from before a cancellation can land after it. So the adapter takes only the id from the payload and fetches the subscription from Polar, then writes what Polar says now. Replaying any subscription event, or looping over every subscription after an outage, converges on the same row.
  • Orders only move forward. Paid, then maybe partially refunded, then maybe refunded. The purchase upsert refuses to go back down that ladder, and amount_refunded only grows. So the signed order in an order.paid or order.refunded payload is written as delivered, with no extra call, and a late paid after a refund changes nothing.

4. Keep side effects inside the claim

Receipts are returned as events and sent by the pipeline inside the claim, with an idempotency key of receipt:<order id> at the email provider as a second guard. A mail failure is logged, never rethrown: the payment is already recorded, and a 500 now would redeliver it and mail the customer twice once mail recovers.

Test it

bun run billing:test-webhook -- --user <user id>                  # processed
bun run billing:test-webhook -- --user <user id> --order <id>     # duplicate

The second call reuses the first delivery's webhook-id. Expect {"received":true,"outcome":"duplicate"}, no second row and no second receipt. With real sandbox traffic, redeliver from the endpoint's log for the same result.