Loading...
Loading...
Weekly AI insights —
Real strategies, no fluff. Unsubscribe anytime.
Written by Gareth Simono, Founder and CEO of Agentik {OS}. Full-stack developer and AI architect with years of experience shipping production applications across SaaS, mobile, and enterprise platforms. Gareth orchestrates 267 specialized AI agents to deliver production software 10x faster than traditional development teams.
Founder & CEO, Agentik{OS}
Stripe handles payments. AI handles the complexity around them. Here's how to build a complete billing system with subscriptions, usage, and smart dunning.

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.
Q: How do you integrate Stripe with AI agents?
Integrate Stripe by using Stripe's API for product and price creation, implementing Checkout Sessions for payment collection, setting up webhooks for event handling (payment success, subscription changes), and letting AI agents generate the integration code. AI agents handle Stripe integration patterns well because they are well-documented and standardized.
Q: What Stripe patterns should every SaaS implement?
Every SaaS needs Stripe Checkout for payment collection, Stripe Customer Portal for self-service management, webhook handling for reliable payment event processing, Stripe Tax for automated tax calculation, and proper error handling for payment failures with retry logic.
Q: How long does Stripe integration take with AI?
AI agents can implement a complete Stripe billing integration (checkout, subscriptions, customer portal, webhooks, tax) in 2-3 days. Traditional development takes 2-4 weeks. The AI advantage comes from generating boilerplate webhook handlers, subscription lifecycle management, and edge case handling automatically.
Full-stack developer and AI architect with years of experience shipping production applications across SaaS, mobile, and enterprise. Gareth built Agentik {OS} to prove that one person with the right AI system can outperform an entire traditional development team. He has personally architected and shipped 7+ production applications using AI-first workflows.

How I Built a SaaS in 19 Days with AI (Build Log)
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 + AI: Build Real-Time Backend in Hours, Not Weeks
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.

Deploying to Vercel: A Real Production Checklist
Vercel deploys are easy until they're not. Env vars missing, build errors in prod only. Here's the production checklist to get it right.
Stop reading about AI and start building with it. Book a free discovery call and see how AI agents can accelerate your business.