Loading...
Loading...

Stripe's documentation is excellent. Stripe's API is well-designed. And yet, implementing a complete billing system still takes most developers three to five times longer than they expect.
The reason isn't Stripe's complexity. It's the gap between "API calls working" and "billing system working correctly." Subscription state transitions, failed payment handling, proration on plan changes, invoice customization, tax handling, dunning sequences. Each is well-documented individually. The integration of all of them is where the hidden complexity lives.
AI cuts through this gap. Not by simplifying the concepts, but by helping you implement the unglamorous parts without losing momentum on the things that actually matter.
Here's the complete billing system I've shipped multiple times, explained step by step.
A production-ready Stripe billing integration with:
This is not a stripped-down demo. This is what you actually need.
Set up your products and prices in Stripe first, then reference the IDs in your code.
// stripe/setup.ts - Run this once to create products
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
async function setupProducts() {
// Starter Plan
const starterProduct = await stripe.products.create({
name: "Starter",
description: "For individuals and small teams",
metadata: { tier: "starter" },
});
const starterMonthly = await stripe.prices.create({
product: starterProduct.id,
unit_amount: 2900, // $29.00
currency: "usd",
recurring: { interval: "month" },
metadata: { interval: "monthly" },
});
const starterAnnual = await stripe.prices.create({
product: starterProduct.id,
unit_amount: 29000, // $290.00 (2 months free)
currency: "usd",
recurring: { interval: "year" },
metadata: { interval: "annual" },
});
// Pro Plan
const proProduct = await stripe.products.create({
name: "Pro",
description: "For growing teams",
metadata: { tier: "pro" },
});
const proMonthly = await stripe.prices.create({
product: proProduct.id,
unit_amount: 9900,
currency: "usd",
recurring: { interval: "month" },
metadata: { interval: "monthly" },
});
console.log({
starterMonthlyId: starterMonthly.id,
starterAnnualId: starterAnnual.id,
proMonthlyId: proMonthly.id,
});
// Save these IDs to your environment variables
}
setupProducts().catch(console.error);// convex/schema.ts (add to existing schema)
subscriptions: defineTable({
userId: v.string(),
stripeCustomerId: v.string(),
stripeSubscriptionId: v.optional(v.string()),
stripePriceId: v.optional(v.string()),
stripeCurrentPeriodEnd: v.optional(v.number()),
status: v.union(
v.literal("active"),
v.literal("trialing"),
v.literal("past_due"),
v.literal("canceled"),
v.literal("incomplete"),
v.literal("none")
),
tier: v.union(v.literal("starter"), v.literal("pro"), v.literal("none")),
cancelAtPeriodEnd: v.boolean(),
}).index("by_user", ["userId"])
.index("by_stripe_customer", ["stripeCustomerId"])
.index("by_stripe_subscription", ["stripeSubscriptionId"]),
usageRecords: defineTable({
subscriptionId: v.id("subscriptions"),
feature: v.string(), // e.g., "api_calls", "storage_gb"
quantity: v.number(),
recordedAt: v.number(),
}).index("by_subscription", ["subscriptionId"]),// convex/billing.ts
import { action } from "./_generated/server";
import { v } from "convex/values";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Tier to price ID mapping
const PRICE_IDS: Record<string, Record<string, string>> = {
starter: {
monthly: process.env.STRIPE_STARTER_MONTHLY_PRICE_ID!,
annual: process.env.STRIPE_STARTER_ANNUAL_PRICE_ID!,
},
pro: {
monthly: process.env.STRIPE_PRO_MONTHLY_PRICE_ID!,
annual: process.env.STRIPE_PRO_ANNUAL_PRICE_ID!,
},
};
export const createCheckoutSession = action({
args: {
tier: v.union(v.literal("starter"), v.literal("pro")),
interval: v.union(v.literal("monthly"), v.literal("annual")),
},
handler: async (ctx, { tier, interval }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
// Get or create Stripe customer
let subscription = await ctx.runQuery(api.billing.getSubscription, {
userId: identity.subject,
});
let customerId: string;
if (subscription?.stripeCustomerId) {
customerId = subscription.stripeCustomerId;
} else {
const customer = await stripe.customers.create({
email: identity.email!,
name: identity.name,
metadata: { userId: identity.subject },
});
customerId = customer.id;
await ctx.runMutation(api.billing.createSubscriptionRecord, {
userId: identity.subject,
stripeCustomerId: customerId,
status: "none",
tier: "none",
cancelAtPeriodEnd: false,
});
}
const priceId = PRICE_IDS[tier][interval];
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
mode: "subscription",
allow_promotion_codes: true,
subscription_data: {
trial_period_days: 14,
metadata: { userId: identity.subject },
},
success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
});
return session.url;
},
});
export const createPortalSession = action({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
const subscription = await ctx.runQuery(api.billing.getSubscription, {
userId: identity.subject,
});
if (!subscription?.stripeCustomerId) {
throw new Error("No billing account found");
}
const session = await stripe.billingPortal.sessions.create({
customer: subscription.stripeCustomerId,
return_url: `${process.env.APP_URL}/billing`,
});
return session.url;
},
});The webhook handler is where most billing system bugs live. Every subscription event needs to update your database correctly.
// app/api/stripe/webhook/route.ts
import { NextRequest } from "next/server";
import Stripe from "stripe";
import { ConvexHttpClient } from "convex/browser";
import { api } from "@/convex/_generated/api";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
// Map Stripe price ID to tier
function getTierFromPriceId(priceId: string): "starter" | "pro" | "none" {
if (priceId === process.env.STRIPE_STARTER_MONTHLY_PRICE_ID ||
priceId === process.env.STRIPE_STARTER_ANNUAL_PRICE_ID) {
return "starter";
}
if (priceId === process.env.STRIPE_PRO_MONTHLY_PRICE_ID ||
priceId === process.env.STRIPE_PRO_ANNUAL_PRICE_ID) {
return "pro";
}
return "none";
}
async function handleSubscriptionEvent(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string;
const priceId = subscription.items.data[0]?.price.id;
const tier = priceId ? getTierFromPriceId(priceId) : "none";
await convex.mutation(api.billing.updateSubscription, {
stripeCustomerId: customerId,
stripeSubscriptionId: subscription.id,
stripePriceId: priceId,
stripeCurrentPeriodEnd: subscription.current_period_end * 1000,
status: subscription.status as any,
tier,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
});
}
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return Response.json({ error: "Invalid signature" }, { status: 400 });
}
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;
if (session.mode === "subscription" && session.subscription) {
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
await handleSubscriptionEvent(subscription);
}
break;
}
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.resumed":
await handleSubscriptionEvent(event.data.object);
break;
case "customer.subscription.deleted": {
const subscription = event.data.object;
await convex.mutation(api.billing.updateSubscription, {
stripeCustomerId: subscription.customer as string,
stripeSubscriptionId: subscription.id,
status: "canceled",
tier: "none",
cancelAtPeriodEnd: false,
});
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object;
// Handled automatically by Stripe dunning, but log it
console.log(`Payment failed for customer ${invoice.customer}`);
// Optionally send notification to customer
break;
}
case "invoice.payment_succeeded": {
// Good opportunity to record successful billing events
break;
}
}
} catch (error) {
console.error(`Error handling webhook ${event.type}:`, error);
return Response.json({ error: "Webhook handler failed" }, { status: 500 });
}
return Response.json({ received: true });
}Protect routes that require active subscriptions:
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { getAuth } from "@clerk/nextjs/server";
import { ConvexHttpClient } from "convex/browser";
import { api } from "@/convex/_generated/api";
const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
const PROTECTED_PATHS = ["/dashboard", "/settings/", "/api/protected/"];
export async function middleware(req: NextRequest) {
const path = req.nextUrl.pathname;
const requiresSubscription = PROTECTED_PATHS.some(p => path.startsWith(p));
if (!requiresSubscription) {
return NextResponse.next();
}
const { userId } = getAuth(req);
if (!userId) {
return NextResponse.redirect(new URL("/sign-in", req.url));
}
const subscription = await convex.query(api.billing.getSubscription, { userId });
const hasAccess = subscription &&
["active", "trialing"].includes(subscription.status);
if (!hasAccess) {
return NextResponse.redirect(new URL("/pricing", req.url));
}
return NextResponse.next();
}This trips everyone up the first time:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/stripe/webhook
# Trigger test events in another terminal
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failedThe CLI outputs a webhook signing secret for local testing. Add it to your .env.local as STRIPE_WEBHOOK_SECRET.
Duplicate webhooks. Stripe may send the same event multiple times. Your webhook handler must be idempotent. Using stripeSubscriptionId as a lookup key and using updateSubscription (not createSubscription) handles this.
Clock drift. Stripe timestamps are in seconds, JavaScript Date is in milliseconds. Always multiply Stripe timestamps by 1000.
Customer Portal limitations. The Customer Portal can't change the product. It can upgrade/downgrade within the same product's prices. If you have different products for different tiers, you'll need to handle plan changes via the API, not the Portal.
Trial to paid transition. When a trial converts to paid, Stripe fires customer.subscription.updated. Make sure your handler updates the status correctly.

One person. AI doing 70% of the coding. A fully functional SaaS with paying customers in 19 days. Here's the exact process, decisions, and mistakes.

Convex eliminates most backend code. Pair it with AI and you're shipping real-time features that would have taken weeks in a few hours.

Vercel deploys are easy until they're not. Environment variables missing, build errors in prod only, domain weirdness. Here's how to get it right the first time.
Stop reading about AI and start building with it. Book a free discovery call and see how AI agents can accelerate your business.