A product is not an entitlement

This is the decision designing a catalog turns on, and the reason one entitlement can be sold on five rails without your code knowing which one paid.

Three names, and they are not the same name

What it isWho owns it
A SKUcom.example.pro.monthly, price_1P…, a Play product idThe store
A product idpro_monthly — your catalog keyYou
An entitlement keypro — the thing your code checksYou

Your gating code names the entitlement, never the product and never the SKU. That sentence is the whole design, and everything below is a consequence of it.

Why the indirection earns its keep

Many products grant one key, deliberately — which is what resolving an entitlement collapses back down to a single yes or no, and what a gate reads. A monthly subscription, an annual one, a lifetime unlock, a promotional tier, and the same thing sold on five different rails can all grant pro. The route that gates on pro never learns any of that, and never changes when you add the sixth.

A store SKU can be renamed; your product id outlives it. The product id is what lands in every purchase row, so renaming something in a console never rewrites history.

A rail is a listing, not a product. One catalog entry carries an apple block, a google block, a stripe block, and so on — each optional, each naming that store’s own identifier. A product sold in one place has one block. A product sold everywhere has five, and is still one product.

What a product must grant

A product has to do something, and the config refuses one that does not:

Product “…” grants nothing. Give it entitlements, a grants clause, or both.

Two ways to be useful, and a product may do both:

Entitlements — the keys it unlocks. An array, empty by default, because a product that only credits a balance grants no keys at all.

A grants clause — today, a ledger credit: a currency and a positive integer amount in that currency’s minor unit. It fires once per provider transaction, which means a subscription grants again on each renewal, because each renewal is its own transaction.

Refunds, and the clawback flag

clawback is off by default, and that default is a position rather than an oversight: the user may already have spent what a refund is trying to take back. A clawback that would overdraw is refused by the ledger rather than routed around it, and the failure is recorded and queryable — not thrown.

A ledger that can be pushed negative to make a refund tidy is a ledger you cannot trust for anything else.

Keys the catalog does not sell

Some entitlements exist only to be granted by hand — a beta flag, an internal tier, a comp. Those go in manualEntitlements.

Declaring them is what makes checking possible. With that list empty, a control-plane grant of any key outside the catalog is refused — which is what turns a typed pr into a 400 instead of a row that grants nothing and that nobody notices for a month.

Only grants are constrained. A revoke of a key the catalog has since dropped stays legal, or a catalog edit would be irreversible for everyone still holding the old key.

Two flags that change what a key means

billingSubject decides who holds an entitlement: one person, or a company and everybody the adopter counts as a member of it. Under organization billing, a colleague who joined this morning is entitled and one who left this afternoon is not — with no row rewritten either time, because membership is resolved at read time and not stamped into the grant.

It is decided once, for the project. Changing it later is a migration of every entitlement, purchase and store link.

The grace-period flag decides whether a subscription in billing retry still grants. It is true by default, because that is what grace is for — a failed card should not lock a paying subscriber out mid-period. Once grace is exhausted the purchase moves to on_hold, which never grants.

The shape this gives you

products: {
  pro_monthly: {
    type: "subscription",
    name: "Pro",
    entitlements: ["pro"],
    apple: { /* … */ },
    stripe: { /* … */ },
  },
  coins_100: {
    type: "consumable",
    name: "100 coins",
    grants: { ledger: { currency: "coins", amount: 100 } },
  },
},

One key in your route handler — pro — and everything above it is catalog data you can change without touching code.

ESC