Back to Blog

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.

Viprasol Tech Team
July 9, 2026
14 min read

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

ModelBest ForStripe Implementation
Flat rateSimple, predictableSingle price with type: recurring
Per seatTeam toolsquantity on subscription
Tiered (volume)Discounts at scalebilling_scheme: tiered, tiers_mode: volume
Tiered (graduated)Different rate per tier buckettiers_mode: graduated
Metered/usageAPI calls, storage, tokensusage_type: metered price
HybridSeat fee + usage overageMultiple 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

RevenueStripe FeesNotes
$10K MRR~$290/month2.9% + $0.30 per transaction
$50K MRR~$1,450/monthStandard rate
$100K MRR~$2,500/monthNegotiate custom rate at $1M ARR
$500K MRR~$10,000/monthEnterprise rate: ~2% or less
Stripe Billing add-on+0.5% of revenueFor metered billing, advanced features
Stripe Radar (fraud)+$0.05/transactionRecommended 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


Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

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.