
SaaS Entitlements & Usage Limits: Turn a Stripe Plan Into Enforced Features and Quotas (2026)
An entitlement turns a subscription into enforced access. Split it in two: boolean feature gates (does the plan include X?) via Stripe Entitlements, synced off the active_entitlement_summary.updated webhook into a cache; and numeric usage quotas (how much this period?) via Billing Meters plus your own atomic counters. Enforce both server-side, per request.
Two problems billing tutorials conflate: feature gates vs usage quotas
A feature gate is a boolean — does the plan include feature X? A usage quota is numeric — how much of X this period? They run on different machinery: gates come from a webhook-synced cache, quotas from atomic per-tenant counters. Most SaaS need both, evaluated server-side at request time. This post is the enforcement layer most tutorials skip.
Most billing tutorials stop at the checkout. You wire up Stripe subscriptions, the webhook fires, the customer pays, and… then what? The plan exists in Stripe, but your product still treats everyone the same. The layer that turns a plan into enforced access is the one almost nobody writes about, and it’s the one I spend the most time on in production at Nixbly.
Here’s the first thing to get straight: there are two separate enforcement problems, and people treat them as one knob. They’re not.
A feature gate is a boolean. Does this plan include feature X? Export to CSV, SSO, advanced analytics, the API itself. The answer is yes or no, and it’s derived from what the customer bought.
A usage quota is numeric. How much of X this period? API calls, seats, projects, tokens, GB stored. The answer is a count compared against a limit, evaluated at request time.
Most real SaaS need both, and they are different mechanisms. A boolean lives happily in a cache and changes only when the subscription changes. A counter changes on every single request and resets on the billing-period boundary. Conflate them and you’ll either over-engineer the boolean or under-engineer the counter (usually the latter, which is how customers blow past limits you thought you were enforcing).
One more framing point. An entitlement decision is based on subscription + packaging rules, not just auth/identity. Authentication tells you who the request is; the entitlement tells you what their plan lets them do. The discipline that matters: billing state must match product access in real time, enforced in the product. You do not reconcile access in finance three weeks later by reading a CSV of who churned. If they downgraded, the product should already know.
This post completes a chain. Subscriptions set up the plan. Usage-based billing with meters measures the numbers. Billing metrics tell you what the money is doing. This is the layer that enforces all of it.
Two mechanisms, not one knob
Feature gates (boolean)
- "Does the plan include feature X?"
- Stripe Entitlements: features → products → active entitlements
- Synced off the active_entitlement_summary.updated webhook into a cache
- Changes only when the subscription changes
- Example: SSO, export API, advanced analytics
Usage quotas (numeric)
- "How much X this period?"
- Stripe Billing Meters + your own atomic counter
- Counter per tenant per period, checked at request time
- Changes on every request; resets at period boundary
- Example: API calls, seats, projects, tokens
How does Stripe Entitlements solve the feature-gate half?
Stripe Entitlements maps the features of your service to Stripe products. The vocabulary is exact, so let’s use it exactly.
A Feature is a monetizable capability. It has an id, a name, a lookup_key (the stable string you reference in code), and metadata. The lookup_key is the important one: it’s what your application keys off, so it survives even if the display name changes.
You attach features to products. That’s the product features relationship. When a customer subscribes to a product, Stripe automatically creates an active entitlement to each of that product’s features for that customer. You never compute “is this plan’s bundle of features X, Y, Z” by hand — Stripe derives the active entitlements from the subscription.
Creating a feature and attaching it to a product:
// 1. Create a feature (the monetizable capability)
const feature = await stripe.entitlements.features.create({
name: 'Advanced analytics',
lookup_key: 'advanced_analytics', // stable key you reference in code
});
// 2. Attach it to a product → subscribers get an active entitlement
await stripe.products.createFeature('prod_ProTier', {
entitlement_feature: feature.id,
});
To read what a given customer is entitled to, you call the active entitlements endpoint. Each item carries the feature’s lookup_key:
curl -G https://api.stripe.com/v1/entitlements/active_entitlements \
-u "$STRIPE_SECRET_KEY:" \
-d customer=cus_123
That’s the whole feature-gate half. Entitlements answers the boolean “does this customer have feature X?” derived directly from billing. You don’t maintain a plan-to-feature mapping in your own database that drifts out of sync with Stripe.
The hard limit, and it’s worth repeating because people miss it: Entitlements does not track usage quotas. It is purely feature access. There is no counter, no “calls remaining,” no numeric limit anywhere in the model. For numbers you bring your own machinery (next section). The official docs: Stripe Entitlements and the Active Entitlement API.
Syncing entitlements: the webhook into a cache (and why you never call Stripe per request)
The cardinal rule: never call Stripe in the request path. A /v1/entitlements/active_entitlements round-trip on every API call adds latency you can’t afford and walks straight into rate limits the first time you get traffic. The entitlement check has to be a local lookup measured in microseconds.
So you sync. Stripe fires entitlements.active_entitlement_summary.updated — the Active Entitlement Summary — each time a customer’s active entitlements change (subscription created, updated, canceled). You listen for that event, fetch the customer’s current active entitlements, and write the set of lookup_keys into a cache keyed by tenant.
// POST /webhooks/stripe — signature already verified (see linked post)
async function handleStripeWebhook(event) {
if (event.type === 'entitlements.active_entitlement_summary.updated') {
const customerId = event.data.object.customer;
// Fetch the customer's current active entitlements
const entitlements = await stripe.entitlements.activeEntitlements.list({
customer: customerId,
});
const featureKeys = entitlements.data.map((e) => e.lookup_key);
// Overwrite the cached set for this tenant
const tenantId = await tenantIdForCustomer(customerId);
await redis.del(`ent:${tenantId}`);
if (featureKeys.length) {
await redis.sadd(`ent:${tenantId}`, ...featureKeys);
}
}
}
A few things that are load-bearing here. First, you verify the webhook signature before you trust a single byte of this — if that fails you sync the wrong customer’s access, which is a security bug, not a billing bug. (If you’ve hit Webhook signature verification failed, here’s the fix.) Second, this handler does a full overwrite of the cached set, not a merge — del then sadd — so a removed feature actually disappears. A merge that only adds keys is how stale access creeps in.
One timing nuance: feature changes on existing subscriptions apply at the start of the next billing period, so the summary event fires then, not the instant the customer clicks “downgrade.” Your sync is correct either way — you just react to the event whenever Stripe sends it.
The #1 entitlements bug in production is a cache you populate but never invalidate. The webhook is your invalidation. If you skip it, paying customers keep features they downgraded out of, and churned customers keep access for free.
Build the entitlements layer in four steps
- Define featuresCreate each capability as a Stripe Feature with a stable lookup_key you reference in code.
- Map to productsAttach features to products (product features). Subscribing creates active entitlements automatically.
- Sync via webhookOn entitlements.active_entitlement_summary.updated, fetch active entitlements and overwrite the tenant's cached set.
- Enforce in middlewareCheck the cached set per request with requireEntitlement; never call Stripe in the request path.
How do you enforce usage quotas at request time?
Now the numeric half. It’s tempting to think Stripe Billing Meters handle this too — they don’t, and the distinction is the whole game.
Billing Meters bill the numbers. You report meter events, Stripe aggregates them, and at invoice time the customer pays for what they used. That’s accounting. Meters do not block a request in real time — they’re not in your hot path and they aren’t meant to be. If a customer on a 10,000-call plan fires 50,000 calls, the meter will happily bill all 50,000. Stripe didn’t stop them; that’s your job.
For enforcement you need your own counter, per tenant per meter per period, checked at request time against the plan’s limit. And the increment has to be atomic. This is the part people get wrong: a read-modify-write in application code means two concurrent requests both read “9,999,” both decide they’re under the limit, and both write “10,000” — you just let two requests through on the last slot. Use an atomic primitive like Redis INCR so the increment-and-read is a single operation.
// Atomic increment, keyed per tenant / meter / period
function counterKey(tenantId, meterKey, period) {
return `usage:${tenantId}:${meterKey}:${period}`;
}
async function bumpUsage(tenantId, meterKey) {
const period = currentBillingPeriod(tenantId); // e.g. "2026-06"
const key = counterKey(tenantId, meterKey, period);
const count = await redis.incr(key); // atomic
// expire a bit after the period ends so it resets naturally
await redis.expireat(key, periodEndEpoch(tenantId) + 86400);
return count;
}
Key the counter per tenant, per meter, per period, so it resets cleanly on the billing-period boundary. On every allowed request you do two things: atomically bump the local counter (for enforcement) and report a Stripe meter event (for billing). The local counter is the brake; the meter is the invoice.
And decide soft vs hard limits deliberately. Warn around 80% — an email, a banner, a header — and block at 100%. Surprising a customer with a hard wall they had no warning about is a support ticket and a churn risk, not an enforcement win.
The middleware pattern: requireEntitlement + enforceQuota
Two guards, applied per route or per action. Both evaluate server-side — permission flags (which is exactly what an entitlement is) are the one flavor of feature flag you must never trust the client to evaluate. A flag in the browser is a suggestion; the gate lives on the server.
// Guard 1: boolean feature gate against the cached entitlement set
function requireEntitlement(featureKey) {
return async (req, res, next) => {
const tenantId = req.tenant.id;
const has = await redis.sismember(`ent:${tenantId}`, featureKey);
if (!has) {
return res.status(403).json({ error: `feature_not_in_plan:${featureKey}` });
}
next();
};
}
// Guard 2: numeric quota — atomic increment vs plan limit, then meter event.
// limit can be a number or a (req) => number resolver, so per-tenant
// limits can be read off the request at call time.
function enforceQuota(meterKey, limit) {
return async (req, res, next) => {
const tenantId = req.tenant.id;
const max = typeof limit === 'function' ? limit(req) : limit;
const used = await bumpUsage(tenantId, meterKey); // atomic INCR
if (used > max) {
return res.status(429).json({ error: `quota_exceeded:${meterKey}`, limit: max });
}
// Allowed: report to Stripe for billing.
// Payload keys must match the meter's configured customer_mapping /
// value_settings (see the usage-based-billing post where meters are defined).
await stripe.billing.meterEvents.create({
event_name: meterKey,
payload: { stripe_customer_id: req.tenant.stripeCustomerId, value: '1' },
});
next();
};
}
// Applied per route. The limit resolver runs per request, not at registration.
app.post(
'/api/v1/export',
authenticate,
requireEntitlement('export_api'),
enforceQuota('api_calls', (req) => req.tenant.plan.apiCallLimit),
exportHandler,
);
The runtime flow is always the same: authenticate → evaluate plan + usage → allow or deny → record usage for future checks. The “record usage” step is not optional and it’s not an afterthought — if you don’t increment on allow, the next check reads a stale count and your limit means nothing. Note the increment happens before the limit comparison so concurrency can’t sneak through; if you’d rather not “spend” a slot on a rejected request you can decrement on the 429, but for most quotas the simpler over-count-by-at-most-one behavior is fine.
The request lifecycle
Production gotchas that bite: cache invalidation, fail-open vs fail-closed, downgrades
These are the ones that cost me real debugging time. None are exotic; all of them bite.
Cache, but invalidate on the webhook. Stale cache equals wrong access, full stop. This is the single most common bug in the whole pattern. The active_entitlement_summary.updated event is your invalidation signal — if you populate the cache and never wire that event, you ship a system that’s correct on day one and wrong by week two.
Atomic increments, always. Concurrent requests must not be able to collectively exceed the limit. A GET then SET is not atomic. INCR is.
Choose fail-open vs fail-closed deliberately — per gate. When your entitlement store is unreachable, what happens? For billing/feature checks, fail-open is usually right: a Redis blip should not break access for a customer who’s paying you. For security-sensitive gates, fail-closed: if you can’t confirm the customer is entitled to a sensitive capability, deny. The mistake is having no policy and letting whatever the framework does by default decide for you.
Downgrade and cancel grace. Entitlement changes apply at the next billing period, so decide explicitly: do you revoke immediately on cancel, or let them keep access through the period they paid for? Both are defensible; pick one on purpose and make the UI say which.
Enforce per-tenant. In multi-tenant systems, never let one tenant’s cached set or usage counter leak into another. Every cache key and counter key includes the tenant ID, no exceptions. A missing tenant prefix is a cross-tenant access bug waiting to happen.
Entitlements production checklist
Should you build, use Stripe Entitlements, or buy a vendor?
Three honest paths, and I’m not selling any of them.
Roll your own entitlements service. Full control, no third-party in the request path, and you own the data model. The cost is that you build and maintain the sync, the cache, and the packaging rules yourself.
Use Stripe Entitlements for the feature-access half. If you already bill on Stripe, the features-to-products mapping and the active-entitlement derivation come essentially for free, and the webhook gives you a clean sync signal. It only covers the boolean half, though.
Adopt a vendor. Schematic, Stigg, LaunchDarkly, Lago, and Flexprice all play in this space at different points (some lean feature-flagging, some lean metering/billing). They can save you build time, especially if your packaging changes weekly and product wants to ship new plans without an engineer.
The pragmatic split I keep landing on: Stripe Entitlements for feature gates + my own atomic counters for quotas. Stripe already knows the subscription, so let it derive the booleans; the numeric limits are tightly coupled to my request path and latency budget, so I keep those in-house.
Whatever you pick, the durable thing here is the pattern — two guards, a webhook-synced cache, atomic counters, server-side enforcement — not any single tool. Match the build/buy split to your team’s size and how fast your packaging changes. More guides in /stripe-billing/.
FAQ
Does Stripe Entitlements track usage limits? No. It tracks boolean feature access only — “does this customer have feature X?” For numeric limits you pair it with Billing Meters (for billing) and your own atomic counters (for enforcement).
How fast do entitlement changes take effect? Feature changes on existing subscriptions apply at the start of the next billing period, and the entitlements.active_entitlement_summary.updated event fires then. Your cache sync reacts to the event whenever Stripe sends it.
Why not just read the subscription object directly on every request? Latency and rate limits. A Stripe API call in the hot path is a non-starter at any real traffic. Sync via the webhook into a local cache and read that instead.
Should the entitlement check fail open or closed? Decide per gate. Billing and feature checks usually fail-open so a store outage doesn’t break paying customers; security-sensitive gates fail-closed.
What’s the single most common bug? A cache you populate but never invalidate on entitlements.active_entitlement_summary.updated. It’s correct on day one and silently wrong soon after.
Ship the layer most tutorials skip
The whole thing fits in your head: feature gates (Stripe Entitlements, synced into a cache off the webhook) plus usage quotas (Billing Meters for billing, your own atomic counters for enforcement), both evaluated server-side, per request, per tenant. Two guards — requireEntitlement and enforceQuota — applied per route, recording usage on allow.
That completes the billing story: subscriptions → usage metering → billing metrics → enforcement. The plan a customer buys finally becomes the access they actually get.
My opinion, earned the hard way: enforce in the product, in real time. Don’t reconcile access in finance weeks later by diffing spreadsheets — by then a churned customer has been using your Pro features for free and a downgraded one is filing a confused support ticket. Pick your build/buy split, write the middleware, and ship the layer most tutorials skip.