Skip to content
← All posts
Entitlements 8 min read

Feature Gating That Survives Your Next Pricing Change

Most entitlement systems break because pricing lives in the code. Here is the separation that keeps a plan change out of your sprint board.

Sales sells a plan that does not exist yet. Marketing wants an add-on that unbundles one feature from the tier it currently ships in. Someone in support has already promised a customer 5,000 extra invoices this month. Each of those is a one-line business decision, and in most codebases each one turns into a pull request that touches auth middleware, three React components, a Stripe webhook handler and a seed migration.

The reason is almost always the same. The plan name is in the code. Somewhere there is if (tenant.plan === 'pro'), and once one of those exists there are forty, spread across the API, the UI, the background jobs and a cron that emails people. Renaming Pro to Growth becomes a refactor. Adding a plan between them becomes an audit.

We have built this layer for our own products and for client platforms, including subscription billing, usage metering, entitlement gating and AI spend wallets with auto-recharge. The pattern that holds up is boring: the code asks about capabilities, never about plans, and the mapping from plan to capability is data you can edit without a deploy.

Entitlements are not permissions, and mixing them costs you

Two different questions get answered by the same can() helper in most codebases, and they have different owners and different failure modes.

  • Permissions answer *is this user allowed*. Owned by the tenant's admin. A store manager can void a sale; a cashier cannot. Changing it is a customer action, instant, self-service.
  • Entitlements answer *has this account paid for it*. Owned by you and your pricing. Changing it is a billing event, and it has to survive a downgrade, a failed payment and a refund.
  • Quotas answer *how much is left*. Owned by metering. They are entitlements with a counter and a reset window, and they are the ones that need to be fast and correct under concurrency.

When you merge them, a downgrade silently strips a role, or a role change accidentally grants a paid feature. Keep them as separate checks that both have to pass. A user needs the role and the account needs the entitlement. Two systems, one gate: if (user.can('invoice.void') && account.entitled('invoicing')).

Name features, not plans

The single decision that prevents the quarterly rewrite: no plan name appears anywhere in application code. Not in the API, not in the frontend, not in a job, not in an email template. The code knows feature keys. A separate table knows which plan grants which key.

Feature keys should be stable and specific enough to unbundle. advanced_reports is a bad key because in eighteen months someone will want to sell scheduled exports separately from custom dashboards, and you cannot split a key that is already in production checks. Prefer reports.scheduled_export and reports.custom_dashboard. Granular keys cost you nothing at check time and save you a migration later.

Keys also need a type. In practice three cover almost everything:

  1. Boolean — on or off. marketplace.amazon_sync.
  2. Limit — a ceiling that is checked at write time. locations.max, users.max.
  3. Meter — a consumable that resets or refills. ai.tokens_month, takeoff.pages_month.

A single resolver returns all three shapes for a tenant, and the callers do not care where the value came from — plan, add-on, one-off grant or an override typed into your admin console by support.

The schema, in five tables

This is close to what we deploy. It fits on one screen on purpose.

features        (key pk, kind, default_value, description)
plans           (id pk, code, name, active)
plan_features   (plan_id, feature_key, value)      -- pricing lives here
account_grants  (account_id, feature_key, value,   -- add-ons + overrides
                 source, expires_at, note)
usage_counters  (account_id, feature_key, period, used, reset_at)

Resolution order is fixed and worth writing on a wall: feature default → plan value → account grants (highest wins for limits, any-true wins for booleans) → hard cap. The hard cap is a safety valve so a bad grant cannot hand someone unlimited AI spend.

source on account_grants is the field people skip and then regret. It records *why* the grant exists: addon:extra_seats, promo:launch, support:ticket-8812, trial. When someone asks in six months why this account has 200 seats on a 25-seat plan, the answer is in the row, not in Slack history. expires_at means trial grants expire themselves instead of leaking free features forever.

Enforce in three places, not everywhere

A common failure is enforcing entitlements only in the UI, which means the API is open, or only in the API, which means the UI shows features that error on click. Pick three enforcement points and be deliberate about what each one is for.

1. The server-side gate — the only one that is real

Every mutating endpoint that touches a gated capability calls the resolver. This is the boundary that actually protects revenue. It is also the place where the failure mode matters most: if the entitlement service is unreachable, do you fail open or closed?

Our rule: fail open for boolean reads, fail closed for anything that spends money. A cached entitlement snapshot on the account record, refreshed on every billing event and TTL'd at a few minutes, means a resolver outage does not take down the product. But an AI call against a spend wallet checks the live counter, because failing open there means an unbounded bill from a provider that will happily take it.

2. The client hint — for the interface, not for security

Ship the resolved entitlement map to the frontend in the session payload. The UI uses it to decide between showing the feature, showing it with an upgrade prompt, or hiding it. That is a product decision per feature, so store it: an upsell_mode column on features beats hardcoding the behavior in twelve components.

3. The background job guard

This is the one everyone forgets. Jobs outlive entitlements. A nightly marketplace sync queued while the account was on Pro will happily run three days after the downgrade. Re-check the entitlement at the top of the job handler, not at enqueue time, and log the skip so support can explain it.

Downgrades are where the design gets tested

