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
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.
Working With 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
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 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
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.