Skip to content

Web Development · Payments

Stripe Billing for SaaS in 2026: Subscriptions, Webhooks, and the Meter API

A practical guide to building SaaS billing with Stripe in 2026 — covering subscription plans, the Meter API for usage-based pricing, webhook reliability, and the Customer Portal.

Anurag Verma

Anurag Verma

8 min read

Stripe Billing for SaaS in 2026: Subscriptions, Webhooks, and the Meter API

Sponsored

Share

Every SaaS product eventually hits the billing question. Should you use Stripe Checkout or build custom payment forms? How do you handle metered billing when a customer’s usage varies month to month? What happens when a webhook arrives twice?

These questions come up on every client project that involves subscriptions. Here is a practical guide to the Stripe setup that actually works in production.

The Core Building Blocks

Stripe’s API has three layers you will interact with on almost every SaaS build:

Products and Prices define what you’re selling. A product is the abstract thing (your SaaS plan), a price is a specific billing configuration for it — $49/month, $490/year, $0.001 per API call.

Customers represent individual subscribers with a billing identity. Never skip creating a Customer object, even for free tiers. When a free user upgrades, you need the customer ID to attach a payment method.

Subscriptions tie a customer to a price. The subscription lifecycle — active, past_due, canceled, unpaid — drives your access control logic.

Choosing Between Checkout and Elements

Stripe gives you two integration paths for collecting payment info.

Stripe Checkout is a hosted page. You create a Checkout Session via the API, redirect the user to stripe.com, and handle success or cancel redirects back to your app.

// pages/api/create-checkout-session.ts
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(req: Request) {
  const { priceId, customerId } = await req.json()

  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    payment_method_types: ['card'],
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/billing`,
    subscription_data: {
      trial_period_days: 14,
    },
  })

  return Response.json({ url: session.url })
}

Checkout handles 3DS authentication, card validation, and tax collection automatically. For most SaaS apps, it’s the right choice unless you need deep UI customization.

Stripe Elements is an embedded approach where Stripe’s iframe handles card input while the rest of the form is yours. More control, more code.

Use Checkout unless you have a specific reason not to.

Usage-Based Billing with the Meter API

Stripe’s Meter API (released in 2024, stable in 2025) replaced the older Usage Records approach. It’s cleaner for anything priced per unit — API calls, active users, seats added mid-cycle.

First, create a meter in the Stripe dashboard or API:

const meter = await stripe.billing.meters.create({
  display_name: 'API Calls',
  event_name: 'api_call',
  default_aggregation: {
    formula: 'sum',
  },
})

Then create a price that references the meter:

const price = await stripe.prices.create({
  product: 'prod_YOURPRODUCT',
  currency: 'usd',
  billing_scheme: 'per_unit',
  unit_amount: 1, // $0.01 per unit, adjust for your pricing
  recurring: {
    interval: 'month',
    usage_type: 'metered',
    meter: meter.id,
  },
})

To record usage, emit a meter event whenever the metered action occurs:

// middleware/track-api-usage.ts
export async function trackAPICall(customerId: string) {
  await stripe.billing.meterEvents.create({
    event_name: 'api_call',
    payload: {
      stripe_customer_id: customerId,
      value: '1',
    },
  })
}

Stripe aggregates these events per billing period. At invoice generation, it totals the events and charges accordingly.

One practical issue: meter events are async. Emit them fire-and-forget; don’t await them in the critical path of an API request. Stripe guarantees delivery.

Webhook Reliability

Webhooks are where most billing integrations go wrong. The failure modes:

  1. Your server was down when Stripe sent the event
  2. The event was delivered but you returned 500 (Stripe will retry)
  3. The same event arrived twice (Stripe retries on non-2xx, network timeouts)
  4. Events arrived out of order (subscription created after invoice generated)

Handle all of these:

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
import { headers } from 'next/headers'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!

export async function POST(req: Request) {
  const body = await req.text()
  const signature = headers().get('stripe-signature')!

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
  } catch (err) {
    return new Response('Webhook signature verification failed', { status: 400 })
  }

  // Idempotency: check if we've already processed this event
  const alreadyProcessed = await db.stripeEvents.findUnique({
    where: { eventId: event.id },
  })
  if (alreadyProcessed) {
    return new Response('Already processed', { status: 200 })
  }

  try {
    await handleEvent(event)
    await db.stripeEvents.create({ data: { eventId: event.id } })
  } catch (err) {
    console.error('Webhook handler failed:', err)
    return new Response('Handler error', { status: 500 })
  }

  return new Response('OK', { status: 200 })
}

async function handleEvent(event: Stripe.Event) {
  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated':
      await syncSubscription(event.data.object as Stripe.Subscription)
      break
    case 'customer.subscription.deleted':
      await cancelSubscription(event.data.object as Stripe.Subscription)
      break
    case 'invoice.payment_failed':
      await handlePaymentFailure(event.data.object as Stripe.Invoice)
      break
  }
}

Key rules: verify the signature every time (never trust raw POST bodies), store processed event IDs to deduplicate, return 200 even when you choose to ignore an event type.

When running locally, use the Stripe CLI to forward webhooks:

stripe listen --forward-to localhost:3000/api/webhooks/stripe

Syncing Subscription State

Do not store subscription status in your own database based on what happens at checkout. Subscriptions change due to payment failures, plan changes, cancellations, and free trial expirations — often without your app’s involvement.

The pattern that works: always treat the Stripe subscription as the source of truth. At app startup, or on any access check that feels stale, re-fetch from Stripe if needed. Otherwise, sync from webhooks.

async function syncSubscription(subscription: Stripe.Subscription) {
  const customerId = subscription.customer as string

  // Find the user by Stripe customer ID
  const user = await db.users.findFirst({
    where: { stripeCustomerId: customerId },
  })
  if (!user) return

  await db.subscriptions.upsert({
    where: { stripeSubscriptionId: subscription.id },
    update: {
      status: subscription.status,
      planId: subscription.items.data[0].price.id,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
    },
    create: {
      userId: user.id,
      stripeSubscriptionId: subscription.id,
      status: subscription.status,
      planId: subscription.items.data[0].price.id,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
    },
  })
}

For access control, check the database copy rather than calling Stripe on every request:

export async function getUserPlan(userId: string): Promise<'free' | 'pro' | 'enterprise'> {
  const subscription = await db.subscriptions.findFirst({
    where: {
      userId,
      status: { in: ['active', 'trialing'] },
    },
  })

  if (!subscription) return 'free'
  
  if (subscription.planId === process.env.STRIPE_PRO_PRICE_ID) return 'pro'
  if (subscription.planId === process.env.STRIPE_ENTERPRISE_PRICE_ID) return 'enterprise'
  
  return 'free'
}

The Customer Portal

Stripe’s Customer Portal lets users manage their own subscription — upgrade, downgrade, cancel, update payment methods — without you building any of that UI.

Generate a portal session from your backend:

export async function POST(req: Request) {
  const { userId } = await getCurrentUser(req)
  const user = await db.users.findUnique({ where: { id: userId } })

  const session = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.APP_URL}/settings/billing`,
  })

  return Response.json({ url: session.url })
}

