Overview
Stripe patterns for SaaS subscriptions. Key principle: webhook-first architecture — never trust the client to confirm payment. Always verify via webhook events.
Setup
npm install stripe @stripe/stripe-js
# CLI for local webhook testing
brew install stripe/stripe-cli/stripe
stripe login
Checkout Session (Server-Side)
// src/app/api/stripe/create-checkout/route.ts
import { stripe } from "@/lib/stripe";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const { priceId, userId } = await request.json();
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
metadata: { userId }, // passed to webhook events
allow_promotion_codes: true,
billing_address_collection: "required",
});
return NextResponse.json({ url: session.url });
}
Stripe Client Singleton
// src/lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
});
// Test card: 4242 4242 4242 4242 | any future date | any CVC
Webhook Handler
// src/app/api/stripe/webhook/route.ts
import { stripe } from "@/lib/stripe";
import { NextRequest, NextResponse } from "next/server";
export const config = { api: { bodyParser: false } };
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, WEBHOOK_SECRET);
} catch (err) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutComplete(session);
break;
}
case "customer.subscription.created":
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscription(subscription);
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await cancelSubscription(subscription.metadata.userId);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailure(invoice);
break;
}
}
return NextResponse.json({ received: true });
}
async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId;
if (!userId) return;
await db.from("subscriptions").upsert({
user_id: userId,
stripe_customer_id: session.customer as string,
stripe_subscription_id: session.subscription as string,
status: "active",
});
}
Customer Portal
// src/app/api/stripe/portal/route.ts
export async function POST(request: NextRequest) {
const { userId } = await request.json();
const subscription = await db
.from("subscriptions")
.select("stripe_customer_id")
.eq("user_id", userId)
.single();
const session = await stripe.billingPortal.sessions.create({
customer: subscription.stripe_customer_id,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`,
});
return NextResponse.json({ url: session.url });
}
Local Webhook Testing
# Forward events to local server
stripe listen --forward-to localhost:3000/api/stripe/webhook
# Trigger specific events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
Price and Product Setup
// Create via API (or use dashboard)
const product = await stripe.products.create({
name: "Pro Plan",
description: "Unlimited projects and API access",
});
const price = await stripe.prices.create({
product: product.id,
unit_amount: 2900, // $29.00 in cents
currency: "usd",
recurring: { interval: "month" },
});
console.log(price.id); // store this as STRIPE_PRO_PRICE_ID
Subscription Status Sync
async function syncSubscription(subscription: Stripe.Subscription) {
const userId = subscription.metadata.userId;
await db.from("subscriptions").upsert({
user_id: userId,
stripe_subscription_id: subscription.id,
status: subscription.status,
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
cancel_at_period_end: subscription.cancel_at_period_end,
price_id: subscription.items.data[0]?.price.id,
});
}
Subscription Guard (Middleware)
export async function requireActiveSubscription(userId: string) {
const sub = await db
.from("subscriptions")
.select("status")
.eq("user_id", userId)
.single();
if (!sub || !["active", "trialing"].includes(sub.status)) {
throw new Error("Subscription required");
}
}
Sandbox to Production Checklist
[ ] Go to Stripe Dashboard → toggle to Live mode
[ ] Activate account (business details, bank account)
[ ] Recreate all products and prices in live mode
[ ] Update price IDs in app (price_live_... not price_test_...)
[ ] Create production webhook at https://yourdomain.com/api/stripe/webhook
[ ] Add all required events to the webhook
[ ] Copy webhook signing secret → update STRIPE_WEBHOOK_SECRET in Vercel
[ ] Update STRIPE_SECRET_KEY to sk_live_... in Vercel
[ ] Update NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY to pk_live_... in Vercel
[ ] Test with a real card (small amount → refund)
[ ] Verify webhook events arriving in Stripe dashboard
Common Mistakes
| Mistake | Fix |
|---------|-----|
| Trusting client to confirm payment | Always use webhooks |
| No webhook signature verification | Always call constructEvent() |
| Using test price IDs in production | Recreate prices in live mode |
| Missing checkout.session.completed handler | This is the primary entry point |
| Storing card data | Never — Stripe handles this |
| Not syncing subscription updates | Handle customer.subscription.updated |