How to Build a SaaS Billing System 2026: Complete Guide
Building a robust billing system is critical for any SaaS. Get it wrong and you'll have revenue leaks, unhappy customers, and accounting nightmares. Here's how to build one properly in 2026.
Architecture Overview
Modern SaaS billing combines several components:
- Payment provider (Stripe, Paddle, Lemon Squeezy)
- Subscription management (plans, tiers, add-ons)
- Usage metering (for usage-based pricing)
- Invoicing & receipts
- Customer portal (update payment, view invoices)
- Webhooks (sync subscription status to your database)
Step 1: Choose Your Stack
For 2026, Stripe is the gold standard. Best developer experience, most complete API, works globally.
Tech Stack:
- Payment provider: Stripe
- Backend: Next.js API routes or tRPC
- Database: PostgreSQL (store subscription metadata)
- Queue: Inngest or Trigger.dev (handle webhooks reliably)
Step 2: Design Your Pricing Model
Before code, define your pricing:
- Seat-based: Price per user (Linear, Notion)
- Usage-based: Price per API call, storage, etc. (Vercel, AWS)
- Tiered: Fixed price for feature bundles (Starter, Pro, Enterprise)
- Hybrid: Seats + usage (Slack, GitHub)
In your database:
CREATE TABLE subscriptions (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
plan_id TEXT, -- 'starter', 'pro', 'enterprise'
status TEXT, -- 'active', 'canceled', 'past_due'
current_period_start TIMESTAMP,
current_period_end TIMESTAMP,
cancel_at_period_end BOOLEAN DEFAULT false
);
Step 3: Create Stripe Products
In Stripe Dashboard, create Products for each plan:
- Starter: $19/month
- Pro: $49/month
- Enterprise: $199/month
Each Product gets a Price ID. Store these in your code:
const STRIPE_PRICES = {
starter: 'price_1234...', // Stripe Price ID
pro: 'price_5678...',
enterprise: 'price_9012...',
};
Step 4: Implement Checkout
Use Stripe Checkout (easiest) or Stripe Elements (custom UI).
// app/api/checkout/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { priceId, userId } = await req.json();
const session = await stripe.checkout.sessions.create({
customer_email: user.email,
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
success_url: `${process.env.APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.APP_URL}/pricing`,
metadata: { userId }, // Store for webhook
});
return Response.json({ url: session.url });
}
Step 5: Handle Webhooks
Stripe sends events (subscription created, payment failed, etc.). Handle them reliably:
// app/api/webhooks/stripe/route.ts
import { buffer } from 'micro';
export async function POST(req: Request) {
const sig = req.headers.get('stripe-signature')!;
const buf = await req.arrayBuffer();
const event = stripe.webhooks.constructEvent(Buffer.from(buf), sig, process.env.STRIPE_WEBHOOK_SECRET!);
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object);
break;
}
return Response.json({ received: true });
}
Step 6: Sync to Your Database
When webhooks fire, update your database:
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
await db.subscription.update({
where: { stripe_subscription_id: subscription.id },
data: {
status: subscription.status,
current_period_end: new Date(subscription.current_period_end * 1000),
cancel_at_period_end: subscription.cancel_at_period_end,
},
});
}
Step 7: Add Usage Metering (Optional)
For usage-based pricing, report usage to Stripe:
async function reportUsage(subscriptionItemId: string, quantity: number) {
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: 'increment',
});
}
Step 8: Build Customer Portal
Let customers manage their subscription:
export async function POST(req: Request) {
const { customerId } = await req.json();
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.APP_URL}/dashboard`,
});
return Response.json({ url: session.url });
}
Step 9: Enforce Access Control
Check subscription status before allowing features:
export async function requireActiveSubscription(userId: string) {
const sub = await db.subscription.findUnique({ where: { userId } });
if (!sub || sub.status !== 'active') {
throw new Error('Active subscription required');
}
return sub;
}
Best Practices
- Use webhook queues: Don't process webhooks inline — queue them (Inngest, Trigger.dev) for reliability
- Idempotency: Stripe webhooks can be delivered multiple times. Handle duplicates gracefully
- Test mode: Use Stripe test mode during development
- Proration: Stripe handles prorating upgrades/downgrades automatically
- Grace period: Don't immediately revoke access on payment failure — give 3-7 days
- Logs: Log all webhook events for debugging
Conclusion
Building a SaaS billing system requires careful handling of webhooks, database sync, and edge cases. Stripe handles the hard parts (PCI compliance, payment processing), but you must implement subscription logic, access control, and reliable webhook processing. Follow this guide and you'll have a production-ready billing system.