Stripe Billing for SaaS in 2026: Metered Usage, Subscriptions, and Proration
Implement Stripe billing for SaaS: subscription management, metered usage, proration handling, invoice customization, and webhook processing with TypeScript.
Stripe Billing for SaaS in 2026: Metered Usage, Subscriptions, and Proration
Billing is where most SaaS products quietly lose money. Not through fraud — through complexity. A customer upgrades mid-cycle: do they owe prorated charges? An API-usage customer spikes 10× in December: does your billing pipeline handle that? A churned customer disputes an invoice from three months ago: can you reconstruct exactly what they were charged and why?
Stripe Billing handles the complexity, but it still requires deliberate implementation. A webhook handler that doesn't handle idempotency corrupts your database. A subscription created without proration_behavior set correctly double-charges customers. This post covers production-ready Stripe Billing — the code you actually ship, not just the happy path.
Pricing Model Options
| Model | Best For | Stripe Implementation |
|---|---|---|
| Flat rate | Simple, predictable | Single price with type: recurring |
| Per seat | Team tools | quantity on subscription |
| Tiered (volume) | Discounts at scale | billing_scheme: tiered, tiers_mode: volume |
| Tiered (graduated) | Different rate per tier bucket | tiers_mode: graduated |
| Metered/usage | API calls, storage, tokens | usage_type: metered price |
| Hybrid | Seat fee + usage overage | Multiple prices on one subscription |
Customer and Subscription Setup
// src/billing/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-11-20.acacia',
typescript: true,
maxNetworkRetries: 3,
timeout: 10_000,
});
// src/billing/customers.ts
import { stripe } from './stripe';
import { db } from '@/db';
export async function createOrGetStripeCustomer(
userId: string,
email: string,
name: string,
): Promise<string> {
// Check if already exists
const existing = await db.query<{ stripe_customer_id: string }>(
'SELECT stripe_customer_id FROM users WHERE id = $1',
[userId],
);
if (existing.rows[0]?.stripe_customer_id) {
return existing.rows[0].stripe_customer_id;
}
// Create new Stripe customer
const customer = await stripe.customers.create({
email,
name,
metadata: {
userId,
createdAt: new Date().toISOString(),
},
});
// Store the ID immediately (idempotency guard)
await db.query(
'UPDATE users SET stripe_customer_id = $1 WHERE id = $2',
[customer.id, userId],
);
return customer.id;
}
Creating a Subscription
// src/billing/subscriptions.ts
interface SubscriptionParams {
customerId: string;
priceId: string; // From Stripe dashboard or price creation
quantity?: number; // For per-seat pricing
trialDays?: number;
couponId?: string;
metadata?: Record<string, string>;
}
export async function createSubscription(
params: SubscriptionParams,
): Promise<Stripe.Subscription> {
const { customerId, priceId, quantity, trialDays, couponId, metadata } = params;
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId, quantity: quantity ?? 1 }],
// Always set proration behavior explicitly
proration_behavior: 'create_prorations',
// Collect payment upfront (avoids declined card surprises)
payment_behavior: 'default_incomplete',
payment_settings: {
save_default_payment_method: 'on_subscription',
},
trial_period_days: trialDays,
discounts: couponId ? [{ coupon: couponId }] : undefined,
// Expand client_secret for frontend PaymentElement
expand: ['latest_invoice.payment_intent'],
metadata: {
...metadata,
createdAt: new Date().toISOString(),
},
});
return subscription;
}
// Frontend receives the client_secret and confirms payment
// with Stripe.js confirmPayment()
export function getSetupClientSecret(subscription: Stripe.Subscription): string | null {
const invoice = subscription.latest_invoice as Stripe.Invoice;
const pi = invoice.payment_intent as Stripe.PaymentIntent;
return pi?.client_secret ?? null;
}
🚀 SaaS MVP in 8 Weeks — Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment — all handled by one senior team.
- Week 1–2: Architecture design + wireframes
- Week 3–6: Core features built + tested
- Week 7–8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
Plan Upgrades and Downgrades
Proration is where billing gets complex. When a customer upgrades mid-cycle, they should be charged the difference for the remaining days. When they downgrade, they should receive a credit.
// src/billing/plan-changes.ts
export type ProrationType = 'immediate' | 'end_of_cycle';
export async function changePlan(
subscriptionId: string,
newPriceId: string,
type: ProrationType = 'immediate',
): Promise<Stripe.Subscription> {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const currentItem = subscription.items.data[0];
if (type === 'immediate') {
// Prorate and charge/credit immediately
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: currentItem.id, price: newPriceId }],
proration_behavior: 'always_invoice', // Creates invoice immediately
proration_date: Math.floor(Date.now() / 1000), // Now
});
} else {
// Change takes effect at next renewal, no charge today
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: currentItem.id, price: newPriceId }],
proration_behavior: 'none',
billing_cycle_anchor: 'unchanged',
});
}
}
// Preview what a plan change would cost before confirming
export async function previewPlanChange(
customerId: string,
subscriptionId: string,
newPriceId: string,
): Promise<{ immediateCharge: number; nextInvoice: number }> {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const proration_date = Math.floor(Date.now() / 1000);
const invoice = await stripe.invoices.retrieveUpcoming({
customer: customerId,
subscription: subscriptionId,
subscription_items: [
{ id: subscription.items.data[0].id, price: newPriceId },
],
subscription_proration_behavior: 'create_prorations',
subscription_proration_date: proration_date,
});
// immediateCharge: amount due today (proration)
// nextInvoice: what the next regular invoice will be
const prorationAmount = invoice.lines.data
.filter((line) => line.proration)
.reduce((sum, line) => sum + line.amount, 0);
return {
immediateCharge: Math.max(0, prorationAmount) / 100,
nextInvoice: invoice.amount_remaining / 100,
};
}
Metered Usage Billing
For API-usage-based pricing (tokens, API calls, GB transferred):
// src/billing/usage.ts
// Report usage to Stripe at the end of each billing period
// or in real-time for live metering
export async function reportUsage(
subscriptionItemId: string,
quantity: number,
action: 'increment' | 'set' = 'increment',
): Promise<void> {
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity,
timestamp: Math.floor(Date.now() / 1000),
action, // 'increment' adds to existing usage; 'set' replaces it
});
}
// Batch reporting for high-volume usage (avoid API rate limits)
export class UsageBuffer {
private buffer = new Map<string, number>(); // subscriptionItemId → usage
increment(subscriptionItemId: string, quantity: number): void {
const current = this.buffer.get(subscriptionItemId) ?? 0;
this.buffer.set(subscriptionItemId, current + quantity);
}
// Call every 5 minutes via cron
async flush(): Promise<void> {
const entries = Array.from(this.buffer.entries());
this.buffer.clear();
await Promise.allSettled(
entries.map(([itemId, quantity]) =>
reportUsage(itemId, quantity, 'increment'),
),
);
}
}
export const usageBuffer = new UsageBuffer();
// Usage in your API handler:
// usageBuffer.increment(user.stripeSubscriptionItemId, tokensUsed);

