Building a SaaS Billing Integration with Stripe: Subscriptions, Usage Metering, and Webhook Handling in Next.js
A practical guide to implementing Stripe billing in a Next.js SaaS application. Covers subscription lifecycle management, usage-based metering, webhook verification, idempotent event handling, customer portal integration, and edge cases like failed payments and mid-cycle plan changes.
Billing seems straightforward until it is not. You pick a payment provider, wire up a checkout page, and charge a credit card. That works right until a customer upgrades mid-cycle, disputes a charge, or your usage metering double-counts events because a webhook fires twice. The gap between a working demo and production billing is wide, and most of the difficulty lives in the edge cases.
This article walks through building a Stripe billing integration in a Next.js application. We will cover subscription creation, plan changes with proration, usage-based metering, webhook handling with idempotency, the customer portal, and failed payment recovery. The code is TypeScript throughout.
The Data Model: Products, Prices, and Subscriptions
Stripe organizes billing around a few core objects. A Product represents what you sell. A Price defines how you charge for it. A Subscription ties a customer to one or more prices on a recurring schedule.
Before writing any code, set up your Stripe products and prices either through the dashboard or the API. A typical SaaS might have:
- A “Starter” product with a flat monthly price of $29
- A “Pro” product with a flat monthly price of $99
- A “Pro” product with a metered price for API calls at $0.001 per call
Your application database needs to track the relationship between your internal user records and Stripe objects. At minimum, store stripe_customer_id and stripe_subscription_id on your user or organization table. Do not store price amounts locally. Always fetch current pricing from Stripe. Cached prices drift, and drifted prices cause billing disputes.
// lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-12-18.acacia",
typescript: true,
});
Creating Subscriptions
When a user picks a plan, create a Stripe customer (if one does not exist) and then create a subscription. Using Stripe Checkout is the simplest path because it handles payment method collection, SCA authentication, and receipt emails.
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { getUser } from "@/lib/auth";
import { db } from "@/lib/db";
export async function POST(req: NextRequest) {
const user = await getUser(req);
const { priceId } = await req.json();
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId: user.id },
});
customerId = customer.id;
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.APP_URL}/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
subscription_data: {
metadata: { userId: user.id },
},
});
return NextResponse.json({ url: session.url });
}
One thing to watch: always set metadata on both the customer and the subscription. When webhooks fire later, metadata is how you map Stripe events back to your internal records without making additional database queries.
Handling Plan Changes: Upgrades and Downgrades
Plan changes are where billing gets tricky. When a user on the $29 Starter plan upgrades to the $99 Pro plan mid-cycle, you need to decide how to handle the price difference for the remaining days.
Stripe supports three proration behaviors:
create_prorations(default): generates credit and debit line items on the next invoicealways_invoice: generates prorations and immediately invoices the customernone: no proration, the new price applies at the next billing cycle
For upgrades, you typically want to charge immediately. For downgrades, you typically want to apply the change at the end of the current period. Here is how to handle both:
// lib/subscriptions.ts
import { stripe } from "./stripe";
interface PlanChangeParams {
subscriptionId: string;
newPriceId: string;
isUpgrade: boolean;
}
export async function changePlan({
subscriptionId,
newPriceId,
isUpgrade,
}: PlanChangeParams) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const currentItemId = subscription.items.data[0].id;
if (isUpgrade) {
// Charge the prorated difference immediately
const updated = await stripe.subscriptions.update(subscriptionId, {
items: [{ id: currentItemId, price: newPriceId }],
proration_behavior: "always_invoice",
payment_behavior: "error_if_incomplete",
});
return updated;
}
// Downgrade: schedule the change for the end of the billing period
const updated = await stripe.subscriptions.update(subscriptionId, {
items: [{ id: currentItemId, price: newPriceId }],
proration_behavior: "none",
billing_cycle_anchor: "unchanged",
});
return updated;
}
The payment_behavior: "error_if_incomplete" flag on upgrades is important. Without it, Stripe will let the upgrade proceed even if the prorated payment fails, leaving you with an unpaid invoice. With it, the API call throws an error that you can catch and surface to the user.
One subtle issue: if a user upgrades and downgrades multiple times in the same billing cycle, proration line items can stack up in confusing ways. Consider adding a cooldown period or limiting plan changes to once per billing cycle.
Usage-Based Metering
For products priced by consumption (API calls, storage, compute minutes), you need to report usage to Stripe. The approach is to create a metered price on your product, then submit usage records against the subscription item.
// lib/metering.ts
import { stripe } from "./stripe";
interface UsageEvent {
subscriptionItemId: string;
quantity: number;
timestamp: number;
}
export async function reportUsage({
subscriptionItemId,
quantity,
timestamp,
}: UsageEvent) {
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity,
timestamp,
action: "increment",
});
}
The action: "increment" parameter adds to the running total for the current period. The alternative, "set", replaces the total. Use increment when you report usage as it happens. Use set when you run a periodic job that calculates total usage and pushes it to Stripe.
A practical pattern is to buffer usage events in your database or a queue, then flush them to Stripe in batches. Calling the Stripe API on every single request adds latency and risks hitting rate limits. A background job that runs every few minutes and aggregates pending usage events works well.
// jobs/flush-usage.ts
import { db } from "@/lib/db";
import { reportUsage } from "@/lib/metering";
export async function flushPendingUsage() {
const pending = await db.usageEvent.groupBy({
by: ["subscriptionItemId"],
_sum: { quantity: true },
where: { reportedToStripe: false },
});
for (const group of pending) {
await reportUsage({
subscriptionItemId: group.subscriptionItemId,
quantity: group._sum.quantity ?? 0,
timestamp: Math.floor(Date.now() / 1000),
});
await db.usageEvent.updateMany({
where: {
subscriptionItemId: group.subscriptionItemId,
reportedToStripe: false,
},
data: { reportedToStripe: true },
});
}
}
Keep your local usage records even after reporting them to Stripe. They are your audit trail when a customer questions their invoice.
Webhook Handling
Webhooks are how Stripe tells your application about events: successful payments, failed charges, subscription cancellations. Getting webhook handling right is critical because it is your source of truth for subscription state.
Two problems you must solve: signature verification and idempotency.
Stripe signs every webhook payload with a secret. You must verify this signature to prevent spoofed events. For idempotency, Stripe may deliver the same event more than once. Your handler needs to tolerate duplicates without creating duplicate side effects.
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import Stripe from "stripe";
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// Idempotency: check if we have already processed this event
const existing = await db.stripeEvent.findUnique({
where: { eventId: event.id },
});
if (existing) {
return NextResponse.json({ received: true });
}
// Record the event before processing
await db.stripeEvent.create({
data: { eventId: event.id, type: event.type, processedAt: new Date() },
});
switch (event.type) {
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;
case "invoice.payment_succeeded":
await handlePaymentSucceeded(event.data.object);
break;
}
return NextResponse.json({ received: true });
}
A few things to note. You must read the raw body as text, not JSON. The req.json() method will parse the body, and the signature verification will fail because it needs the raw string. Also, always return a 200 response quickly. If your webhook endpoint takes too long, Stripe will retry, potentially causing duplicate processing if your idempotency check has a race condition.
For the idempotency check, using a unique constraint on the event ID in your database handles the race condition. If two identical webhook deliveries arrive simultaneously, the second create call will throw a unique constraint violation that you can catch and treat as a duplicate.
Customer Portal
Stripe provides a hosted customer portal where users can update payment methods, view invoices, and cancel subscriptions. This saves you from building these UI flows yourself.
// app/api/billing-portal/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { getUser } from "@/lib/auth";
export async function POST(req: NextRequest) {
const user = await getUser(req);
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.APP_URL}/billing`,
});
return NextResponse.json({ url: session.url });
}
Configure the portal in the Stripe dashboard to control which actions are available. You can allow or disallow plan switching, cancellations, and payment method updates. If you allow plan switching through the portal, make sure your webhook handler for customer.subscription.updated correctly syncs the new plan back to your database.
Handling Failed Payments
Payments fail. Cards expire, spending limits get hit, banks flag transactions. How you handle this determines whether you lose revenue or recover it.
Stripe has built-in retry logic called Smart Retries. It will attempt to collect payment multiple times over a configurable period. You configure this in your Stripe dashboard under subscription settings. But you should also handle it in your application.
When invoice.payment_failed fires, you have a few options:
- Notify the user via email that their payment failed and ask them to update their payment method
- Apply a grace period where the user retains access for a few days while retries happen
- Restrict access after the grace period expires if payment is not recovered
async function handlePaymentFailed(invoice: Stripe.Invoice) {
const subscription = await stripe.subscriptions.retrieve(
invoice.subscription as string
);
const userId = subscription.metadata.userId;
const attemptCount = invoice.attempt_count;
if (attemptCount === 1) {
// First failure: notify the user, no restrictions
await sendEmail(userId, "payment-failed-soft");
} else if (attemptCount >= 3) {
// Multiple failures: restrict access, send urgent notice
await db.user.update({
where: { id: userId },
data: { billingStatus: "past_due" },
});
await sendEmail(userId, "payment-failed-urgent");
}
}
Track billingStatus on your user record and check it in your middleware or API routes. A user in past_due status might get read-only access or a banner prompting them to update their payment method. Avoid cutting off access immediately. Aggressive dunning loses customers who would have paid if given a few days.
Comparing Billing Approaches
Different pricing models fit different products. Here is how they compare:
| Approach | Predictability (for customer) | Revenue upside | Implementation effort | Best for |
|---|---|---|---|---|
| Flat-rate | High | Low | Low | Simple tools, early-stage products |
| Per-seat | Medium | Medium | Medium | Collaboration tools, team products |
| Usage-based | Low | High | High | API products, infrastructure |
| Hybrid (base + usage) | Medium | High | High | Products with variable consumption |
| Tiered | Medium | Medium | Medium | Products with distinct feature sets |
Hybrid billing (a flat base price plus metered usage) is increasingly common because it gives customers a predictable minimum while letting you capture upside from heavy usage. The tradeoff is complexity. You are now managing both a recurring charge and metered billing on the same subscription, which means two price items, two sets of invoice line items, and more edge cases in your webhook handler.
Production Considerations
A few things that do not fit neatly into the sections above but will bite you in production:
Tax collection. Stripe Tax can handle this, but you need to enable it and configure your tax registrations. If you sell to customers in the EU, you are likely required to collect VAT. Do not skip this.
Currency handling. If you support multiple currencies, each price object in Stripe needs a separate entry per currency. Store and display amounts using the currency’s smallest unit (cents for USD, yen for JPY with no decimal). Off-by-one-cent errors in currency conversion erode trust.
Testing. Use Stripe’s test mode extensively. Write integration tests that create real test subscriptions, trigger plan changes, and simulate webhook events using stripe trigger from the CLI. Do not rely solely on mocking the Stripe SDK. Mocks cannot catch API version mismatches or incorrect parameter combinations.
Subscription state sync. Your database and Stripe will drift. Build a reconciliation job that periodically compares subscription states between your database and Stripe, flagging discrepancies. Run it daily. When it finds a mismatch, prefer Stripe as the source of truth.
Logging. Log every Stripe API call and every webhook event with enough context to debug billing issues six months later. Billing bugs are the kind that customers notice, and they expect you to explain exactly what happened.
Wrapping Up
Billing integration is less about the initial wiring and more about handling the long tail of edge cases: mid-cycle plan changes, failed payments, duplicate webhook deliveries, currency rounding, tax compliance. Stripe handles a lot of the heavy lifting, but the application-level logic around subscription state management, grace periods, and usage aggregation is yours to build and maintain. Start with the simplest billing model that works for your product, get the webhook handling and idempotency right from day one, and layer on complexity only when the business requires it.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.