Field notes

Seven ways a license server quietly cuts off paying customers

A license server fails in one direction that nobody notices and one that costs you the customer. The dangerous direction is the quiet one: access is taken away from someone who paid, their agent tells them to go buy the thing they already bought, and you find out weeks later or never. Every rule below exists because it happened.

7 August 2026 · Povilas Konopackas · billing for asc-mcp, on Polar, Cloudflare Workers and D1

I sell an MCP server. It is $9 a month, it has a seven-day trial you start from inside your coding agent, and the licensing runs on a Cloudflare Worker with a D1 table of about eight rows. That is the entire system. It is as small as a billing integration gets, and it has still found seven distinct ways to revoke access from people who were paying.

The decision logic now lives in one file, license-worker/src/logic.ts, and every branch in it carries a comment saying which incident put it there. What follows is that file in prose. If you are about to charge for a piece of software, you will meet most of these.

One

Cancel is not revoke

Cost: customers lost time they had already paid for, the moment they turned off renewal.

Polar emits subscription.canceled when a customer switches off auto-renewal. It does not mean their access ends. They have paid through the end of the current period and they are entitled to every day of it. Only subscription.revoked means access ends now.

The naive handler treats both as "deactivate this license", because both read as bad news. So the customer cancels on day 3 of a 30-day period, and the software stops working on day 3. They did not ask for that, they paid for 30 days, and the message they see tells them to subscribe.

// Both are "cancel-shaped" events. Only one ends access.
CANCEL_EVENTS         = { subscription.canceled, subscription.revoked }
IMMEDIATE_REVOKE      = { subscription.revoked }
// canceled: stamp canceled_at, leave the license usable until expires_at.
Rule

Model the two separately from the start. One marks intent not to renew, the other ends entitlement. Natural expiry does the rest.

Two

The same bug walks back in through a different door

Cost: fixed once in v1.8.2, reintroduced through status handling, caught by reproducing a real cancellation in sandbox against the live worker.

Having fixed the event path, there is a second path to the same row. A subscription.updated event carries a status field, and the handler decides activation from it. The obvious dead-status list reads revoked, unpaid, incomplete_expired, and it is very tempting to add canceled.

Adding it recreates incident one exactly. Polar sets status: canceled the moment renewal is switched off, while current_period_end is still in the future and the customer is still entitled. So the fix on the event path was undone by the status path, for the same customers, with the same symptom.

Rule

When you fix a rule about entitlement, find every code path that writes the same column. A bug fixed on one path is not fixed.

Three

Two secrets that look alike and hash differently

Cost: zero license rows were ever created. The first paying customer got no key at all.

Polar signs webhooks with the Standard Webhooks scheme: HMAC-SHA256 over id.timestamp.body. The trap is the key.

  • A secret copied from the Polar dashboard looks like polar_whs_..., and the HMAC key is the raw UTF-8 bytes of the whole string, prefix included. Polar's own validator base64-encodes the secret before handing it to the Standard Webhooks library, which decodes it straight back.
  • A secret minted through POST /v1/webhooks/endpoints comes back as whsec_<base64>, the canonical shape, where the key is the base64-decoded remainder.

Both are valid. Both are handed to you by the same vendor. Pick the wrong one and every signature fails, every webhook is rejected with a 401 that only your logs see, and the first sale produces nothing. Polar retries, the retries also fail, and the customer emails you.

// Two extra HMACs on a request you were going to reject anyway.
candidateKeys(secret) =
  [ utf8(secret) ]                       // dashboard shape
  + [ base64decode(after first "_") ]    // API shape, when it decodes
Rule

Where a credential has two encodings in the wild, verify against both. The cost is microseconds; the cost of choosing wrong is your first customer.

Four

Every event goes to every endpoint

Cost: one test purchase in a sibling project minted three stray licenses and emailed welcome messages, with keys, to people who had bought something else.

Polar delivers each event to every webhook endpoint registered in the organization. If you sell two products from one org, your license server sees the other product's sales and, unguarded, provisions for them. That is not a theoretical leak. It is a stranger receiving an email containing a working license key for software they never heard of.

The guard is a product id allowlist. The subtlety is reading the id: it has arrived as product_id, as productId, and nested under product.id. A guard that checks one shape returns null on the others, and a null id falls through to "provision it", which is exactly the behaviour you were guarding against.

resolveProductId(data) =
  data.product_id ?? data.productId ?? data.product?.id ?? null

There is a deliberate hole in ours: an event with no product id at all is treated as ours. Legacy subscriptions still arrive in a payload shape that omits it, and a false negative here costs a paying customer their key. That asymmetry is the whole design principle of the file.

Rule

Give the paid product its own organization if the processor scopes webhooks by org. Then keep the guard anyway.

Five

Grace for renewals, never for trials

Cost: a four-day grace applied to everything turned every seven-day trial into an eleven-day one, and gave every cancelling customer four free days.

Renewals reach you as a webhook that pushes expires_at forward. If that delivery is late or dropped, the old expiry is already in the past, and a customer who paid this morning is demoted to the free tier this afternoon. A few days of grace after expiry costs nothing and prevents a support ticket, or a cancellation, caused entirely by your own plumbing.

