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}
Billing bugs charge people twice or miss charges entirely. Stripe handles subscriptions, usage metering, dunning, and tax so your revenue doesn't leak.

Billing is terrifying. That's the honest starting point for any serious treatment of Stripe integration.
Billing bugs don't just inconvenience users. They damage your revenue. A bug that charges customers twice causes chargebacks, account suspensions, and lost trust that's nearly impossible to recover. A bug that misses charges loses revenue silently. A broken webhook handler means your users upgrade their plan and your application never knows.
Stripe is exceptional software. It handles the genuinely hard parts: PCI compliance, payment method security, subscription state management, dunning, and tax. Your job is to integrate it correctly. "Correctly" means understanding Stripe's data model, handling webhooks reliably, and testing edge cases before your customers find them.
This guide covers the integration patterns that matter: product and pricing setup, subscription management, webhook handling, usage-based billing, and the failure modes to avoid.
Stripe's data model for subscriptions has a specific hierarchy that you need to understand before writing a line of code.
Products represent what you're selling. "Agentik OS Pro Plan" is a product. "Agentik OS Enterprise" is a product.
Prices represent how you charge for a product. A product can have multiple prices: a monthly price and an annual price. A price specifies the amount, currency, interval, and billing model (flat rate, per seat, usage-based).
Subscriptions connect a customer to a price. A subscription has a status (active, trialing, past_due, canceled), a current period, and generates invoices.
Create your products and prices in Stripe Dashboard or via the API. Store the Price IDs in your environment variables. Never hard-code them.
# .env.local
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
# Price IDs from Stripe Dashboard
STRIPE_PRICE_STARTER_MONTHLY=price_...
STRIPE_PRICE_STARTER_ANNUAL=price_...
STRIPE_PRICE_PRO_MONTHLY=price_...
STRIPE_PRICE_PRO_ANNUAL=price_...
STRIPE_PRICE_ENTERPRISE_MONTHLY=price_...Create a Stripe customer for every user when they sign up. Store the Stripe customer ID in your database. Every subsequent billing operation references this customer ID.
// lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-03-01.basil",
typescript: true,
});
// Call this when a new user registers
export async function createStripeCustomer({
userId,
email,
name,
}: {
userId: string;
email: string;
name: string;
}) {
const customer = await stripe.customers.create({
email,
name,
metadata: {
userId, // Store your internal user ID for webhook lookup
},
});
// Store in your database
await db.user.update({
where: { id: userId },
data: { stripeCustomerId: customer.id },
});
return customer;
}
// Get or create customer (idempotent)
export async function getOrCreateStripeCustomer(userId: string) {
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) throw new Error("User not found");
if (user.stripeCustomerId) {
return await stripe.customers.retrieve(user.stripeCustomerId);
}
return await createStripeCustomer({
userId: user.id,
email: user.email,
name: user.name,
});
}Use Stripe Checkout for subscription initiation. Building a custom checkout form for card collection is unnecessary complexity with significant PCI scope implications. Stripe Checkout handles the entire payment flow, including 3D Secure, Apple Pay, Google Pay, and saved payment methods.
// app/api/create-checkout-session/route.ts
import { stripe } from "@/lib/stripe";
import { auth } from "@clerk/nextjs/server";
export async function POST(req: Request) {
const { userId } = await auth();
if (!userId) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { priceId } = await req.json();
const customer = await getOrCreateStripeCustomer(userId);
const session = await stripe.checkout.sessions.create({
customer: (customer as Stripe.Customer).id,
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
mode: "subscription",
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
subscription_data: {
trial_period_days: 14, // 14-day free trial
metadata: { userId },
},
allow_promotion_codes: true,
});
return Response.json({ url: session.url });
}// app/components/UpgradeButton.tsx - Client Component
"use client";
export function UpgradeButton({ priceId }: { priceId: string }) {
async function handleUpgrade() {
const res = await fetch("/api/create-checkout-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId }),
});
const { url } = await res.json();
window.location.href = url;
}
return (
<button onClick={handleUpgrade}>
Start Free Trial
</button>
);
}Webhooks are where most Stripe integrations go wrong. The checkout session creates a subscription. Your application finds out about it through a webhook. If the webhook handler fails, your user can pay for a subscription your application never activates.
The webhook endpoint must be idempotent. Stripe delivers each event at least once and may deliver duplicates. Your handler must check whether it's already processed an event before taking action.
// app/api/webhooks/stripe/route.ts
import { stripe } from "@/lib/stripe";
import { headers } from "next/headers";
import Stripe from "stripe";
export async function POST(req: Request) {
const body = await req.text();
const headersList = await headers();
const signature = headersList.get("stripe-signature") as string;
let event: Stripe.Event;
// Verify the webhook signature - NEVER skip this
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook signature verification failed", err);
return new Response("Invalid signature", { status: 400 });
}
// Process events idempotently
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.CheckoutSession;
await handleCheckoutCompleted(session);
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionUpdated(subscription);
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionDeleted(subscription);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailed(invoice);
break;
}
case "invoice.payment_succeeded": {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentSucceeded(invoice);
break;
}
default:
// Unhandled event type - log but don't error
console.log(`Unhandled event type: ${event.type}`);
}
return new Response("Webhook processed", { status: 200 });
}
async function handleCheckoutCompleted(session: Stripe.CheckoutSession) {
if (session.mode !== "subscription") return;
const subscriptionId = session.subscription as string;
const customerId = session.customer as string;
// Get the full subscription object
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
// Find the user by Stripe customer ID
const user = await db.user.findFirst({
where: { stripeCustomerId: customerId },
});
if (!user) {
console.error(`No user found for Stripe customer: ${customerId}`);
return;
}
// Idempotency: check if subscription already synced
const existing = await db.subscription.findFirst({
where: { stripeSubscriptionId: subscriptionId },
});
if (existing) return; // Already processed
// Create the subscription record
await db.subscription.create({
data: {
userId: user.id,
stripeSubscriptionId: subscriptionId,
stripeCustomerId: customerId,
stripePriceId: subscription.items.data[0].price.id,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
}AI applications often need usage-based pricing: charge per API call, per token, per document processed. Stripe's metered billing handles this.
// Create a metered price in Stripe Dashboard or API:
// billing_scheme: per_unit, aggregate_usage: sum, usage_type: metered
// lib/usage-billing.ts
export async function reportUsage(customerId: string, quantity: number) {
// Find the subscription item for metered billing
const subscriptions = await stripe.subscriptions.list({
customer: customerId,
status: "active",
});
const subscription = subscriptions.data[0];
if (!subscription) return;
// Find the metered subscription item
const meteredItem = subscription.items.data.find(
(item) => item.price.recurring?.usage_type === "metered"
);
if (!meteredItem) return;
// Report usage to Stripe
await stripe.subscriptionItems.createUsageRecord(meteredItem.id, {
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: "increment",
});
}
// Call this after each AI operation
export async function handleAIRequest(userId: string, tokensUsed: number) {
const user = await db.user.findUnique({ where: { id: userId } });
if (!user?.stripeCustomerId) return;
// Report token usage for billing
await reportUsage(user.stripeCustomerId, tokensUsed);
// Store usage locally for user dashboard
await db.usageRecord.create({
data: { userId, tokensUsed, timestamp: new Date() },
});
}Usage-based billing requires careful consideration of your rate limits and cost structure. If users can generate costs faster than you can process payments, you need usage limits that cut off access before bills become untenable. Stripe's spend controls help with this.
Users need to update payment methods, view invoices, change plans, and cancel. Build it yourself or use Stripe's Customer Portal (highly recommended).
// app/api/create-portal-session/route.ts
import { stripe } from "@/lib/stripe";
import { auth } from "@clerk/nextjs/server";
export async function POST() {
const { userId } = await auth();
if (!userId) return Response.json({ error: "Unauthorized" }, { status: 401 });
const user = await db.user.findUnique({ where: { clerkId: userId } });
if (!user?.stripeCustomerId) {
return Response.json({ error: "No subscription found" }, { status: 404 });
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`,
});
return Response.json({ url: session.url });
}The portal handles plan changes (upgrades and downgrades), payment method updates, invoice history, and cancellation. Proration happens automatically.
Your application needs to know whether a user has an active subscription to gate features:
// lib/subscription.ts
export async function getUserSubscription(userId: string) {
const subscription = await db.subscription.findFirst({
where: {
userId,
status: { in: ["active", "trialing"] },
},
orderBy: { createdAt: "desc" },
});
return subscription;
}
export async function hasActiveSubscription(userId: string): Promise<boolean> {
const sub = await getUserSubscription(userId);
if (!sub) return false;
// Check current period end for edge cases
return new Date(sub.currentPeriodEnd) > new Date();
}
// Usage in Server Component
export default async function ProFeature() {
const { userId } = await auth();
if (!userId) redirect("/sign-in");
const isSubscribed = await hasActiveSubscription(userId);
if (!isSubscribed) redirect("/pricing");
return <div>Pro feature content</div>;
}Test these scenarios before launch:
invoice.payment_failed fires, send email, update subscription status.past_due status.cancel_at_period_end = true. Access continues until period end. Subscription then deletes.customer.subscription.updated fires with new price.customer.subscription.trial_will_end fires 3 days before trial ends. Send reminder email.Use Stripe's test mode and webhook CLI (stripe listen --forward-to localhost:3000/api/webhooks/stripe) for local testing.
The Clerk integration creates the user identity. Stripe handles the billing. These two systems talk through your database: Clerk user ID maps to Stripe customer ID. Getting that relationship right is the foundation everything else builds on.
Q: How do you automate billing with Stripe?
Stripe billing automation handles subscription management, invoice generation, payment processing, dunning (failed payment recovery), proration for plan changes, usage-based billing, and tax calculation automatically. You define products and prices, Stripe handles the recurring billing lifecycle, payment retries, and customer communications.
Q: What is the best way to integrate Stripe with a SaaS application?
The best integration pattern uses Stripe Checkout for payment collection (PCI compliant with zero custom payment UI), webhooks for backend synchronization (never trust client-side payment confirmations), Stripe Customer Portal for self-service subscription management, and Stripe Tax for automatic tax calculation across jurisdictions.
Q: How does AI help with Stripe billing implementation?
AI agents dramatically accelerate Stripe integration by generating webhook handlers, subscription lifecycle management, customer portal configuration, and billing dashboard components. What traditionally takes 2-4 weeks of careful implementation can be built in 2-3 days with AI agents that understand Stripe's patterns and edge cases.
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.

Clerk Auth: Skip 18 Months of Authentication Pain
Authentication grows from JWT to MFA to SAML to org management. Clerk handles the whole journey so you can build what actually matters.

Next.js 16 + AI: Build Intelligent Apps Fast
Next.js 16 solves AI's hardest problems: secret exposure, blocking UIs, and scaling costs. Here's the architecture that actually works in production.

AI SaaS Development: Ship Your Product 10x Faster in 2026
From idea to production SaaS in weeks, not months. How AI agents and modern infrastructure compress the traditional 6-month build cycle into 3 weeks.
Stop reading about AI and start building with it. Book a free discovery call and see how AI agents can accelerate your business.