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 created | HMAC key |
|---|---|
| before 8 Sep 2026 | the UTF-8 bytes of the whole secret string |
| on or after 8 Sep 2026 | Standard 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(), neverrequest.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.updatedfrom 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_refundedonly grows. So the signed order in anorder.paidororder.refundedpayload is written as delivered, with no extra call, and a latepaidafter 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.