Then two exclusions, both learned after the fact:

  • Trials get no grace. A trial has no renewal, so there is no webhook that can be late. Grace here is just a longer trial that you did not decide to offer.
  • Cancelled subscriptions get no grace. Renewal is switched off, so again there is no webhook coming. The customer keeps exactly the time they paid for, which is the correct answer in both directions.
if (!active)                 -> unusable
if (expiry >= now)           -> usable
if (source === "trial")      -> unusable   // nothing to be late
if (canceled_at)             -> unusable   // no renewal coming
if (now <= expiry + 4 days)  -> usable, in grace
                             -> expired
Rule

Grace compensates for a specific missing message. Apply it only where that message exists.

Six

Retries arrive out of order and resurrect the dead

Cost: found by an audit that ran the billing lifecycle rather than the trial path. Revoked, non-paying users got a working key back.

Cancelling in Polar emits canceled and revoked back to back. Webhook delivery is at-least-once and not ordered, so an ordinary retry of the earlier subscription.updated, still carrying the pre-cancellation state, lands after revoked has switched the row off and writes active = 1 straight back. No attacker involved. Just a retry.

Status alone cannot fix this, because the retried event's status is genuinely live: it was live when the event was generated. Two things do fix it.

  • Stamp revoked_at when revocation happens and guard the upsert with WHERE revoked_at IS NULL. Revocation is terminal.
  • Refuse to activate when the paid period has already ended, whatever the status says. That also stops a cycled or updated replayed long after a subscription lapsed.
shouldBeActive(status, currentPeriodEnd, now):
  if (deadStatus(status))        return 0
  if (!currentPeriodEnd)         return 1
  return currentPeriodEnd > now ? 1 : 0
Rule

Treat webhooks as unordered. Any state a later event can undo needs a terminal marker, not a status check.

Seven

The trial key that outlives the trial

Cost: the exact shape of "I paid and it does not work", arriving on day 8, from someone who paid on day 3.

Anyone who converts mid-trial holds two rows: the trial they started and the subscription they bought. Every lookup that returns "their key" now has to choose, and newest-first is wrong. It hands a paying customer their dead trial key, which validates as free tier and looks exactly like a broken product.

Worse, the trial key is already sitting in their config file. It keeps working until day 8, so conversion is a trap with a delayed trigger: they pay, nothing changes, everything looks fine, and a week later their agent tells them to subscribe.

pickLookupRow(rows, now):
  usable = rows.filter(isUsable)
  return usable.find(r => r.source !== "trial") ?? usable[0] ?? null

The fix is two-sided. Lookups prefer the paid row, and the trial endpoint checks for a paid row first and hands back the subscription key so it replaces the trial key in place. Matched on the machine fingerprint alone, deliberately: requiring the same email on both rows stranded everyone who trialled with a personal address and checked out with a work one.

Rule

One customer, two entitlement rows, is the normal case, not the edge case. Decide which one wins before you ship the trial.

What this adds up to

None of these are hard problems. Every one of them is a two-line fix. What makes them expensive is that they all fail silently in the direction of the customer, and none of them show up in a test you would think to write, because the input that triggers them is a webhook from a vendor behaving exactly as documented.

The version of this you write on day one is the naive version, and the naive version quietly takes access away from people who paid. You will not find out for weeks. That is the actual lesson, and it is why the file is 428 lines: 224 of code, and 164 of comments explaining which incident put each branch there.

There are now 180 unit tests, 39 HTTP checks against a database migrated from the live table shape, 21 checks driving the built server over stdio, and 16 lifecycle checks pushing real signed webhooks through a local worker. Almost all of them were written after an incident, not before one.

Questions people actually type

Does subscription.canceled mean access ends immediately in Polar?

No. It means the subscription will not renew. The customer keeps access until the period they already paid for ends. Only subscription.revoked ends access immediately. Treating canceled as revocation takes away time the customer has already paid for, which is the one thing a license server must not do.

Why is my Polar webhook signature always invalid?

Because the HMAC key depends on where the secret came from. A dashboard secret is polar_whs_... and the key is the raw UTF-8 of the whole string. An API-minted secret is whsec_<base64> and the key is the base64-decoded remainder. Verify against both candidates and the failure mode disappears for two extra HMAC operations.

Why does my Polar webhook fire for products I do not sell?

Polar delivers every event to every endpoint registered in the organization. Without a product id guard, another product's purchase provisions a license in your app and emails a key to its buyer. Read the product id from all the shapes it arrives in, because a guard that finds nothing usually falls through to provisioning.

Should a license server give a grace period after expiry?

Yes for paid subscriptions, so a late or dropped renewal webhook cannot demote someone mid-session. No for trials and no for already-cancelled subscriptions, where no renewal message is coming at all. Without those exclusions a four-day grace silently extends every trial and gives every cancellation four free days.

Two things, if this was useful

The product this paid for is asc-mcp: 41 tools that drive App Store Connect from your coding agent, with a seven-day trial you start from inside the agent with no card. Six tools work without a license, three of those without any Apple credentials at all.

And if you sell an MCP server yourself: the parts above that are not App Store specific are the license lifecycle, and I have been asked whether they should be a package. If you want that, say so on the repo. I would rather build it for someone specific than guess.