ShipKit docs
The stack

Entitlements

What a plan is allowed to do, decided in code rather than read off a subscription row.

Payments answer "did they pay". Entitlements answer "may they do this", and the two are deliberately separate. packages/entitlements holds a catalogue in code, and the rest of the app asks it rather than inspecting the subscriptions table.

The catalogue works with payments unconfigured. With no Polar credentials everyone resolves to free, and every gate still behaves correctly. That is what lets a fresh clone run the whole product without an account anywhere.

Three kinds of limit

KindDeclared asChecked
Boolean featureFEATURESAt the action: may this user do this at all
Metered featureMETERSCounted per UTC month against a limit
Static capcapsAt creation time, against a total rather than a rate

Shipping today: features ai.chat, ai.rag, files.upload, webhooks.endpoints, api-keys, projects; meters ai.messages and files.uploads; caps maxWebhookEndpoints and maxApiKeys.

A null limit means unlimited. Rename all of it — the names are this product's domain, not a framework's.

Using it

import {
  requireEntitlement,
  requireUsage,
  consumeUsage,
  UpgradeRequiredError,
} from "@workspace/entitlements"

// Boolean gate. Throws UpgradeRequiredError, which the API maps to a 402.
await requireEntitlement(user.id, "ai.chat")

// Metered gate: check before doing the work…
await requireUsage(user.id, "ai.messages")
const reply = await runTheExpensiveThing()
// …and count after it succeeded.
await consumeUsage(user.id, "ai.messages")

The split between requireUsage and consumeUsage is the point. Counting before the work charges a user for a request that then failed; counting only after means a burst of concurrent calls can overshoot the limit slightly. That trade is deliberate, and overshooting a soft monthly quota is the cheaper side of it. If a meter ever gates something expensive and abusable, reserve first instead.

getEntitlement and getUsage are the non-throwing versions, for rendering a UI that shows what a plan includes rather than blocking an action.

Where the plan comes from

plan.ts resolves a user's effective plan from the latest row in subscriptions, and returns more than an id:

{
  plan: "pro",
  status: "past_due",
  cancelAtPeriodEnd: false,
  currentPeriodEnd: Date,
  inGracePeriod: true,
}

A past_due subscription keeps its entitlements for ENTITLEMENTS_GRACE_DAYS after the paid period lapses, defaulting to 7. That window exists because payment providers retry failed charges for days, and locking somebody out on the first decline turns a bank hiccup into a support ticket and a cancellation.

inGracePeriod is exposed so the UI can say so. A user whose card failed should see a banner, not a silent countdown to losing access.

Changing plans

Everything is in catalog.ts. Add a plan id, give it a features map, limits and caps, and map it to a Polar product in the checkout configuration. Access decisions come from this file, so a plan that exists here works before it exists in Polar, which is the right order for developing against it.

On this page