Tiers, quotas and features

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:

  1. The subject’s override, if it pins this name.
  2. unrestricted, if the override carries it: unlimited, and on.
  3. The highest value among every key the subject holds now. null beats 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.
  4. defaults, for a name no held key speaks to.
  5. 0 and false. 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 50

Three control-plane writes, each behind its own scope:

RouteScopeWhat it does
POST /payments/entitlements/overridespayments:overrides:setPin quotas or features by name. A merge: only the names sent change
POST /payments/entitlements/overrides/unrestrictedpayments:overrides:unrestrictedEvery quota unlimited and every feature on, for every name declared now or later. Or take it back
POST /payments/entitlements/overrides/clearpayments:overrides:clearUnpin 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 is payments/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 unrestricted subject a pin is all that holds a name below unlimited, so unpinning it is a 400. Turn unrestricted off first, or clear the whole override.
  • A reason is required on a set and on an unrestricted write, and the @pithy-sh/audit event carries it verbatim beside the subject, the mode, the before and the after.
  • Bounded. A number past the tier’s overrideMax is 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:

MeterCounted fromEnforced by
emailsa per-period counter in pithy_payments_meter_counters@pithy-sh/email, at enqueue
storageBytespithy_storage_objects@pithy-sh/storage, as the subject’s ceiling in place of bytesPerOwner
mediaItemspithy_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.

ESC