Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 065 / 100

Entitlements and feature access: lessons from production

Connect billing to product features.

Expona Admin9 min read

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 handler
  • if (user.subscription.status === 'active') in your UI
  • if (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:

typescript

// 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:

typescript

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:

typescript

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:

typescript

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:

  1. active → payment fails
  2. past_due → Stripe retries for several days (configurable)
  3. unpaid → if all retries fail, subscription enters this state
  4. canceled → if manually canceled or after dunning exhausted

Entitlements policy with grace:

typescript

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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles