Design your catalog

The catalog is where a store’s vocabulary stops and yours begins. Getting it right early is cheap; getting it wrong is a migration.

Products are what people buy. Entitlements are what your code gates on

A product is not an entitlement is that distinction in full, entitlements is the gate, and an in-app economy is the case where what people buy is a balance rather than access.

products: {
  pro_monthly: {
    type: "subscription",
    name: "Pro",
    entitlements: ["pro"],
    apple:  { productId: "com.acme.pro.monthly" },
    google: { productId: "pro_monthly" },
    stripe: { priceId: "price_1Abc" },
  },
  pro_annual: {
    type: "subscription",
    name: "Pro, annual",
    entitlements: ["pro"],
    apple:  { productId: "com.acme.pro.annual" },
    google: { productId: "pro_annual" },
    stripe: { priceId: "price_1Def" },
  },
},

Two products. Six store SKUs. One entitlement.

app.get("/reports", requireAuth(), requireEntitlement("pro"), handler);

Add an annual plan, launch on a fifth store, rename a Play id — that gate does not change, and neither does anything that reads it.

The catalog lives in config, not in a database

A product’s entitlement mapping is policy. It should be diffable in git and it should not be mutable at runtime.

The cost is that a new SKU needs a deploy. That is the correct trade: a table a mis-click or an attacker can edit decides who is entitled to what.

Naming the keys

Name an entitlement for the capability it unlocks, never for the plan that sells it.

pro is a plan name that leaked into a key, and it works until you have a plan called Team that also gets reports. reports, unlimited_projects, priority_support age better — and they let one product grant several:

entitlements: ["reports", "unlimited_projects"],

A product can grant several keys, and several products can grant one. Both are normal.

The counter-argument is real: pro is one key rather than nine, and nine keys means nine gates to keep straight. Start coarse if your product is simple, and know that splitting later is a catalog edit plus finding your gates.

The four product types

TypeWhat it is
subscriptionRenews. Has a period, a grace window, and a cancel that does not immediately revoke
non_consumableBought once, held forever. Remove ads, unlock the pro tier
consumableBought and spent. A coin pack
non_renewingA fixed term that does not auto-renew

Match the type to what you actually created in each store. It decides how a renewal and a restore behave — and on Apple it decides whether the reconciliation pass can ask about the purchase at all, because the subscription endpoint speaks for auto-renewables and nothing else.

Selling a currency pack

A consumable that grants a balance rather than a key:

coins_100: {
  type: "consumable",
  name: "100 coins",
  grants: { currency: "coins", amount: 100 },
  apple:  { productId: "com.acme.coins100" },
  google: { productId: "coins_100" },
},

That is the seam to ledger. Most products never touch a balance, and composing ledger is only necessary if you sell one.

Whether the balance is credited depends on the purchase status, and the distinction that matters is did the money ever arrive. A purchase that ended before any payment cleared credits nothing — and reading it as merely expired would credit a coin pack for money that never came, with no clawback ever to follow, because there is nothing to reverse.

Comped keys are declared

An entitlement key you grant by hand — a beta flag, an internal tier, a comp for somebody who emailed you — is declared in config, separately from any product:

manualEntitlements: ["beta", "internal"],

A grant nothing declared is a grant nobody reviewed.

Those keys are also what survives the re-derivation below, because a key the catalog never sold is a human’s decision rather than a derivation’s.

Editing the catalog is a write the read model has to follow

This is the subtle part, and it is worth understanding before you drop a key.

The derivation reads the catalog. So removing a key from a product changes what an entitlement row should say, with no event to trigger it.

Drop it and ship, and everybody holding that key holds it with nothing behind it — no purchase for it will ever arrive again, the read path only lapses rows 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 event’s own product grants: on a subject’s next purchase event, whatever it was about, it clears every non-manual key they hold that no current product grants.

Comped keys survive.

Per store, per environment

Test mode and live mode have different ids on every rail, so your staging and production configs differ.

On Paddle they differ more than that: sandbox and live are separate accounts, so a sandbox price id does not exist in live at all.

Check it worked

  • A gate names a key, and no SKU appears outside the catalog
  • Each product’s type matches what you created in each store
  • Every rail block a rail needs is present, and none names a rail you turned off
  • pithy doctor reports no entitlement gap
  • The admin catalog read shows what you expect, including the comped keys
ESC