Back to BlogArchitecture & Engineering

SaaS Entitlements: Enforcing Plans, Limits and Add-ons in Code

Rupak Amin

Founder & Lead Engineer, RAITHub

14 min read

SaaS entitlements are the rules that say what each customer may use: which features, how much of each limit, and which add-ons. Keep them in your own database, write them only from billing webhooks, check them in one server-side function before every gated action, and count usage with an atomic database update so two requests can never both take the last unit.

If you would rather have entitlements built into your product for you, see how RAITHub would build this below.

What is an entitlement in a SaaS, and how is it different from a plan or a role?

An entitlement is a fact about what one customer account has paid for: "tenant 42 may use SSO", "tenant 42 may have 25 seats", "tenant 42 bought the extra-storage add-on". A plan is only a bundle of entitlements with a price on it. A role is something else again: it says what a person inside that tenant may do.

Keeping the three apart is what makes pricing changes cheap. If your code checks plan === 'pro' in forty places, every new plan, discount or custom contract means a code change in forty places. If your code checks can(tenant, 'sso'), you change one row of configuration.

ConceptQuestion it answersWho changes itExample
PlanWhat bundle and price did they choose?Billing, via checkout or salesPro, yearly
Entitlement (feature)Is this capability switched on for the account?Billing webhooks, never a person by handsso = true
Entitlement (limit)How much of something may the account use?Billing webhooks plus contract overridesseats = 25, projects = 50
Add-onWhat did they buy on top of the plan?Billing webhooks+100 GB storage
Role or permissionWhat may this user do inside the account?The customer's own adminBilling admin, viewer

Roles are covered in SaaS authorization and RBAC design. A real request usually needs both checks: the tenant must be entitled to exports, and the user must hold a role that may export.

Should Stripe be the source of truth for entitlements?

Stripe can be the source of truth for which features a paying customer has, but your database should hold a copy that your app reads on every request. Stripe's own guidance says the same.

Stripe Billing has an Entitlements feature: you create Features with a unique lookup key, attach them to Products, and Stripe creates an Active Entitlement for each customer who subscribes. When access changes, Stripe sends the entitlements.active_entitlement_summary.updated event with the customer's full current list. The same page recommends you "persist these entitlements internally for faster resolution", and notes that the summary webhook carries at most 10 entitlements, so a customer with more must be fetched through the paginated list.

Two practical points follow:

  • Stripe features are on or off. The active entitlement object shown in the docs carries a feature and a lookup key, not a number. Numeric limits such as "25 seats" or "10,000 API calls a month" still need a place in your own schema, usually as product or price metadata that your webhook handler copies across.
  • Webhooks can arrive late, twice or out of order. Your handler must be idempotent and should re-read the current state rather than trust the event payload alone. The failure modes are in Stripe webhook returns 200 but the subscription is not updated.

Stripe also notes that "existing subscriptions will create active entitlements for any product feature changes at the start of the next billing period", so if you add a feature to a plan and want current customers to have it today, you need an override in your own table.

What does a good entitlements data model look like?

Three tables cover most products: what each plan grants, what each tenant has right now, and how much each tenant has used. Keep overrides as rows, not as special cases in code.

-- What each plan or add-on grants, copied from billing config.
CREATE TABLE plan_grants (
  plan_key     text NOT NULL,          -- 'pro', 'addon_storage_100gb'
  feature_key  text NOT NULL,          -- 'sso', 'seats', 'projects'
  limit_value  bigint,                 -- NULL means unlimited; ignored for on/off features
  PRIMARY KEY (plan_key, feature_key)
);

-- What each tenant has right now, written only by the billing sync.
CREATE TABLE tenant_entitlements (
  tenant_id    uuid NOT NULL,
  feature_key  text NOT NULL,
  limit_value  bigint,                 -- NULL means unlimited
  source       text NOT NULL,          -- 'plan', 'addon', 'override', 'trial'
  expires_at   timestamptz,            -- trials and temporary overrides
  PRIMARY KEY (tenant_id, feature_key, source)
);

-- Metered counters, one row per tenant, feature and period.
CREATE TABLE usage_counters (
  tenant_id    uuid NOT NULL,
  feature_key  text NOT NULL,
  period_start date NOT NULL,
  used         bigint NOT NULL DEFAULT 0,
  PRIMARY KEY (tenant_id, feature_key, period_start)
);

The source column lets a sales-negotiated override sit beside the plan grant without being overwritten when the next webhook arrives. The effective entitlement is the most generous unexpired row for that feature. If you are on Postgres with row-level security, add the tenant policy to these tables like any other; the pattern is in the Postgres row-level security guide.

How do you check an entitlement in code?

With one function, called on the server, that every gated route and job goes through. The browser may hide a button, but only the API decides.

type FeatureKey = 'sso' | 'audit_log' | 'api_access' | 'seats' | 'projects'