Configure the portal in the Stripe dashboard: which plans users can switch to, whether cancellation is immediate or at period end, what payment methods are accepted.

When users update their plan in the portal, you receive webhooks. The subscription sync from the previous section handles these automatically.

Handling Dunning

When a payment fails, Stripe retries on a configurable schedule (often 3 days, 5 days, 7 days). During this window, the subscription moves to past_due.

Decide how to handle past_due subscriptions based on your product. Options:

  • Full access until the subscription moves to canceled or unpaid (good for lower-risk SaaS)
  • Degraded access or read-only mode after the first failed payment
  • Block access immediately on payment failure (for security-sensitive products)

Listen to invoice.payment_failed to send your own dunning emails with a direct link to update payment info via the Customer Portal. Stripe’s automatic emails are plain and unbranded.

case 'invoice.payment_failed': {
  const invoice = event.data.object as Stripe.Invoice
  const customerId = invoice.customer as string
  
  const user = await db.users.findFirst({
    where: { stripeCustomerId: customerId },
  })
  
  if (user) {
    await sendEmail({
      to: user.email,
      subject: 'Payment failed — update your billing info',
      template: 'payment-failed',
      data: { updateUrl: `${process.env.APP_URL}/settings/billing` },
    })
  }
  break
}

Test Everything in Test Mode

Stripe’s test mode is complete — test cards, clock advancement for subscription periods, simulated webhook delivery. Use it.

Useful test card numbers:

  • 4242 4242 4242 4242 — succeeds
  • 4000 0000 0000 0002 — card declined
  • 4000 0027 6000 3184 — 3DS authentication required

To simulate a subscription renewal or trial expiration, use Stripe’s test clocks:

const testClock = await stripe.testHelpers.testClocks.create({
  frozen_time: Math.floor(Date.now() / 1000),
})

// Advance to trigger subscription renewal
await stripe.testHelpers.testClocks.advance(testClock.id, {
  frozen_time: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 35, // 35 days
})

This is faster and more reliable than waiting for real time to pass in integration tests.

The Bottom Line

Stripe’s billing API is extensive but the core integration is manageable. Use Checkout instead of custom forms. Treat webhooks with care — verify signatures, deduplicate, sync subscription state to your database. Let the Customer Portal handle self-service plan management so you don’t have to build it.

The Meter API makes usage-based pricing practical for smaller teams. If your product has any consumption-based component — API calls, seats, compute time — it’s worth the setup.

Sponsored

Enjoyed it? Pass it on.

Share this article.

Sponsored

The dispatch

Working notes from
the studio.

A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.

No spam, ever. Unsubscribe anytime.

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored