# 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.

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`:

```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:

```sql
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

```bash
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