interface Entitlement {
  featureKey: FeatureKey
  limit: number | null // null = unlimited
  expiresAt: Date | null
}

export class EntitlementError extends Error {
  constructor(public featureKey: FeatureKey, public reason: 'not_in_plan' | 'limit_reached') {
    super(featureKey + ': ' + reason)
  }
}

// Effective entitlement = most generous unexpired row across plan, add-on and override.
export function effective(rows: Entitlement[], key: FeatureKey, now = new Date()): Entitlement | null {
  const live = rows.filter((r) => r.featureKey === key && (!r.expiresAt || r.expiresAt > now))
  if (live.length === 0) return null
  if (live.some((r) => r.limit === null)) return { featureKey: key, limit: null, expiresAt: null }
  return live.reduce((a, b) => ((b.limit ?? 0) > (a.limit ?? 0) ? b : a))
}

// On/off features.
export function requireFeature(rows: Entitlement[], key: FeatureKey): void {
  if (!effective(rows, key)) throw new EntitlementError(key, 'not_in_plan')
}

// Countable limits: pass the current count, read inside the same transaction as the insert.
export function requireCapacity(rows: Entitlement[], key: FeatureKey, current: number, adding = 1): void {
  const e = effective(rows, key)
  if (!e) throw new EntitlementError(key, 'not_in_plan')
  if (e.limit !== null && current + adding > e.limit) throw new EntitlementError(key, 'limit_reached')
}

Map EntitlementError to one HTTP response everywhere, for example 403 with a machine-readable code such as limit_reached and the feature key, so the front end can show an upgrade prompt instead of a generic error. Load the tenant's rows once per request and cache them for a few seconds at most; a longer cache means a customer who just upgraded still sees the old limit.

How do you stop two requests both using the last unit of a limit?

Count with a single conditional update, so the database, not your application, decides who gets the last unit. A read-then-write in application code has a race: two requests both read 9 of 10, and both write 10.

-- Atomically consume N units if, and only if, it stays within the limit.
-- Returns the new total, or no row when the limit would be exceeded.
INSERT INTO usage_counters (tenant_id, feature_key, period_start, used)
VALUES ($1, $2, date_trunc('month', now())::date, $3)
ON CONFLICT (tenant_id, feature_key, period_start)
DO UPDATE SET used = usage_counters.used + EXCLUDED.used
WHERE usage_counters.used + EXCLUDED.used <= $4   -- $4 = the effective limit
RETURNING used;

The conditional DO UPDATE ... WHERE form is documented in the PostgreSQL INSERT reference. If the statement returns no row, refuse the action. For an unlimited entitlement, skip the WHERE check but still count, because usage data is what tells you where to set limits next year. Note one edge: the very first insert of a period is not checked against the limit, so validate that $3 is not above $4 before you run it.

Seats and projects are a different kind of limit: they are counts of rows that already exist, not a running meter. Check them inside the same transaction that creates the row, and lock the tenant row first (SELECT ... FOR UPDATE) so two invitations cannot both slip under the cap.

What should happen on a downgrade, a failed payment or a limit breach?

Decide the rules before launch and write them down, because each one is a support conversation. Nothing should be deleted automatically on the day access changes.

  • Downgrade below current usage. A team with 30 seats moves to a 10-seat plan. Do not remove users. Block new invitations until they are under the limit, and show the admin exactly what to change.
  • Failed payment. Billing providers retry a failed charge on a schedule you configure. Keep access during that window, show a banner to billing admins, and move to read-only, not deletion, when the subscription is finally cancelled.
  • Soft and hard limits. For metered features, warn at 80% and 100%, then either block or bill overage. Overage billing belongs with your billing model; see usage-based billing for SaaS.
  • Trials. A trial is an entitlement row with an expiry date and source 'trial'. When it lapses, the check simply stops finding it.
  • Grandfathered customers. Old plans keep their grants as long as the price exists. Never edit a live plan's grants in place; create a new plan key.

Every one of these changes should land in your audit log with the event that caused it. The pattern is in SaaS audit log design.

Do-it-yourself estimate: 3–6 days for the tables, webhook sync, the check function, the atomic counter, an admin override screen and tests, if you already run Stripe subscriptions. The main risk is drift: an entitlement table that no longer matches billing because one webhook failed. Run a nightly reconciliation job that compares your rows with Stripe's list endpoint and alerts on differences.

How are entitlements different from feature flags?

A feature flag is a decision an engineer makes; an entitlement is a fact a customer paid for. They can share one evaluator, but they must not share one source of truth.

Release flags are short-lived and flipped by hand. Entitlements last as long as the plan and change only when billing changes. If plan features live as hand-flipped flags in a dashboard, a cancellation will not remove access until someone remembers. The full comparison is in feature flags for SaaS.

Buy, build or hire?

OptionExamplesChoose this whenWatch out for
Billing platform features onlyStripe Entitlements with your own copy in the databaseYour plans differ by on/off features and you already use Stripe BillingNumeric limits, overrides and grace rules are still yours to build
Hosted entitlements or pricing serviceDedicated pricing and entitlement platforms that sit between billing and your appYou change pricing often, run many plan experiments, or bill across several providersAnother vendor on the request path; check latency, outage behaviour and pricing units
In-house tables and one check functionThe model and code aboveA handful of plans, a few limits, one billing providerYou must write the reconciliation job and the tests yourself
Hire a team to build it inRAITHub or another studioBilling, limits and permissions must agree, and nobody owns that layer todayInsist the handover includes the downgrade rules and the tests, not only the code

How do you test entitlements?

Test the boundaries and the transitions, because that is where money leaks and where customers get locked out.

  • At, below and above every limit: 9 of 10 succeeds, 10 of 10 succeeds, 11 of 10 is refused with the right error code.
  • Concurrency: fire 20 parallel requests at a limit with 5 units left and assert exactly 5 succeed.
  • Every webhook transition: new subscription, upgrade, downgrade, add-on bought, add-on removed, payment failed, cancelled, reactivated. Replay each event twice to prove idempotency.
  • API, not just UI: call each gated endpoint as a tenant without the entitlement and expect a refusal.
  • Reconciliation: delete a row by hand in staging and check the nightly job finds and fixes it.

The webhook side of this is covered in testing payments and webhooks. If you expose a public API, the same check gates it; see designing a public API for your SaaS, and for trial mechanics, free trial vs freemium engineering.

Why RAITHub for this

Entitlements sit where billing, tenancy and permissions meet, which is the layer RAITHub builds and tests on most projects. RAITHub has not published a standalone entitlements product; the evidence is in the surrounding systems.

  • Permissions at scale. Sundor Skin runs 12 staff roles built from 88 permission codes on 146 PostgreSQL tables with row-level security, covered by 530+ tests, plus tier pricing and customer credit limits.
  • Billing tied to access. PropDesk collects rent through Stripe and has 1,024 tests; PadhAI runs 9 payment gateways.
  • Multi-tenant from day one. BlockEstate is a multi-tenant listing platform that reached MVP in 6 weeks.

When you don't need us

  • You have two plans that differ by one feature. A single check against Stripe Entitlements, copied into one table, is enough.
  • You change pricing weekly. A hosted pricing and entitlements service may serve you better than custom code.
  • You want developers placed in your team. RAITHub does fixed-scope builds and dedicated teams, not staff augmentation.

How RAITHub would build this

As part of a new SaaS build, or added to an existing product, scoped in writing first.

  • Entitlement model: plan grants, tenant entitlements with overrides and trials, and usage counters, in your database with tenant isolation.
  • Billing sync: idempotent Stripe (or other provider) webhook handlers, plus a nightly reconciliation job that alerts on drift.
  • One server-side check: feature and capacity checks with atomic counting, one error format, and an upgrade prompt in the UI.
  • Written downgrade and failed-payment rules, implemented and tested, with an admin screen for sales overrides.

Timeline: inside a new product, this is part of the 4–6 week fixed-scope SaaS build; added to an existing backend, it fits the 6–12 week backend and API range alongside other work, with the exact scope in the quote.

You receive: automated tests and CI for every limit and webhook transition, handover docs and runbooks, and full IP assigned to you under NDA.

Next step: a free 15-minute technical audit, then a written fixed quote. See the SaaS development service, or book the audit.

Frequently asked questions

What are entitlements in SaaS?

Entitlements are the features, limits and add-ons a customer account is allowed to use because of what it pays for. Plans bundle them; your code should check entitlements, not plan names, so pricing can change without code changes.

Can Stripe enforce plan limits for me?

Stripe Entitlements tells you which features a customer has and notifies you when that changes. Enforcement still happens in your app, and numeric limits such as seats or monthly quotas need your own storage and checks.

Where should I check entitlements, front end or back end?

On the back end, for every gated route and background job. The front end can hide or disable controls for a better experience, but anyone can call your API directly.

How do I enforce a usage limit without race conditions?

Use one conditional database update that adds usage only if the new total stays within the limit, and refuse the action if no row comes back. Never read the count in application code and write it back separately.

What happens to data when a customer downgrades?

That is a policy you choose, but the safe default is to keep all data, block new usage above the lower limit, and tell the admin what to change. Deleting data automatically on a downgrade causes support tickets and churn.

Are feature flags and entitlements the same thing?

No. Flags are engineering decisions, often short-lived and flipped by hand. Entitlements are commercial facts written by billing. They can share an evaluator, but entitlements must change automatically when the subscription does.

SaaS entitlementsPlan limitsAdd-onsStripe EntitlementsUsage limitsTypeScriptPostgreSQL

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.