Wiring Polar billing and webhooks without cutting off paying customers
Hard-WonAn eligibility gate the sandbox does not enforce, a signature scheme that deviates from the spec it cites, and the webhook orderings that zero a live subscription.
- Authors
- Leon Mallett, Founder of Captivated Ltd with Claude Code
- Status
- Last confirmed working 18 August 2026 on Polar API as at 2026-08, WebCrypto (workerd) 2026-07
- Written
- 17 July 2026
- Licence
- Handover-1.0
Wiring Polar as a merchant of record into an application, from an integration proved end to end: seat-based subscription, signed webhook, entitlement update.
Polar takes the payment, handles sales tax globally, and pays out. You model products, a customer checks out, and Polar tells your backend what happened by webhook. Your backend never sees a card. Keep your entitlement system separate and let the webhook be the single wire between them.
Before you build: is your product even allowed?
Check this first. It is the only item here that can invalidate the entire integration after it works.
Merchant-of-record platforms are software-first. Polar supports software, SaaS and digital products. Its acceptable-use terms prohibit advertising, sponsorships, and directory or marketplace listing placements. If what you are selling is an ad slot, a featured listing, a directory partnership — anything sold to be seen rather than run as software — it will be refused.
The trap is where that check happens. Eligibility is reviewed at organisation activation, and the sandbox has no such gate. So you can model the products, mint checkouts, receive signed webhooks, wire up entitlements, and prove the whole funnel end to end — then be denied at the point of taking real money.
A product that is really a placement — an advertising slot, a sponsored listing, a directory partnership — will clear the entire sandbox flow and then be refused at activation, on the basis that it is not supported software or a digital product. The integration works perfectly right up to the moment it cannot be used.
Do not appeal by re-describing an advertising product as software. Beyond being untrue, it puts the whole account at risk — including any genuine software products billing through it, which is a far larger loss than the one being appealed.
The same restriction applies across the merchant-of-record category, including Paddle and Lemon Squeezy. For advertising or listing revenue, use a payment processor directly — which permits ad services, at the cost of becoming responsible for your own sales tax, since a processor is not a merchant of record — or simply invoice the customer, which is usually sufficient when such deals are hand-sold in small numbers.
Rule of thumb: a merchant of record sells software you run for the customer, not placement you sell to the customer. Establish which you have before building any of the rail.
The signature scheme deviates from the spec it cites
This is the one that costs hours, and it is worth stating before anything else.
Polar’s documentation says webhooks follow the Standard Webhooks specification.
Under that spec the secret looks like whsec_<base64>, and the HMAC key is the
base64-decoded remainder — drop the prefix, decode the rest, use those bytes.
Polar keys the HMAC on the literal UTF-8 bytes of the entire secret string, prefix included. No stripping, no decoding.
The signing content is per spec: `${webhook-id}.${webhook-timestamp}.${rawBody}`,
HMAC-SHA256, base64-encoded, with the header carrying v1,<signature> — a
space-delimited list during secret rotation.
This was established by brute-forcing every combination of key interpretation and
signing content against a real sandbox delivery. Only UTF-8 bytes of the whole
secret, over {id}.{timestamp}.{body}, reproduced the header.
Why it is a trap rather than merely a bug
A round-trip unit test that signs and verifies with your own helper is self-consistent. It passes with the wrong key interpretation, because both halves are wrong in the same direction. Meanwhile every real delivery returns 401.
You cannot catch this without a genuine signed delivery. So:
- Write a regression test that computes the HMAC independently of your own
sign()helper — ideally from a captured real delivery. - Treat “someone helpfully corrected this to match the spec” as a silent-outage risk, and leave a comment saying so.
Accepting both interpretations is safe, and survives Polar aligning to the spec later:
function secretKeyCandidates(secret: string): Uint8Array[] {
const candidates = [new TextEncoder().encode(secret)]; // what Polar does
if (secret.startsWith('whsec_')) {
try {
candidates.push(Uint8Array.from(atob(secret.slice(6)), (c) => c.charCodeAt(0)));
} catch {}
}
return candidates;
}
// sign: base64(HMAC-SHA256(key, `${id}.${timestamp}.${rawBody}`))
// verify: recompute per candidate, timing-safe compare against each v1,<sig>,
// and reject if |now - timestamp| exceeds ~5 minutes (replay guard).
Both candidates derive from the same secret, so forging still requires an HMAC-SHA256 collision.
Verify the raw body, never a re-serialised object
Parsing and re-serialising changes bytes — key order, whitespace — and the signature will never match. Read the request body as text, verify that, and parse only after the signature checks out.
Sandbox and production are entirely separate
Separate signup, separate accounts, separate tokens, separate everything. A production login does not work in sandbox, and a production token cannot call the sandbox API or vice versa. Create the organisation, products, tokens and webhook endpoints independently in each.
Sandbox accepts the standard test cards, and the full checkout funnel works. Sandbox customer email only reaches members of your organisation, so use a plus-addressed address.
Build and prove in sandbox first, because several product fields cannot be changed afterwards.
Product fields that are locked at creation
Changing any of these means creating a new product:
- Pricing type — fixed, metered, seat-based and so on.
- Billing interval — monthly versus annual. Which means monthly and annual are two separate products. There is no one-product-two-intervals. This has consequences later, in the upgrade race below.
Amounts on a fixed-price product can be edited; the type cannot. Currencies, visibility, benefits, description and trial are all mutable, so do not agonise over those.
Graduated versus volume tiers, and the revenue inversion
- Volume — every seat bills at the rate of the lowest matching tier.
- Graduated — each tier bills only the seats falling within it.
Use graduated for any wide ladder. Volume inverts revenue at every tier boundary. With, say, 200–999 seats at $2.25 and 1,000+ at $1.25, a 600-seat customer pays less by buying 1,000 — $1,250 against $2,248. Half the range becomes a dead zone in which over-buying is the rational move and you absorb the difference.
Graduated is monotonic. Narrow ladders do not expose the problem, but there is no downside to graduated, so default to it.
Annual at ten times the monthly per-seat rate gives a clean sixteen-and-two-thirds percent discount at every scale, with no boundary artefacts.
Prefer private products if you attribute through your own IDs
A public product appears in the storefront and anyone can buy it. A private one is purchasable only through a checkout link you mint.
If your backend maps a payment to an internal entity through an external customer id, that field is only set when checkout originates from your application. A storefront purchase carries no external id, so the webhook cannot attribute it — someone pays and receives nothing.
Choose private, and always mint checkout sessions server-side. Visibility is mutable, so you can open a storefront later and handle orphan attribution then, deliberately.
The seat-assignment privacy trap
Polar has its own seat feature in which the buyer assigns seats to people by email address. Polar’s “seat” is a person.
If your product’s seat is a device, or any opaque unit, do not use assignment — it drags end-user email addresses into your billing system for no benefit. Take only the count and ignore assignment, which is optional. Set a success URL at checkout so the buyer is not pushed into self-assignment.
Organisation settings with dangerous defaults
Grace period for benefit revocation — do not leave this at “immediately”. It controls how long Polar waits before revoking benefits during payment retries. Set to immediate, an expired card instantly fires a revocation webhook, your enforcement zeroes the customer out, and they fix the card ten minutes later to find everything must re-provision. Set seven to fourteen days so dunning can run.
Allow multiple subscriptions — usually off. If your model is one customer, one authoritative count, two concurrent subscriptions make that count ambiguous and the last webhook wins arbitrarily.
Proration — prorating on the next invoice avoids surprise mid-cycle charges when seat counts change.
Webhook semantics that decide whether you cut off paying customers
subscription.updated is the catch-all — active, cancelled, past due, revoked —
and it also fires when the seat count changes. It is usually the event to key
on.
The distinction that matters:
revoked— access has genuinely ended. Zero the entitlement.canceledmust not zero anything. The customer has cancelled but has paid through to period end.revokedfires later, when it actually lapses. Zeroing oncanceledcuts off someone who has paid.past_due— leave the entitlement alone. The grace period and dunning handle it.
A field-name asymmetry that is easy to mis-wire
On checkout creation you pass external_customer_id. On the webhook payload
it returns as customer.external_id. Same concept, two names.
The upgrade race, which follows from monthly and annual being separate products
A customer upgrading briefly holds two subscriptions:
- The new annual subscription activates with its seat count — you set the cap.
- Polar revokes the old monthly subscription — naive code sets the cap to zero.
A paying customer is cut off, purely on webhook ordering.
The fix: store which subscription id currently owns the entitlement. A teardown only applies if it is for that subscription; ignore a revocation of a stale one. This converges under either ordering — if the revoke lands first, the later activation corrects it. Test both orderings, because you will only ever see one of them by accident.
Endpoint immutability and delivery behaviour
A webhook endpoint’s URL cannot be edited. Changing it means creating a new endpoint, which mints a new signing secret — so update your secret store too, and delete the dead endpoint or it accumulates failed retries. Set the format to raw rather than a chat-service preset.
Deliveries retry with backoff, which is genuinely useful: fix a bug and a retry re-hits you within a minute or two without anyone paying again.
Return 2xx to acknowledge. Return 5xx only when you actually want a retry — a downstream you depend on is down. Return 2xx for anything a retry cannot fix, such as an unknown customer or a missing external id, so the retries stop.
Verify the product through the API, not the dashboard
Pricing type and interval are locked forever, so confirm what you actually created rather than what you intended:
curl -sS "https://sandbox-api.polar.sh/v1/products/?limit=10" \
-H "Authorization: Bearer $(<your secret store lookup>)"
Check the amount type, the tier type, the recurring interval, the currency and every tier boundary. Recompute your blended rates from the returned tiers rather than from your intent — the checkout quote is the source of truth, and it matched a hand-computed graduated ladder to the cent.
Note the token is read from a secret store rather than pasted: a billing API token in a transcript or a shell history is a rotation waiting to happen.
Minting a checkout server-side
curl -sS -X POST "https://sandbox-api.polar.sh/v1/checkouts/" \
-H "Authorization: Bearer $(<your secret store lookup>)" \
-H 'Content-Type: application/json' \
-d '{
"products": ["<product-id>"],
"external_customer_id": "<your-internal-id>",
"seats": 50
}'
Only products is required, but you almost always want the other two: the
external id is how the webhook attributes the payment, and without it a purchase
arrives unattributable. The response carries a hosted checkout URL. An unpaid
checkout stays open and reusable, so an interrupted test does not need a fresh
one.
Testing against real signed deliveries
Because the signature scheme cannot be verified against your own signing helper, you need Polar’s servers to reach your local backend. Run the backend locally, expose it through a tunnel, and register that hostname as a webhook endpoint.
Four environment problems will otherwise absorb an afternoon:
- Quick tunnels are ephemeral. Restart the tunnel and the hostname changes — and since a webhook endpoint URL cannot be edited, the registered endpoint is now dead. You need a new endpoint, which mints a new signing secret. For anything you will repeat, use a named tunnel with a stable hostname.
- Tunnel DNS can take around a minute to propagate. Retry before concluding anything is broken.
- macOS negatively-caches DNS. Curl the tunnel hostname before it resolves
and the failure sticks, so curl keeps failing while
digresolves it fine. Resolve explicitly to work around it. This is a local artefact only — Polar resolves independently and is unaffected, so do not go looking for a problem at their end. - macOS has no
setsid. Background long-running processes withnohupinstead.
What a clean integration looks like
Polar (billing) Your backend (entitlement)
─────────────── ──────────────────────────
customer checks out
server-minted checkout,
external_customer_id set,
seats = N
│
│ subscription.created / updated
│ { seats: N, customer.external_id }
▼
verify signature — raw body, literal-secret HMAC
attribute via customer.external_id
record the paid seat count
push it to the licensing system as the cap
return 204, or 5xx only to force a retry
Keep billing and entitlement as separate concerns joined only by this webhook. Polar never learns about your devices or units; your licensing system never learns about price. That separation is what lets either side change without a migration, and it is much harder to introduce later than to start with.
Secrets — the webhook signing secret and API tokens — belong in a secret store and, in production, in the platform’s own secret storage. The operator-side handling of those is a separate concern with its own document.