Granting is easy. Taking away is where entitlement systems earn their keep, because the data already exists and the limit is now below it. An account on a 10-location plan drops to 3. You have seven locations of real inventory, staff and open orders. You cannot delete them and you cannot pretend the limit is satisfied.

The pattern that works is soft enforcement on existing rows, hard enforcement on new ones. Over-limit resources become read-only rather than deleted, creation is blocked until the count is under the limit, and the admin sees a clear list of what to archive. We built exactly this for per-location inventory in Retailer OS, where deleting a location on downgrade would destroy transaction history a tenant is legally required to keep.

That means every limit check needs two variants in the resolver: canCreate(key) and isOverLimit(key). Write both from day one. Retrofitting the second one across a live codebase is a week you did not plan for. The related mess — what a mid-cycle change does to the invoice — we covered in our post on mid-cycle plan changes.

Granting a feature is a write; revoking one is a negotiation with data that already exists.

Metering without hammering the database

Meters are the hard part because they need a counter that is correct under concurrency and cheap enough to check on a hot path. Three approaches, in rough order of how far they scale:

  • Atomic increment in Postgres, UPDATE ... SET used = used + $1 WHERE ... RETURNING used, with the limit checked on the returned value. Correct, simple, and fine into the low thousands of writes per minute per tenant. Start here.
  • Redis counter with periodic flush to Postgres. Faster, but you accept a small window of loss on a node failure. Acceptable for soft limits, not for prepaid balances.
  • Reserve-and-commit for expensive operations. Reserve an estimated amount before the call, commit the actual after, release the difference. This is what a spend wallet needs, because an AI call's true cost is unknown until it returns.

For AI spend specifically we run reserve-and-commit with auto-recharge on the wallet. The reservation stops two concurrent requests from each seeing a $2 balance and both spending it. The commit reconciles against actual token usage. In BuildAEC, where a takeoff run against a set of plan sheets has a cost that varies by an order of magnitude with page count, estimating high and refunding the difference is the only version that does not either block legitimate work or overspend.

When this is more machinery than you need

If you have one plan and a free tier, five tables and a resolver service are overhead. A plan column and a handful of checks will carry you to your first fifty customers, and you will learn what your real feature boundaries are by selling. Building a fully general entitlement engine before you have sold anything means you will get the keys wrong, because feature boundaries come from pricing conversations, not from architecture diagrams.

The signal to build it properly is the second time you ship a pricing change and it touches more than three files, or the first time sales asks for a per-customer exception. That is the moment the source column pays for itself.

There is also a tenancy interaction. On a database-per-tenant fleet, the entitlement data has to live in the control plane, not in each tenant database, or you will run a hundred migrations to add one feature key. We operate 700+ tenant databases across our platforms, and the entitlement resolver reads from shared control-plane tables with a per-tenant cache — a decision that follows directly from the shared schema versus database-per-tenant tradeoff.

A checklist before you call it done

  1. No plan name appears in application code. Grep for your plan codes; the only hits should be in seed data and billing webhooks.
  2. Every feature key has a kind, a default and a hard cap.
  3. Every grant records source and, where applicable, an expiry.
  4. Limit checks exist in both canCreate and isOverLimit form.
  5. Background jobs re-check entitlements at execution, not enqueue.
  6. The failure mode is documented per feature: open for reads, closed for spend.
  7. Support can grant, revoke and expire an override from the admin console without an engineer.
  8. Adding a new plan is one insert into plans and N inserts into plan_features, with zero deploys.

That last line is the actual test. If launching a new tier requires a release, the plan name is still in the code somewhere and you will find it during the next pricing change, at the worst possible time. If you want a second set of eyes on an entitlement layer before it hardens, talk to us — it is a large part of what we build.

Frequently asked

What is the difference between entitlements and permissions in a SaaS app?

Permissions answer whether a user is allowed to do something and are managed by the tenant's own admin. Entitlements answer whether the account has paid for the capability and are managed by you through billing. Keep them as two separate checks that both must pass, otherwise a downgrade can silently strip roles or a role change can grant a paid feature.

Should feature flags and entitlements use the same system?

No. Feature flags are for rollout and experimentation and are meant to be temporary, owned by engineering. Entitlements are permanent commercial contracts tied to what a customer pays for. Sharing one system means a flag cleanup can accidentally delete a paid capability. Use separate stores; it is fine for both to be read through one resolver in your code.

How do you handle a downgrade when the customer already exceeds the new plan limit?

Enforce softly on existing data and hard on new data. Resources over the limit become read-only rather than deleted, creating new ones is blocked until the count drops below the limit, and the admin gets a list of what to archive. Deleting data on downgrade destroys records customers often need for tax, audit or legal reasons.

Where should entitlement checks live in a multi-tenant architecture?

In the control plane, shared across tenants, with a short-lived cache per tenant. If you store entitlement tables inside each tenant database on a database-per-tenant fleet, adding a single feature key becomes a migration run against every database. Read from shared tables and cache the resolved snapshot on the account or session.

How do you meter usage-based limits without slowing down every request?

For most limits, an atomic increment in Postgres with the limit checked on the returned value is correct and fast enough. For high-volume soft limits, use a Redis counter flushed periodically. For prepaid balances such as AI spend, use reserve-and-commit: reserve an estimate before the call, commit the actual cost after, and release the difference.