chitecture consists of three layers:
- Billing Integration Layer: Listens to webhooks from the billing provider to sync subscription and usage events.
- Entitlement Service: Maintains the authoritative state of user permissions and usage counters. Exposes a low-latency API for enforcement.
- Enforcement Middleware: Intercepts requests, evaluates entitlements, and applies rate limiting or feature gating.
Database Schema Design
Use a relational schema that supports flexible feature definitions and usage tracking.
// Prisma Schema Example
model Plan {
id String @id @default(uuid())
name String
billingId String @unique // Provider SKU
features Feature[]
limits Limit[]
createdAt DateTime @default(now())
}
model Feature {
id String @id @default(uuid())
key String @unique // e.g., "api_access", "export_csv"
label String
planId String
plan Plan @relation(fields: [planId], references: [id])
entitlements Entitlement[]
}
model Limit {
id String @id @default(uuid())
planId String
plan Plan @relation(fields: [planId], references: [id])
metric String // e.g., "api_calls", "storage_gb"
maxValue Int
period String // "month", "year", "lifetime"
}
model Entitlement {
id String @id @default(uuid())
userId String
featureId String
feature Feature @relation(fields: [featureId], references: [id])
grantedAt DateTime @default(now())
revokedAt DateTime?
@@unique([userId, featureId])
}
model Usage {
id String @id @default(uuid())
userId String
metric String
value Int
periodStart DateTime
periodEnd DateTime
updatedAt DateTime @updatedAt
@@unique([userId, metric, periodStart])
}
Entitlement Service Implementation
The service handles webhook processing and entitlement evaluation. It uses a cache for low-latency reads.
import { Redis } from 'ioredis';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const redis = new Redis();
export class EntitlementService {
// Evaluate if user has access to a feature
async hasAccess(userId: string, featureKey: string): Promise<boolean> {
const cacheKey = `entitlement:${userId}:${featureKey}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
return cached === 'true';
}
const hasAccess = await this.checkDatabase(userId, featureKey);
await redis.set(cacheKey, hasAccess.toString(), 'EX', 300); // 5 min TTL
return hasAccess;
}
private async checkDatabase(userId: string, featureKey: string): Promise<boolean> {
const count = await prisma.entitlement.count({
where: {
userId,
feature: { key: featureKey },
revokedAt: null,
},
});
return count > 0;
}
// Process webhook to sync plan changes
async handleSubscriptionUpdate(userId: string, planId: string, status: string) {
if (status === 'active') {
const features = await prisma.feature.findMany({
where: { plan: { id: planId } },
});
// Upsert entitlements
await prisma.entitlement.createMany({
data: features.map(f => ({
userId,
featureId: f.id,
})),
skipDuplicates: true,
});
// Invalidate cache
await this.invalidateCache(userId);
}
}
private async invalidateCache(userId: string) {
const keys = await redis.keys(`entitlement:${userId}:*`);
if (keys.length > 0) await redis.del(keys);
}
}
Usage Metering and Enforcement
For metered tiers, implement a token-bucket or sliding-window approach with idempotent ingestion.
export class MeteringService {
// Ingest usage event with idempotency
async recordUsage(
userId: string,
metric: string,
amount: number,
idempotencyKey: string
) {
// Check idempotency
const lockKey = `meter:lock:${idempotencyKey}`;
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 60);
if (!acquired) return; // Duplicate event
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
await prisma.usage.upsert({
where: {
userId_metric_periodStart: { userId, metric, periodStart },
},
update: {
value: { increment: amount },
},
create: {
userId,
metric,
value: amount,
periodStart,
periodEnd,
},
});
// Enforce limit check
const currentUsage = await this.getCurrentUsage(userId, metric);
const limit = await this.getLimit(userId, metric);
if (currentUsage > limit) {
// Trigger overage workflow or hard block
await this.handleOverage(userId, metric, currentUsage, limit);
}
}
async getCurrentUsage(userId: string, metric: string): Promise<number> {
const usage = await prisma.usage.findUnique({
where: {
userId_metric_periodStart: {
userId,
metric,
periodStart: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
},
},
});
return usage?.value || 0;
}
}
Enforcement Middleware
Apply entitlement checks at the API gateway or controller level.
import { Request, Response, NextFunction } from 'express';
export function requireFeature(featureKey: string) {
return async (req: Request, res: Response, next: NextFunction) => {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
const hasAccess = await entitlementService.hasAccess(userId, featureKey);
if (!hasAccess) {
return res.status(403).json({
error: 'Feature not available in current plan',
feature: featureKey,
upgrade_url: '/billing/upgrade',
});
}
next();
};
}
// Usage in route
app.post('/api/export', requireFeature('export_csv'), async (req, res) => {
// Export logic
});
Pitfall Guide
1. Hardcoding Tier Names in Logic
Mistake: Using string literals like if (plan === 'pro') throughout the codebase.
Impact: Renaming a tier in the billing provider breaks the application. Adding a new tier requires code changes.
Best Practice: Map tiers to feature keys. Evaluate against features, not plan names. Plan names are UI labels; features are system constraints.
2. Race Conditions in Usage Metering
Mistake: Reading usage, incrementing, and writing back without atomic operations.
Impact: Concurrent requests can overwrite each other, leading to under-counting usage and revenue leakage.
Best Practice: Use database atomic increments (increment: amount) or Redis atomic operations. Implement idempotency keys for ingestion endpoints.
3. Webhook Replay and Security
Mistake: Processing webhooks without signature verification or idempotency checks.
Impact: Attackers can replay events to grant free access or manipulate usage. Duplicate processing causes double counting.
Best Practice: Verify webhook signatures using the provider's secret. Implement idempotency keys for all webhook handlers. Store processed event IDs to prevent reprocessing.
4. Ignoring Period Boundaries
Mistake: Resetting usage counters based on subscription start date rather than calendar periods.
Impact: Users on monthly plans may experience misaligned billing cycles, causing confusion and support tickets. Complex math for prorated periods increases error risk.
Best Practice: Align metering periods with calendar months for standard plans. Use explicit period start/end timestamps. Document proration logic clearly.
5. Client-Side Enforcement
Mistake: Hiding UI elements based on plan but not checking permissions on the API.
Impact: Users can bypass UI restrictions via direct API calls, accessing premium features without payment.
Best Practice: UI gating is for UX; API enforcement is for security. Always validate entitlements server-side. Assume the client is compromised.
6. Cache Invalidation Delays
Mistake: Long cache TTLs for entitlements without invalidation triggers.
Impact: Users who upgrade or downgrade do not see changes immediately. Support teams must manually clear caches.
Best Practice: Use short TTLs (e.g., 5 minutes) combined with active invalidation on webhook events. Implement a "force refresh" mechanism for admin actions.
7. Lack of Audit Trails
Mistake: No logging of entitlement checks or usage updates.
Impact: Inability to debug billing disputes or security incidents. Compliance failures for financial data.
Best Practice: Log all entitlement evaluations and usage increments. Include user ID, feature/metric, result, and timestamp. Retain logs for audit periods.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple SaaS (Flat Tiers) | Entitlement Service + Webhooks | Decouples billing from app; allows instant feature changes. | Low infrastructure cost; high agility. |
| Usage-Based Pricing | Metering Service + Atomic Counters | Prevents race conditions; ensures accurate billing. | Moderate infra cost; reduces revenue leakage. |
| Marketplace / Multi-Tenant | Scoped Entitlements + RBAC | Supports per-tenant limits and hierarchical access. | Higher complexity; essential for isolation. |
| High-Volume API | Edge Enforcement + Cache | Minimizes latency; reduces load on core services. | Edge costs; improves UX and throughput. |
Configuration Template
Use a declarative configuration to define tiers and features. This can be stored in a database or a version-controlled config file.
// pricing.config.ts
export const PRICING_TIERS = {
starter: {
billingId: 'price_starter_monthly',
features: ['basic_auth', 'api_read'],
limits: {
api_calls: { max: 1000, period: 'month' },
storage_gb: { max: 1, period: 'lifetime' },
},
},
pro: {
billingId: 'price_pro_monthly',
features: ['basic_auth', 'api_read', 'api_write', 'export_csv'],
limits: {
api_calls: { max: 50000, period: 'month' },
storage_gb: { max: 10, period: 'lifetime' },
},
},
enterprise: {
billingId: 'price_enterprise_monthly',
features: ['basic_auth', 'api_read', 'api_write', 'export_csv', 'sso', 'sla'],
limits: {
api_calls: { max: Infinity, period: 'month' },
storage_gb: { max: Infinity, period: 'lifetime' },
},
},
};
Quick Start Guide
- Initialize Schema: Run Prisma migrations to create
Plan, Feature, Entitlement, and Usage tables.
- Setup Webhooks: Configure your billing provider to send events to
/api/webhooks/billing. Implement signature verification.
- Deploy Entitlement Service: Install the
EntitlementService and connect it to your database and Redis instance.
- Protect Routes: Add
requireFeature('feature_key') middleware to protected API endpoints.
- Test Flow: Create a test subscription, trigger webhooks, and verify that entitlements are granted and enforced correctly.
By implementing a dynamic pricing tier architecture, you eliminate the coupling between billing and application logic, reduce engineering overhead, and provide the business with the agility to experiment with pricing models without technical friction. This approach ensures scalable, secure, and accurate monetization of digital products.