SaaS Billing Architecture in 2026: Subscription Payments Done Right
Early billing decisions shape your product forever. A practical guide to subscription architecture, Stripe integration, and separating billing from entitlements.
TL;DR
- Separate billing infrastructure (Stripe) from product entitlements (your application). Never store feature access in Stripe.
- Stripe manages Products, Prices, and Subscriptions. Your app manages Plans, Features, and Usage Limits.
- This separation lets you change pricing models without rewriting your application.
- Use webhooks to sync subscription state—don’t rely on client-side session data.
- Build entitlement logic as a separate service that answers “can this user do X?”
- Early architecture decisions here have lasting impact—get it right from the start.
The Architecture Principle
The most important billing architecture decision:
Stripe handles billing. Your application handles entitlements.
| Stripe’s Responsibility | Your App’s Responsibility |
|---|---|
| Products and Prices | Plans and Features |
| Subscriptions | Entitlements |
| Payment processing | Feature access control |
| Invoicing | Usage limits |
| Revenue recovery | User experience |
Why This Matters
Without separation:
- Changing pricing requires code changes
- Adding new plans means database migrations
- Feature flags are scattered across systems
- Testing becomes painful
With separation:
- Change pricing in Stripe dashboard
- Add plans with minimal code changes
- Centralized entitlement logic
- Clean, testable architecture
Data Model
Stripe Side
┌─────────────────────┐
│ Product │ ← What you're selling (e.g., "Pro Plan")
│ (Stripe construct) │
└──────────┬──────────┘
│
│ has many
▼
┌─────────────────────┐
│ Price │ ← How much it costs ($99/month, $999/year)
│ (Stripe construct) │
└──────────┬──────────┘
│
│ used by
▼
┌─────────────────────┐
│ Subscription │ ← Customer's active billing
│ (Stripe construct) │
└─────────────────────┘
Your Application Side
┌─────────────────────┐
│ Plan │ ← Your plan definition
│ (Your database) │
└──────────┬──────────┘
│
│ has many
▼
┌─────────────────────┐
│ Feature │ ← What's included
│ (Your database) │
└──────────┬──────────┘
│
│ defines
▼
┌─────────────────────┐
│ Entitlement │ ← What user can access
│ (Your database) │
└─────────────────────┘
Linking the Two
// Plan model in your database
interface Plan {
id: string;
name: string; // "Pro Plan"
stripePriceIds: string[]; // Links to Stripe prices
features: Feature[]; // What's included
limits: UsageLimit[]; // Usage constraints
}
// Subscription model in your database
interface UserSubscription {
userId: string;
planId: string; // Your plan
stripeSubscriptionId: string; // Stripe's subscription
status: 'active' | 'past_due' | 'canceled';
currentPeriodEnd: Date;
}
Stripe Integration
Product and Price Setup
// Create product in Stripe
const product = await stripe.products.create({
name: 'Pro Plan',
description: 'Everything you need to scale',
metadata: {
internal_plan_id: 'plan_pro', // Link to your system
},
});
// Create prices for the product
const monthlyPrice = await stripe.prices.create({
product: product.id,
unit_amount: 9900, // $99.00
currency: 'usd',
recurring: { interval: 'month' },
metadata: {
billing_period: 'monthly',
},
});
const yearlyPrice = await stripe.prices.create({
product: product.id,
unit_amount: 99900, // $999.00 (2 months free)
currency: 'usd',
recurring: { interval: 'year' },
metadata: {
billing_period: 'yearly',
},
});
Subscription Creation
async function createSubscription(
customerId: string,
priceId: string,
userId: string
) {
// Create subscription in Stripe
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
payment_behavior: 'default_incomplete',
expand: ['latest_invoice.payment_intent'],
metadata: {
user_id: userId, // Link to your user
},
});
// Don't update your database here!
// Wait for webhook confirmation
return {
subscriptionId: subscription.id,
clientSecret: subscription.latest_invoice.payment_intent.client_secret,
};
}
Webhook Handler
// Always use webhooks to update subscription state
async function handleStripeWebhook(event: Stripe.Event) {
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
await syncSubscription(event.data.object);
break;
case 'customer.subscription.deleted':
await cancelSubscription(event.data.object);
break;
case 'invoice.payment_failed':
await handlePaymentFailure(event.data.object);
break;
case 'invoice.paid':
await handlePaymentSuccess(event.data.object);
break;
}
}
async function syncSubscription(stripeSubscription: Stripe.Subscription) {
const userId = stripeSubscription.metadata.user_id;
const planId = getPlanFromPrice(stripeSubscription.items.data[0].price.id);
await db.userSubscription.upsert({
where: { userId },
update: {
planId,
stripeSubscriptionId: stripeSubscription.id,
status: mapStripeStatus(stripeSubscription.status),
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
},
create: {
userId,
planId,
stripeSubscriptionId: stripeSubscription.id,
status: mapStripeStatus(stripeSubscription.status),
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
},
});
// Refresh entitlements
await refreshEntitlements(userId);
}
Entitlement Service
The Core Question
Your entitlement service answers: “Can this user do X?”
class EntitlementService {
async canAccess(userId: string, feature: string): Promise<boolean> {
const subscription = await this.getActiveSubscription(userId);
if (!subscription) {
return this.isFreeTierFeature(feature);
}
const plan = await this.getPlan(subscription.planId);
return plan.features.includes(feature);
}
async getLimit(userId: string, resource: string): Promise<number> {
const subscription = await this.getActiveSubscription(userId);
if (!subscription) {
return this.getFreeTierLimit(resource);
}
const plan = await this.getPlan(subscription.planId);
return plan.limits[resource] ?? Infinity;
}
async checkUsage(userId: string, resource: string): Promise<UsageResult> {
const limit = await this.getLimit(userId, resource);
const current = await this.getCurrentUsage(userId, resource);
return {
limit,
current,
remaining: limit - current,
canUse: current < limit,
};
}
}
Usage in Application
// Middleware for feature access
async function requireFeature(feature: string) {
return async (req: Request, res: Response, next: NextFunction) => {
const canAccess = await entitlementService.canAccess(req.userId, feature);
if (!canAccess) {
return res.status(403).json({
error: 'upgrade_required',
message: 'This feature requires a paid plan',
upgradeUrl: '/pricing',
});
}
next();
};
}
// Usage in routes
app.post('/api/export', requireFeature('export'), exportController);
app.post('/api/ai', requireFeature('ai_features'), aiController);
Usage-Based Billing
Recording Usage
async function recordUsage(
userId: string,
resource: string,
quantity: number
) {
// Record in your database
await db.usage.create({
data: {
userId,
resource,
quantity,
timestamp: new Date(),
},
});
// If using Stripe metered billing
const subscription = await getActiveSubscription(userId);
const subscriptionItem = getMeteredItem(subscription, resource);
if (subscriptionItem) {
await stripe.subscriptionItems.createUsageRecord(
subscriptionItem.id,
{
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: 'increment',
}
);
}
}
Overage Handling
async function checkAndRecordUsage(
userId: string,
resource: string,
quantity: number
): Promise<UsageResult> {
const usage = await entitlementService.checkUsage(userId, resource);
if (!usage.canUse) {
const plan = await getActivePlan(userId);
if (plan.allowOverage) {
// Record overage for billing
await recordOverage(userId, resource, quantity);
return { allowed: true, overage: true };
}
return { allowed: false, upgradeRequired: true };
}
await recordUsage(userId, resource, quantity);
return { allowed: true, remaining: usage.remaining - quantity };
}
Pricing Page Integration
Fetching Prices from Stripe
async function getPricingPageData() {
const plans = await db.plan.findMany({
include: { features: true },
});
// Fetch current prices from Stripe
const pricesWithStripe = await Promise.all(
plans.map(async (plan) => {
const stripePrices = await stripe.prices.list({
product: plan.stripeProductId,
active: true,
});
return {
...plan,
prices: stripePrices.data.map((p) => ({
id: p.id,
amount: p.unit_amount,
interval: p.recurring?.interval,
})),
};
})
);
return pricesWithStripe;
}
Customer Portal
async function createPortalSession(userId: string) {
const user = await db.user.findUnique({ where: { id: userId } });
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.APP_URL}/settings/billing`,
});
return session.url;
}
Testing Strategy
Mock Entitlement Service
// Test helper
function createTestEntitlements(overrides: Partial<Entitlements> = {}) {
return {
canAccess: jest.fn().mockResolvedValue(true),
getLimit: jest.fn().mockResolvedValue(100),
checkUsage: jest.fn().mockResolvedValue({ canUse: true, remaining: 50 }),
...overrides,
};
}
// In tests
it('should block access for unpaid users', async () => {
const entitlements = createTestEntitlements({
canAccess: jest.fn().mockResolvedValue(false),
});
// Test your feature with blocked access
});
Implementation Checklist
Database Design
- Plans table with features and limits
- User subscriptions table linking to Stripe
- Usage tracking table
- Separate entitlements logic
Stripe Integration
- Product and price setup
- Subscription creation flow
- Webhook handler for all relevant events
- Customer portal integration
Entitlement Service
- Feature access checking
- Usage limit enforcement
- Overage handling
- Caching for performance
Testing
- Mock entitlement service for tests
- Stripe webhook test mode
- End-to-end subscription flow tests
FAQ
Should I store prices in my database?
Store plan definitions and feature mappings in your database. Let Stripe be the source of truth for actual prices. This allows you to change prices in Stripe without code changes.
How do I handle plan changes?
Use Stripe’s proration settings. Update your subscription record via webhook, then refresh entitlements.
What about lifetime deals?
Create a special plan with no Stripe subscription. Mark entitlements as “lifetime” with no expiration.
How do I handle failed payments?
Use webhooks to mark subscriptions as past_due. Implement grace periods in your entitlement logic. Use Stripe’s Smart Retries for recovery.
Should I cache entitlements?
Yes, for performance. Cache with short TTL (1-5 minutes). Invalidate on subscription changes.
How do I test locally?
Use Stripe CLI to forward webhooks to localhost. Use test mode API keys. Create test customers and subscriptions.
Sources & Further Reading
- SaaS Subscription Architecture 101 — Architectural principles
- Stripe Billing Documentation — Official Stripe docs
- Stripe SaaS Integration Guide — SaaS-specific patterns
- Stripe Subscriptions Guide — Subscription fundamentals
- Stripe Subscription Integration — Implementation guide
- Pricing Page Design — Related: pricing UI
- Pricing Experiments — Related: testing prices
Interested in our research?
We share our work openly. If you'd like to collaborate or discuss ideas — we'd love to hear from you.
Get in Touch