You sell Start, Growth and Scale. Growth includes 5,000 emails a month and ten projects, and one customer on Growth signed a contract for 20,000 emails. A key alone cannot say any of that: requireEntitlement("growth") knows whether the caller holds Growth, and nothing about how much Growth buys.
This page is the how much. Each entitlement is declared once with what it buys, a route reads the number without knowing the plan, an operator can shade one customer’s number from the control plane, and the kit can count and refuse what it already stores.
Declare each entitlement once
The top-level entitlements map is where a key is declared: its display name, its numbered quotas and its features. A product goes on mentioning the keys it grants, as an array of strings, exactly as before.
payments({
billingSubject: "organization",
resolveSubject,
entitlements: {
start: {
name: "Start",
quotas: { emails: 500, projects: 3, seats: 2 },
features: { sso: false, auditExport: false },
},
growth: {
name: "Growth",
quotas: { emails: 5000, projects: 10, seats: 10 },
features: { sso: true, auditExport: false },
overrideMax: { emails: 100_000 },
},
scale: {
name: "Scale",
quotas: { emails: null, projects: null, seats: 50 },
features: { sso: true, auditExport: true },
},
// Declared and sold by nothing: comp-only.
enterprise: {
name: "Enterprise",
quotas: { emails: null, projects: null, seats: null },
features: { sso: true, auditExport: true },
},
},
// What a caller holding no key gets.
defaults: {
quotas: { emails: 50, projects: 1, seats: 1 },
features: { sso: false, auditExport: false },
},
products: {
growth_monthly: { type: "subscription", name: "Growth", entitlements: ["growth"], stripe: { priceId: GROWTH } },
growth_annual: { type: "subscription", name: "Growth, yearly", entitlements: ["growth"], stripe: { priceId: GROWTH_YEAR } },
},
});The declaration is top-level because a tier is sold by more than one product. growth_monthly and growth_annual are two SKUs for one tier, and the numbers belong to the tier. Written on each product, they would be written twice and drift.
quotas holds whole numbers and null. features holds booleans. null is unlimited, and it is written, never inferred. A quota that is a fraction, a negative or a string is refused at parse, naming the key and the name. Two maps rather than one, because a single map answering number | boolean | null would make every caller tell 0 from false from nothing declared by hand.
A key a product mentions must be declared. A mention with no declaration is refused at parse, naming the product and the key. The reverse is legal and means comp-only: enterprise above is grantable by hand and sold by no checkout. manualEntitlements is gone, and a config still passing it is refused with the declaration to write instead.
defaults is the free floor. A customer who has bought nothing holds no key, so without a floor every quota resolves to 0 for the most common account in your product. It is optional, and leaving it out means an unentitled caller gets nothing.
overrideMax is optional, per quota. It bounds what an operator may pin for a subject on that tier. It must name a quota the key declares and must not sit below that quota’s own value, because a ceiling under the plan would refuse what the plan already grants.
The catalog stays in pithy.config.ts, for the reason the catalog lives in config gives. A per-customer override is the one audited exception.
Read an allowance without knowing the plan
import { feature, quota } from "@pithy-sh/core/src/entitlement/allowance";
app.post("/projects", requireAuth(), validate("json", NewProject), async (c) => {
const projects = await quota(c, "projects"); // a whole number, or null for unlimited
if (projects !== null && (await countProjects(orgOf(c))) >= projects) throw new ProjectLimitReached();
if (await feature(c, "auditExport")) enableExport();
// …
});Neither reader takes a key. Your route does not know which tier the caller is on, and during an upgrade overlap the caller holds two. So quota(c, name) resolves across every key the subject holds, in this order:
- The subject’s override, if it pins this name.
unrestricted, if the override carries it: unlimited, and on.- The highest value among every key the subject holds now.
nullbeats every number, and a feature is on if any held key turns it on. A customer holding two products is owed the larger of what they bought, and an upgrade overlapping the old subscription’s last weeks must never read as a downgrade. defaults, for a name no held key speaks to.0andfalse. Never unlimited. A name that resolved to unlimited because nothing declared it is a paywall that does not exist, and nobody notices until the invoice.
A name nothing declares throws core/internal, naming the name and listing the declared ones. quota(c, "emals") is a hard failure, never a zero and never unlimited.
requireEntitlement still gates on the key alone. A quota denies nothing by itself, and a feature set to false denies nothing either. What a number means for a request is your route’s decision, because only your code can count what it owns. Payments never counts your members.
A paywall reads the same numbers. virtual:pithy/payments carries what each tier buys, so a pricing screen can lay the tiers side by side, and useAllowances() from @pithy-sh/payments/src/client/hooks reads what the caller’s own holder resolves to off GET /payments/entitlements. It starts null, never zero, so a screen never tells a paying customer they have 0 of 0 while the read is in flight. It is a screen’s input, never a security boundary: the server’s quota(), the kit’s meters and your own route’s count are what refuse the eleventh seat.
Shade one customer’s number from the control plane
An override changes one subject’s numbers without taking them off their plan. It is keyed on the subject, never on a key, so it survives a tier change:
growth plan: emails 5000, projects 10, seats 10, sso true
override: emails 20000
effective: emails 20000 override
projects 10 plan: growth
seats 10 plan: growth
later, upgraded to scale:
effective: emails 20000, projects unlimited, seats 50Three control-plane writes, each behind its own scope:
| Route | Scope | What it does |
|---|---|---|
POST /payments/entitlements/overrides | payments:overrides:set | Pin quotas or features by name. A merge: only the names sent change |
POST /payments/entitlements/overrides/unrestricted | payments:overrides:unrestricted | Every quota unlimited and every feature on, for every name declared now or later. Or take it back |
POST /payments/entitlements/overrides/clear | payments:overrides:clear | Unpin names, or clear the whole override |
{
"subjectType": "organization",
"subjectId": "acme",
"version": 0,
"reason": "MSA signed 2026-06-01, ticket 4471",
"quotas": { "emails": 20000 }
}- Version-checked. Every read carries the subject’s
overrideVersion, and a set or an unrestricted write sends it back. A write behind the row ispayments/override_stale(409) and writes nothing, so two operators editing one customer cannot silently overwrite each other. - A clear’s version is optional. Sent, it is checked the same way. Left out, the clear removes whatever is pinned now, including a pin somebody wrote after your read. That is allowed because a clear only moves a subject back toward its plan. A clear that removes nothing answers 200 and writes no audit event.
- A clear only narrows. On an
unrestrictedsubject a pin is all that holds a name below unlimited, so unpinning it is a 400. Turnunrestrictedoff first, or clear the whole override. - A reason is required on a set and on an unrestricted write, and the
@pithy-sh/auditevent carries it verbatim beside the subject, the mode, the before and the after. - Bounded. A number past the tier’s
overrideMaxis a 400 naming the limit. Never clamped. A quota with no ceiling accepts any whole number. - Declared names only. A name the declarations do not mention is a 400 naming it and listing what is declared.
- It grants no key. A subject with an override and no entitlement is refused at every gate. A comped key and a pinned number compose.
The reads behind an override editor. GET /payments/admin/entitlements/:subjectType/:subjectId projects, per declared name, what applies now, where it came from (unrestricted, override, plan with the key, default or none), and what it would be with the override removed. That last one is what a pane shows beside a pin. GET /payments/admin/catalog projects each declared key’s name, quotas, features and overrideMax, plus the floor, so the editor is built from one response.
Let the kit count what it stores
The kit counts three things itself, because it writes the rows they count:
| Meter | Counted from | Enforced by |
|---|---|---|
emails | a per-period counter in pithy_payments_meter_counters | @pithy-sh/email, at enqueue |
storageBytes | pithy_storage_objects | @pithy-sh/storage, as the subject’s ceiling in place of bytesPerOwner |
mediaItems | pithy_media_assets | @pithy-sh/media, at create |
payments({
// …
meters: { emails: { enforce: true } },
});Every meter is advisory until you name it here. Declaring quotas: { emails: 500 } makes 500 readable. Enforcing the emails meter makes it binding. Two steps, because the first is a pricing decision and the second is a production one: a cap that arrives by surprise breaks a customer already over it. An enforced meter with no quota of its name is refused at parse, because it would refuse everyone.
A refusal is payments/quota_exceeded (402), and nothing is written. The same code from all three meters. Where storageBytes is advisory, storage keeps its own ceiling, bytesPerOwner, and its own refusal, storage/quota_exceeded (413).
unrestricted lifts only what is not pinned. A meter never refuses an unrestricted subject on a name nothing pins. A number pinned beside unrestricted wins, and an enforced meter holds the subject to it.
An organization’s quota is the organization’s. Under billingSubject: "organization", storageBytes sums every member’s bytes stored for the organization and mediaItems counts every member’s records, against one number. Counted per member, a paid quota would grow with every invitation. A write held for a different organization than the one billed, or for none, is refused as core/internal, so your resolveSubject and the organization in force must agree.
An email is counted when you say whose it is. Pass subject, the holder pair, on the enqueue for mail a customer’s plan pays for. The kit’s own mail, such as a magic link to somebody who has not signed up, names nobody and is never counted.
await enqueue(env, {
to,
template: "operationalNotice",
payload,
subject: { subjectType: "organization", subjectId: org.id },
});The period is the subscription’s billing period where payments knows one, and the calendar month in UTC where it does not. A new period is a new counter row, so it starts at zero with nothing to reset it. The check and the charge are one statement, so concurrent sends cannot race past the limit.
A counter read that fails refuses, and logs. Over-counting briefly is recoverable and under-counting is not. The cost is real, since a bug of ours becomes a refused send, and enforcement being opt-in is what bounds it.
A meter refuses. It never bills. Nothing a meter counts is reported to a store, and the kit does not sell overage. To bill by a number your code owns, read bill by a count you own.