💡 The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments — with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity — you own everything
Recommended Reading
Webhook Handler (Production-Ready)
The webhook handler is the most critical part of your billing implementation. It must be idempotent, handle failures gracefully, and process events in order.
// src/api/webhooks/stripe.ts (Fastify route)
import type { FastifyRequest, FastifyReply } from 'fastify';
import { stripe } from '@/billing/stripe';
import { processStripeEvent } from '@/billing/event-processor';
import { db } from '@/db';
export async function stripeWebhookHandler(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const signature = request.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
// Verify webhook signature — ALWAYS do this
event = stripe.webhooks.constructEvent(
request.rawBody!, // Must be raw bytes, not parsed JSON
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
reply.status(400).send({ error: 'Invalid signature' });
return;
}
// Idempotency: skip if already processed
const alreadyProcessed = await db.query<{ processed: boolean }>(
'SELECT true AS processed FROM stripe_events WHERE event_id = $1',
[event.id],
);
if (alreadyProcessed.rows[0]?.processed) {
reply.status(200).send({ received: true, duplicate: true });
return;
}
// Process asynchronously — Stripe expects 200 within 30 seconds
reply.status(200).send({ received: true });
// Handle the event (after responding)
processStripeEvent(event).catch((err) => {
logger.error({ err, eventId: event.id, type: event.type }, 'Webhook processing failed');
// Dead letter queue or retry mechanism here
});
}
// src/billing/event-processor.ts
export async function processStripeEvent(event: Stripe.Event): Promise<void> {
// Record event as processing
await db.query(
`INSERT INTO stripe_events (event_id, type, created_at, status)
VALUES ($1, $2, to_timestamp($3), 'processing')
ON CONFLICT (event_id) DO NOTHING`,
[event.id, event.type, event.created],
);
try {
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
await handleSubscriptionChange(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object as Stripe.Subscription);
break;
case 'invoice.payment_succeeded':
await handleInvoicePaid(event.data.object as Stripe.Invoice);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
case 'customer.subscription.trial_will_end':
await sendTrialEndingEmail(event.data.object as Stripe.Subscription);
break;
default:
logger.debug({ type: event.type }, 'Unhandled Stripe event');
}
// Mark as processed
await db.query(
`UPDATE stripe_events SET status = 'processed', processed_at = now()
WHERE event_id = $1`,
[event.id],
);
} catch (err) {
await db.query(
`UPDATE stripe_events SET status = 'failed', error = $2
WHERE event_id = $1`,
[event.id, String(err)],
);
throw err;
}
}
async function handleSubscriptionChange(subscription: Stripe.Subscription): Promise<void> {
const customerId = subscription.customer as string;
const status = subscription.status;
const priceId = subscription.items.data[0]?.price.id;
const currentPeriodEnd = new Date(subscription.current_period_end * 1000);
await db.query(
`UPDATE users
SET subscription_status = $1,
stripe_subscription_id = $2,
current_plan_price_id = $3,
subscription_period_end = $4,
updated_at = now()
WHERE stripe_customer_id = $5`,
[status, subscription.id, priceId, currentPeriodEnd, customerId],
);
// Update feature flags based on plan
const plan = PRICE_TO_PLAN[priceId ?? ''] ?? 'free';
await updateUserFeatureFlags(customerId, plan);
}
Customer Portal
Stripe's Customer Portal handles plan changes, payment method updates, and cancellations — no UI to build:
// src/api/billing/portal.ts
export async function createPortalSession(
customerId: string,
returnUrl: string,
): Promise<string> {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
configuration: process.env.STRIPE_PORTAL_CONFIG_ID, // Configure once in dashboard
});
return session.url;
}
// Route handler
app.get('/billing/portal', async (req, reply) => {
const url = await createPortalSession(
req.user.stripeCustomerId,
`${process.env.APP_URL}/settings/billing`,
);
reply.redirect(url);
});
Cost Estimates for Stripe Billing
| Revenue | Stripe Fees | Notes |
|---|---|---|
| $10K MRR | ~$290/month | 2.9% + $0.30 per transaction |
| $50K MRR | ~$1,450/month | Standard rate |
| $100K MRR | ~$2,500/month | Negotiate custom rate at $1M ARR |
| $500K MRR | ~$10,000/month | Enterprise rate: ~2% or less |
| Stripe Billing add-on | +0.5% of revenue | For metered billing, advanced features |
| Stripe Radar (fraud) | +$0.05/transaction | Recommended for all products |
At $1M ARR, negotiate directly with Stripe — enterprise rates typically 1.8–2.2% with no per-transaction fee.
Why Clients Trust Viprasol
Our SaaS team has implemented Stripe billing for 15+ products — from simple subscriptions to complex usage-based models with hybrid pricing.
What we deliver:
- Full Stripe Billing integration (subscriptions, metered, hybrid)
- Idempotent webhook handlers that survive double-delivery
- Customer portal and invoice management
- Proration previews and plan upgrade/downgrade flows
- Dunning management and failed payment recovery
→ Discuss your billing requirements → SaaS development services
External Resources
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours — fast.
Free consultation • No commitment • Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow — automating repetitive tasks, qualifying leads, and responding across every channel your customers use.