What are entitlements in SaaS, and what a plan change does to each kind
In SaaS, an entitlement is what a customer's plan lets them do: a feature, a limit, a quota or a credit grant. The four look alike until the plan changes.
In short
An entitlement is what a customer's plan lets them do, read from the plan version they bought at the moment they try. It takes four shapes: a feature that is on or off, a limit on how many things they hold, a quota they spend each period, and credits the plan deposits. A downgrade treats each one differently.
An entitlement is what one customer is allowed to do in your product because of the plan they bought: whether a feature is on, how many of something they can hold, how much they can use this period. It is read from that customer's plan at the moment they try, not copied somewhere and kept in sync by hand.
Most explainers give that definition and a list of types, and stop short of the day the plan changes. That is the day the kinds come apart. They do not all change at once, and a system that treats them as one thing ends up with a refund request or a customer who kept what they stopped paying for.
Why entitlements exist
A pricing page makes promises and code has to keep them. Without a name for that
job, the promise becomes if (plan === 'pro') scattered through the codebase,
and the first new plan quietly breaks every one of those checks. The plan's name
is not what was sold. The list of things on it is.
We learned the other half of this on our own console. BuildBase sells its plans through its own billing module: every console org is a workspace inside a BuildBase tenant, and its subscription is read through the same public endpoint a customer's app calls. The plan versions held features, limits and quotas. The console had a full editor for all three. And for a while, nothing read them - the code that fetched an org's subscription kept the status and dropped the plan version on the floor.
An entitlement that no code reads is a line on a pricing page.
The four kinds, and what "no" means for each
Four shapes cover nearly every pricing page, and the useful way to tell them apart is what the customer can do about a refusal.
A feature is on or off: csv-export: true. A "no" means the plan does not
include it, and the only remedy is a different plan.
A limit is a ceiling on how many of something the customer holds at once: seats, projects, custom domains. It is held, not spent. Deleting one frees a slot, which is why our own refusal copy for a limit ends "Upgrade for more, or remove one."
A quota is an allowance used up over a period that resets, like 10,000 API calls a month. Spent, not held. Deleting what you made does not give the call back, so our quota refusal never says "remove one" and says instead that the allowance renews with the billing cycle.
A credit grant is the plan depositing credits, once or every period, into the same balance the customer buys credits into. Each action spends what it costs, and a "no" means the balance is short, which buying more fixes without a plan change. A grant either adds to what is there or swaps last period's plan credits for fresh ones. Quotas are not credits covers why a balance and an allowance differ even when both renew monthly.
All four on one published plan version, simplified:
{
features: { 'csv-export': true, 'custom-domain': true },
limits: { 'max-custom-domains': 3, 'max-projects': 5 },
quotas: {
'api-calls': { allowOverage: false, monthly: { included: 10000 } },
},
creditGrant: {
enabled: true,
creditPackage: '<credit package id>',
mode: 'topup', // or 'reset': expire last period's credits, grant fresh
renewOnPeriod: true,
},
}Notice that custom-domain and max-custom-domains arrive as a pair. Most
capabilities need both. One says whether the thing exists at all; the other says
how many. Our own registry pairs custom-auth-domain, which decides whether the
Domains tab exists, with max-auth-domains, which decides whether you may add
another. They fail in different places with different messages. Two
entitlements, then, not one with a magic zero.
Which brings up zero. A numeric entitlement has three values that are easy to
collapse into two: none allowed, no ceiling, and not on this plan at all. Our
registry writes unlimited as -1, never 0, because 0 already means none and
an absent key means the plan does not mention it.
We do not fully agree with ourselves here, either. An older workspace setting,
maxWorkspacesPerUser, stores 0 for unlimited on live orgs, and migrating it
would flip every one of those orgs to "none allowed". So it stays, and the
caller translates at that one boundary. Pick your spelling for unlimited
before the first plan ships.
What some explainers call a configuration entitlement, say 30 days of history on one plan and 90 on the next, is a limit with a unit. Zero days is not a short window. It is none.
Key takeaway
A feature is on or off. A limit caps how many things a customer holds at once, and deleting one frees a slot. A quota is spent, then resets. Credits are a balance the plan deposits into and each action spends from. Most capabilities pair a feature with a limit.
Where an entitlement lives
On the plan version the customer is subscribed to, and nowhere else that can disagree with it. It gets read at the point of use.
"The plan version" means the one the subscription is pinned to, not the newest one you sell, which is how a price change leaves existing customers exactly where they are. "Nowhere else" is about ownership. In our registry, code owns the namespace: which slugs exist and what kind each one is. The database owns the assignment, meaning which plan grants which slug at what value. Moving a feature between tiers is then a console edit, not a deploy. And a typo in a slug is a compile error instead of a feature that silently never turns on.
"Read at the point of use" means the check sits where the work happens. The button is a courtesy. The route is the boundary:
// In the route that runs the export, not only the button that opens it
const workspace = await bb.workspace.get(workspaceId);
if (workspace.features['csv-export'] !== true) {
return Response.json(
{ error: 'CSV export is not included in your plan' },
{ status: 402 }
);
}402 is payment required, because the fix is a purchase. The UI half wraps the button in a component keyed on the same slug and shows an upgrade prompt instead. Enforcing quotas in React does that for quotas, and the server-side version explains why, for a quota, the record call is the real check.
So why read workspace.features and not the plan version on the subscription?
Because of what happens on a downgrade.
What a plan change does to each kind
An upgrade is the easy direction. Once the payment for it is confirmed, the new plan's features and limits apply straight away. If the proration invoice is still open, say a declined card or a 3D Secure prompt, BuildBase waits for it to be paid before granting anything.
A downgrade is where the kinds split. The customer paid for the current period on the higher plan, so the features and limits they already have run to the end of it. Quotas do not wait. The subscription moves to the lower plan at once and the smaller allowance applies immediately, while credits already in the account stay until they expire on their own and the lower plan's grant arrives at the next renewal. The proration guide has the table of which clock moves when.
That split is easy to get wrong, and our own code has got it wrong at least twice.
During a deferred downgrade the subscription points at two plan versions:
planVersion is the plan being moved to, which quotas read, and
featurePlanVersion is the plan being left, which features and limits read
until the period rolls over. Our own control plane built each org's entitlement
from planVersion, so downgrades bit at once until the held plan was added.
Separately, a race between the plan-change route and its own Stripe webhook made
a downgrade read as an upgrade. The customer lost the plan they had paid for, on
the spot. We found that one in production, and it only happened some of the
time.
The server's own feature resolution reads the held plan first, and so does the
features map on a workspace. The SDK's subscription types, as of 0.0.82, do
not declare featurePlanVersion at all. Read features off the subscription's
plan version and you are reading the wrong plan for as long as a downgrade is
pending.
When a subscription stops entitling the workspace at all, the plan-managed features fall back to their defaults. Canceled, unpaid, a trial that ran out, a suspension at the end of dunning: each of those counts as no plan. Past due does not, because it is the grace window while Stripe retries the card.
What you refuse after that matters as much as when. Our console gate is written for writes only, so a customer whose plan no longer includes a capability can still see and export what they made while it did. Holding someone's data hostage over a downgrade turns a lost upsell into a lost customer.
The gate you add after customers exist
Versioning handles most of this: a subscription stays pinned to the version it was sold, and entitlements resolve from that version. What it does not handle is adding a brand-new entitlement to plans that already have customers.
Every published version predates the new slug, so none of them mention it. If absent reads as locked, shipping the gate takes the feature away from every existing customer the moment it deploys. Our console gate reads absent as unknown and lets the request through. Treating absent as locked is a separate switch, the last step of the rollout, for once every published version carries an explicit value. Separately, the gate itself has three positions: off, shadow (decide and log every would-be refusal, refuse nobody) and enforce.
Unknown is a real answer, not a gap. A gate with only granted and locked forces "we have not found out yet" into "denied", which is how a customer sees a padlock over a feature they paid for while the page loads, or gets a 402 because your own billing lookup timed out. A false unknown costs one ungated feature. A false locked costs a paying customer a screen they bought.
When an entitlement is the wrong tool
Not everything with a number belongs on a plan.
Safety ceilings. Rate limits and abuse prevention are not entitlements, and we do not sell them. Price a rate limit and someone will eventually sell a way past it, which gives you a reason to leave customers less safe. Keep those in configuration, the same for every plan.
What a new customer needs to see the product work. Our first question for any gate is whether it would make a new customer leave before the product proves itself. If it would, it is free on every plan: gating it costs more in churn than it earns in upgrades.
A limit on something the customer cannot delete. A limit is held, so the way out at the ceiling is to remove one. With no delete path the limit is a ratchet. Once at the cap, the customer is at it forever, and the only exit is an upgrade they did not choose. Ship the delete before the cap.
Anything engineering decides. A beta, a percentage rollout, a kill switch. None of these are about what the customer paid for, and they belong in a flag. Entitlements are not feature flags makes that case in full, including why the two look identical at the render site.
What an entitlement is not
It is not a permission. The plan decides whether the workspace can export at all, and the member's role decides who may press the button. We made "role never skips the plan" a hard rule for our own console: owner and admin roles there bypass permission checks by design, and a plan gate that inherited the bypass would look closed and be open.
It is not a Stripe feature list either, although Stripe Entitlements covers the on-or-off kind well. A Stripe feature has a name and a lookup key and no quantity, so limits, quotas and balances still need somewhere to live.
In BuildBase, features, limits and quotas sit on the plan version in the billing module, plan credit grants in credits, and the feature flags reference covers the components that gate UI on a plan-tied slug. The four kinds, and the way a downgrade pulls them apart, are the same whatever you build on.