Entitlements and feature access: lessons from production
You've built a payment system that successfully charges customers and manages subscriptions. But when a customer upgrades their plan or a payment fails, does your product actually know what features they can access? Connecting billing to product features through entitlements is where theory meets reality, and getting it wrong causes angry support tickets and refund requests.
Over the past 64 days, we've covered the mechanics of Stripe integration—webhooks, idempotency, retry logic. Today we're tackling the layer that makes it matter to your users: entitlements. Entitlements and feature access bridges the gap between your billing system and your codebase, ensuring that what a customer paid for is exactly what they can use.
Why entitlements matter
Without a deliberate entitlements system, you'll find yourself with scattered feature checks throughout your codebase:
if (user.plan === 'pro')in your API handlerif (user.subscription.status === 'active')in your UIif (daysSinceSignup > 14)in your background job
This approach breaks down immediately. Plans change. Feature sets drift. You add a feature flag for a beta that should only work on certain plans. Six months later, you can't remember which customers should have that feature. Then a payment fails, and you're unsure whether they should still have read access while you wait for retry.
A proper entitlements system centralizes this logic, making it auditable and testable. It becomes the single source of truth: "Does user X have permission to do action Y?"
Store entitlements—Features per plan in your database
Your first step is designing what gets stored. We recommend a simple but extensible approach: a features table that describes what each plan provides, and a user_entitlements table (or view) that joins user subscription data to those features.
Schema approach:
// PostgreSQL schema
CREATE TABLE features (
id SERIAL PRIMARY KEY,
key VARCHAR(255) UNIQUE NOT NULL, -- 'exports', 'api_access', 'custom_branding'
name VARCHAR(255),
description TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE plan_features (
id SERIAL PRIMARY KEY,
stripe_product_id VARCHAR(255) NOT NULL,
feature_id INT REFERENCES features(id),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(stripe_product_id, feature_id)
);
CREATE TABLE user_entitlements (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id),
stripe_subscription_id VARCHAR(255),
feature_id INT REFERENCES features(id),
granted_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP, -- NULL means no expiration
UNIQUE(user_id, feature_id)
);
In your application code, you'd query this at startup (with caching) or on subscription change. Many teams find it cleaner to compute entitlements on every subscription webhook rather than maintaining a separate table—it's more reliable and easier to reason about.
In-memory representation:
interface PlanFeatures {
[stripePriceId: string]: string[]; // price ID to array of feature keys
}
// Example data
const planFeatures: PlanFeatures = {
'price_free': ['basic_reports'],
'price_pro_monthly': ['basic_reports', 'exports', 'api_access'],
'price_enterprise_yearly': ['basic_reports', 'exports', 'api_access', 'sso', 'custom_branding']
};
interface UserEntitlements {
userId: number;
stripeSubscriptionId: string;
features: Set<string>;
planName: string;
status: 'active' | 'past_due' | 'canceled' | 'incomplete';
currentPeriodEnd: Date;
}
Store this mapping somewhere your application can access it quickly—Redis, an in-memory cache, or a database query on subscription events. The key insight: entitlements should be computed once per subscription change, not on every feature check.
Sync from webhooks—Update on subscription changes
Your Stripe webhooks are already handling subscription events. This is where you update your entitlements.
Webhook handler pattern:
async function handleCustomerSubscriptionUpdated(event: Stripe.Event) {
const subscription = event.data.object as Stripe.Subscription;
// 1. Fetch the subscription details from Stripe
const fullSubscription = await stripe.subscriptions.retrieve(
subscription.id,
{ expand: ['items.data.price'] }
);
// 2. Extract price IDs from the subscription
const priceIds = fullSubscription.items.data.map(item => item.price.id);
// 3. Look up which features these prices grant
const entitledFeatures = new Set<string>();
for (const priceId of priceIds) {
const featuresForPrice = planFeatures[priceId] || [];
featuresForPrice.forEach(f => entitledFeatures.add(f));
}
// 4. Store or update entitlements for this user
const customerId = subscription.customer as string;
const user = await db.users.findByStripeCustomerId(customerId);
await db.userEntitlements.upsert({
userId: user.id,
stripeSubscriptionId: subscription.id,
features: Array.from(entitledFeatures),
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000)
});
// 5. Notify downstream systems (optional but recommended)
await eventBus.emit('entitlements_updated', {
userId: user.id,
features: Array.from(entitledFeatures)
});
}
Handle customer.subscription.created, customer.subscription.updated, and customer.subscription.deleted events. Each one should recalculate and persist the user's entitlements.
A critical detail: webhooks can arrive out of order. Always fetch the latest subscription state from Stripe before making decisions. Never trust only the webhook payload.
Check in code—hasFeature(user, 'exports') in one place
Now that you're syncing entitlements, you need a single function your application code calls to check permissions. This prevents the scattered checks we mentioned earlier.
Centralized entitlements service:
class EntitlementsService {
private cache: Map<number, UserEntitlements> = new Map();
async hasFeature(userId: number, featureKey: string): Promise<boolean> {
const entitlements = await this.getEntitlements(userId);
if (!entitlements) {
return false; // No subscription = no features
}
// Check if subscription is still active (handle grace period separately)
if (entitlements.status === 'canceled') {
return false;
}
return entitlements.features.has(featureKey);
}
async getEntitlements(userId: number): Promise<UserEntitlements | null> {
// Check in-memory cache first
if (this.cache.has(userId)) {
return this.cache.get(userId)!;
}
// Query database or Redis
const entitlements = await db.userEntitlements.findByUserId(userId);
if (entitlements) {
this.cache.set(userId, entitlements);
}
return entitlements || null;
}
invalidateCache(userId: number) {
this.cache.delete(userId);
}
}
// Usage in your API handlers
app.get('/api/reports/export', async (req, res) => {
const hasExportAccess = await entitlements.hasFeature(req.user.id, 'exports');
if (!hasExportAccess) {
return res.status(403).json({ error: 'Feature not available on your plan' });
}
// Proceed with export
});
By centralizing this check, you've created a single place where you can add logging, metrics, or experiment logic. Teams often add event tracking here to measure feature adoption across plans.
Cache aggressively, but invalidate on webhook events. A stale cache for a few seconds is far better than querying the database on every request.
Grace handling—Do not cut access instantly on failed payments
This is where production experience diverges from theory. When a payment fails, do not immediately revoke features. You will anger customers who are traveling, dealing with fraud alerts, or between payment methods.
Stripe subscriptions go through a state machine when payments fail:
active→ payment failspast_due→ Stripe retries for several days (configurable)unpaid→ if all retries fail, subscription enters this statecanceled→ if manually canceled or after dunning exhausted
Entitlements policy with grace:
class EntitlementsService {
// ... previous methods ...
async hasFeature(
userId: number,
featureKey: string,
options?: { ignoreGrace?: boolean }
): Promise<boolean> {
const entitlements = await this.getEntitlements(userId);
if (!entitlements) {
return false;
}
// Define grace period: 14 days after payment failure
const GRACE_PERIOD_DAYS = 14;
const now = new Date();
const gracePeriodEnd = new Date(entitlements.failedAt || now);
gracePeriodEnd.setDate(gracePeriodEnd.getDate() + GRACE_PERIOD_DAYS);
// If subscription is past_due or unpaid, check if we're still in grace
if (entitlements.status === 'past_due' || entitlements.status === 'unpaid') {
if (options?.ignoreGrace) {
return false; // Force revoke for sensitive operations
}
if (now <= gracePeriodEnd) {
// Still in grace period—allow access
return entitlements.features.has(featureKey);
} else {
// Grace period expired
return false;
}
}
if (entitlements.status === 'canceled') {
return false;
}
return entitlements.features.has(featureKey);
}
}
// Usage: allow reports but not exports during grace period
app.get('/api/reports', async (req, res) => {
const canAccess = await entitlements.hasFeature(req.user.id, 'reports');
if (!canAccess) return res.status(403).json({ error: 'Access denied' });
// Proceed
});
app.post('/api/exports', async (req, res) => {
// Exports are sensitive—don't allow during grace period
const canAccess = await entitlements.hasFeature(req.user.id, 'exports', {
ignoreGrace: true
});
if (!canAccess) return res.status(403).json({ error: 'Access denied' });
// Proceed
});
This approach keeps customers happy while giving your recovery emails and dunning system time to work. You can log grace-period access for analytics to understand who's most at risk of churning.
Common mistakes
- Checking Stripe directly in your handlers: Calling the Stripe API on every request kills performance. Cache entitlements and update them from webhooks instead.
- Trusting webhook payloads without verification: Always fetch the full object from Stripe if you need to make decisions. Webhooks can be stale or arrive out of order.
- Revoking access immediately on payment failure: You'll annoy loyal customers and damage retention. Implement grace periods.
- Storing feature lists in plan metadata: Stripe metadata is useful for display names, not for access control. Sync to your database where you control schema and indexing.
- Forgetting to invalidate cache on webhook: If you cache entitlements in memory, you must clear the cache when the subscription changes. Otherwise, upgrades won't take effect.
Key takeaways
- Build an entitlements system that maps subscriptions to features, computed once on subscription changes rather than on every check.
- Centralize all feature access checks into a single service to avoid scattered logic across your codebase.
- Sync entitlements from Stripe webhooks, always fetching the full subscription object to handle out-of-order events.
- Implement grace periods for failed payments so customers maintain access while retry logic runs, improving retention and trust.
- Cache entitlements aggressively but invalidate on webhook events to balance performance and correctness.






Comments
Loading comments…