An entitlement is a key your code gates on. A product is a thing somebody buys. They are not the same, and treating them as the same is the most expensive mistake in this part of the kit.
app.get("/reports", requireAuth(), requireEntitlement("pro"), handler);pro_monthly and pro_annual are two products, listed in several stores’ catalogs under different SKUs. Between them they grant one entitlement: pro.
Nothing outside the catalog ever names a SKU. Add an annual plan, launch on another store, rename a Play product id — that gate does not change, and neither does anything that reads it.
The gate lives in core, not in payments
A product is not an entitlement is the distinction this rests on, payments is what grants one, and one entitlement across mobile and web is why the gate never names a rail.
requireEntitlement is in @pithy-sh/core, beside requireAuth, and for the same reason: a route should be able to say what it requires without importing the capability that provides it.
So the entitlement resolver is a seam. With payments composed, it resolves from your rows. With nothing composed, it denies — a gate with no provider fails closed rather than standing open.
That failure mode creates one problem worth knowing about: a Worker whose routes gate on entitlements while composing no provider is not broken, it is silently paywalled shut, and the runtime cannot tell that mistake from a legitimately unentitled user. So the CLI answers it instead — pithy doctor and pithy dev compare the gates in your source against what the Worker composes, and say so at startup rather than leaving you a fleet of production 403s.
Where an entitlement is read from
A materialized row in your D1, per request. No KV cache, no token claims.
That is the decision that makes a revocation immediate: the truth has one home, and reading it is one indexed lookup rather than a cache with an invalidation story.
An entitlement in a token claim would be an entitlement that stays true for the life of the token, which is exactly wrong for the case that matters — somebody’s card failed and you would like access to stop.
The stored flag is an optimization; the timestamp is the truth
The read applies the expiry again.
A subscription can lapse with no notification arriving at all, because the store simply stops renewing. So a row saying active with an expiry in the past does not grant — and it does not need a write to stop granting.
A read never writes. Repairing a stale row is the reconciliation Workflow’s job, and the hot path stays one lookup.
Rows are derived, never written independently
The entitlement rows are re-derived in the same batch as the purchase write, from the purchases table itself rather than from a value the writer computed.
So there is no window in which a purchase is stored and its entitlement is not.
Editing the catalog changes what a row should say
This is the subtle one, and it is worth reading twice.
The derivation reads the catalog. So dropping a key from a product’s entitlement list and shipping means every subject holding that key holds it with nothing behind it — no purchase for it will ever arrive again, the read path only lapses a row carrying a dated expiry, and reconciliation re-asks about subscriptions rather than about keys.
Nothing would repair it.
So the projection re-derives more than the keys the event’s own product grants: on its next purchase event, whatever that event was about, it also clears every non-manual key that subject holds which no current product grants.
A comped key survives, because a key the catalog never sold is a human’s decision rather than a derivation’s — which is why those are declared separately in config as manual entitlements. A grant nothing declared is a grant nobody reviewed.
Sandbox never grants production
A sandbox transaction granting a real entitlement is the most common in-app-purchase security defect there is, so no such row is ever created.
The environment is an input from this deployment’s own environment var, never inferred from the payload — inferring it from what the store said is exactly the hole.
Only a Worker deployed to production is production. Staging, dev, and an unset var are all sandbox, because the failure directions are not symmetric: treating production as sandbox loses a purchase that reconciliation repairs; the other way round hands out entitlements for test transactions.
Who holds one
billingSubject has no default, because it is an answer only you can give, and it lands in a column and a unique index.
user means one person buys and one person is entitled. organization means a company buys and everybody in it is entitled.
Changing it later is a migration of your own rather than a config edit.
Grace, and what it is for
A subscription in its billing-retry grace period still grants its entitlements by default.
That is the point of grace: a failed card should not lock somebody out mid-month while the store is still trying. It is a config key, so you can decide otherwise.
What a client sees
One route returning the caller’s own resolved entitlements. A client renders from that rather than from a SKU it knows about, which is the same rule as the gate — and the reason a fifth store is a catalog edit rather than a client release.