Payments & Subscriptions
Complete guide to Stripe integration for subscription billing
Payments & Subscriptions
The starter kit includes a complete Stripe integration for subscription billing with plans, checkout, customer portal, and webhook handling.
Features
- ✅ Stripe Integration with Better Auth Stripe plugin
- ✅ Subscription Plans (Free, Pro, Startup)
- ✅ Monthly & Yearly Billing options
- ✅ Checkout Sessions for upgrades
- ✅ Customer Portal for self-service
- ✅ Webhook Handling for real-time updates
- ✅ Invoice Management and tracking
- ✅ Usage Tracking (optional metered billing)
- ✅ Plan Limits and feature gating
Setup
1. Create Stripe Account
Sign up at Stripe.com and get your API keys.
2. Configure Environment Variables
# Stripe API Keys
STRIPE_SECRET_KEY="sk_test_xxxxxxxxxxxx"
STRIPE_PUBLISHABLE_KEY="pk_test_xxxxxxxxxxxx"
# Webhook Secret (from Stripe Dashboard)
STRIPE_WEBHOOK_SECRET="whsec_xxxxxxxxxxxx"3. Create Products in Stripe
- Go to Products
- Create products:
- Pro with monthly and yearly prices
- Startup with monthly and yearly prices
- Copy Price IDs
4. Configure Price IDs
STRIPE_PRICE_PRO_MONTHLY="price_xxxxxxxxxxxx"
STRIPE_PRICE_PRO_YEARLY="price_xxxxxxxxxxxx"
STRIPE_PRICE_STARTUP_MONTHLY="price_xxxxxxxxxxxx"
STRIPE_PRICE_STARTUP_YEARLY="price_xxxxxxxxxxxx"5. Set Up Webhooks
- Go to Webhooks
- Add endpoint:
https://yourdomain.com/api/stripe/webhook - Select events:
customer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
- Copy webhook signing secret to
STRIPE_WEBHOOK_SECRET
6. Seed Subscription Plans
pnpm plans:setupThis creates the three default plans in your database.
Subscription Plans
Plan Structure
Plans are stored in the subscriptionPlans table:
{
id: string
name: "Free" | "Pro" | "Startup"
slug: "free" | "pro" | "startup"
description: string
// Stripe Price IDs
stripePriceIdMonthly: string
stripePriceIdYearly: string
// Pricing (in cents)
priceMonthly: number // 0 for Free, 1999 for Pro ($19.99)
priceYearly: number // 0 for Free, 19900 for Pro ($199)
// Features list
features: string[] // ["Feature 1", "Feature 2"]
// Limits
limits: {
storage?: number
apiCalls?: number
teamMembers?: number
}
isActive: boolean
sortOrder: number
}Default Plans
Free Plan:
{
name: "Free",
priceMonthly: 0,
priceYearly: 0,
features: [
"1 user",
"Basic features",
"Community support"
],
limits: {
storage: 1024, // 1GB
apiCalls: 1000
}
}Pro Plan:
{
name: "Pro",
priceMonthly: 1999, // $19.99
priceYearly: 19900, // $199 (save $40)
features: [
"5 users",
"Advanced features",
"Priority support",
"Advanced analytics"
],
limits: {
storage: 10240, // 10GB
apiCalls: 10000
}
}Startup Plan:
{
name: "Startup",
priceMonthly: 4999, // $49.99
priceYearly: 49900, // $499 (save $100)
features: [
"Unlimited users",
"All features",
"24/7 support",
"Custom integrations",
"SLA guarantee"
],
limits: {
storage: 102400, // 100GB
apiCalls: 100000
}
}Managing Plans
Navigate to /dashboard/admin/subscription-plans to:
- View all plans
- Edit pricing and features
- Toggle plan visibility
- Update limits
- Sync with Stripe
User Subscriptions
Subscription States
subscriptions {
userId: string
planId: string
status: "active" | "canceled" | "past_due" | "trialing" | "incomplete"
billingCycle: "monthly" | "yearly"
stripeCustomerId: string
stripeSubscriptionId: string
stripePriceId: string
stripeCurrentPeriodEnd: Date
canceledAt: Date | null
cancelAtPeriodEnd: boolean
trialEndsAt: Date | null
}Status Meanings
- active: Subscription is active and paid
- trialing: In trial period (if configured)
- past_due: Payment failed, retrying
- canceled: Subscription canceled
- incomplete: Checkout not completed
Checkout Flow
1. User Selects Plan
User visits /pricing and clicks "Upgrade to Pro".
2. Create Checkout Session
// app/api/subscription/checkout/route.ts
import { stripe } from "@/lib/stripe";
import { auth } from "@/lib/auth";
export async function POST(request: Request) {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { priceId, billingCycle } = await request.json();
// Create Stripe checkout session
const checkoutSession = await stripe.checkout.sessions.create({
customer_email: session.user.email,
mode: "subscription",
payment_method_types: ["card"],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/user/billing?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
metadata: {
userId: session.user.id,
billingCycle,
},
});
return NextResponse.json({ url: checkoutSession.url });
}3. Redirect to Stripe Checkout
// components/pricing-card.tsx
"use client";
export function PricingCard({ plan, billingCycle }) {
const handleCheckout = async () => {
const response = await fetch("/api/subscription/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
priceId:
billingCycle === "yearly"
? plan.stripePriceIdYearly
: plan.stripePriceIdMonthly,
billingCycle,
}),
});
const { url } = await response.json();
window.location.href = url; // Redirect to Stripe
};
return <Button onClick={handleCheckout}>Upgrade to {plan.name}</Button>;
}4. Stripe Processes Payment
User completes payment on Stripe-hosted checkout page.
5. Webhook Updates Database
Stripe sends customer.subscription.created event to your webhook.
Webhooks
Webhook Handler
// app/api/stripe/webhook/route.ts
import { stripe } from "@/lib/stripe";
import { db } from "@/db";
import { subscriptions } from "@/db/schema";
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
let event;
try {
event = stripe.webhooks.constructEvent(
body,
signature!,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return NextResponse.json(
{ error: "Webhook signature verification failed" },
{ status: 400 }
);
}
// Handle events
switch (event.type) {
case "customer.subscription.created":
case "customer.subscription.updated":
await handleSubscriptionUpdate(event.data.object);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object);
break;
case "invoice.paid":
await handleInvoicePaid(event.data.object);
break;
case "invoice.payment_failed":
await handleInvoicePaymentFailed(event.data.object);
break;
}
return NextResponse.json({ received: true });
}
async function handleSubscriptionUpdate(subscription: any) {
await db
.insert(subscriptions)
.values({
userId: subscription.metadata.userId,
stripeCustomerId: subscription.customer,
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
status: subscription.status,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
})
.onConflictDoUpdate({
target: subscriptions.stripeSubscriptionId,
set: {
status: subscription.status,
stripeCurrentPeriodEnd: new Date(
subscription.current_period_end * 1000
),
updatedAt: new Date(),
},
});
}Events Handled
- customer.subscription.created: New subscription
- customer.subscription.updated: Plan change, renewal
- customer.subscription.deleted: Cancellation
- invoice.paid: Payment successful
- invoice.payment_failed: Payment failed
Customer Portal
Stripe Customer Portal
Users can manage their subscription at /dashboard/user/billing:
// app/api/subscription/portal/route.ts
import { stripe } from "@/lib/stripe";
export async function POST(request: Request) {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get user's subscription
const subscription = await db.query.subscriptions.findFirst({
where: eq(subscriptions.userId, session.user.id),
});
if (!subscription?.stripeCustomerId) {
return NextResponse.json(
{ error: "No subscription found" },
{ status: 404 }
);
}
// Create portal session
const portalSession = await stripe.billingPortal.sessions.create({
customer: subscription.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/user/billing`,
});
return NextResponse.json({ url: portalSession.url });
}Portal Features
Users can:
- View current plan and billing cycle
- Update payment method
- View invoice history
- Download invoices
- Cancel subscription
- Upgrade/downgrade plan
- Update billing information
Feature Gating
Checking Subscription Status
// lib/subscription.ts
import { db } from "@/db";
import { subscriptions } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function getUserSubscription(userId: string) {
const subscription = await db.query.subscriptions.findFirst({
where: eq(subscriptions.userId, userId),
with: {
plan: true,
},
});
return subscription;
}
export async function hasActiveSubscription(userId: string) {
const subscription = await getUserSubscription(userId);
return subscription?.status === "active";
}
export async function canAccessFeature(userId: string, feature: string) {
const subscription = await getUserSubscription(userId);
if (!subscription || !subscription.plan) {
return false; // Free plan
}
return subscription.plan.features.includes(feature);
}Usage in Components
// Server component
export default async function ProtectedFeature() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
redirect("/sign-in");
}
const hasAccess = await canAccessFeature(
session.user.id,
"Advanced Analytics"
);
if (!hasAccess) {
return (
<div>
<h2>Upgrade Required</h2>
<p>This feature requires a Pro or Startup plan.</p>
<Link href="/pricing">View Plans</Link>
</div>
);
}
return <AdvancedAnalyticsDashboard />;
}Client-Side Gating
"use client";
import { useSubscription } from "@/hooks/use-subscription";
export function PremiumFeature() {
const { subscription, isPending } = useSubscription();
if (isPending) return <Skeleton />;
const isPro =
subscription?.plan?.slug === "pro" ||
subscription?.plan?.slug === "startup";
if (!isPro) {
return <UpgradePrompt />;
}
return <PremiumContent />;
}Invoice Management
Viewing Invoices
Users can view their invoice history:
// app/dashboard/user/billing/page.tsx
import { db } from "@/db";
import { invoices } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
export default async function BillingPage() {
const session = await auth.api.getSession({ headers: await headers() });
const userInvoices = await db.query.invoices.findMany({
where: eq(invoices.userId, session.user.id),
orderBy: [desc(invoices.createdAt)],
});
return (
<div>
<h1>Billing History</h1>
<InvoiceTable invoices={userInvoices} />
</div>
);
}Invoice Data
{
stripeInvoiceId: string;
amountPaid: number; // in cents
amountDue: number;
currency: "usd";
status: "paid" | "open" | "void" | "uncollectible";
hostedInvoiceUrl: string; // Stripe-hosted page
invoicePdf: string; // PDF download link
periodStart: Date;
periodEnd: Date;
paidAt: Date;
}Testing
Test Mode
Use Stripe test mode during development:
STRIPE_SECRET_KEY="sk_test_xxxxxxxxxxxx"
STRIPE_PUBLISHABLE_KEY="pk_test_xxxxxxxxxxxx"Test Cards
Use Stripe test cards:
- Success:
4242 4242 4242 4242 - Decline:
4000 0000 0000 0002 - Requires Authentication:
4000 0025 0000 3155
Expiry: Any future date
CVC: Any 3 digits
ZIP: Any 5 digits
Webhook Testing
Test webhooks locally with Stripe CLI:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to local dev
stripe listen --forward-to localhost:3000/api/stripe/webhook
# Trigger test events
stripe trigger customer.subscription.created
stripe trigger invoice.paidProduction Deployment
1. Switch to Live Keys
STRIPE_SECRET_KEY="sk_live_xxxxxxxxxxxx"
STRIPE_PUBLISHABLE_KEY="pk_live_xxxxxxxxxxxx"2. Update Webhook Endpoint
Point webhook to production URL:
https://yourdomain.com/api/stripe/webhook3. Verify Integration
- Test checkout flow
- Verify webhooks are received
- Check subscription creation
- Test customer portal
Best Practices
- Always verify webhooks with signing secret
- Handle idempotency for webhook events
- Use metadata to link Stripe objects to your users
- Keep prices in cents to avoid floating-point issues
- Test failure scenarios (failed payments, etc.)
- Monitor Stripe Dashboard for issues
- Set up email notifications for failed payments
Troubleshooting
Webhook not received
- Verify webhook URL is accessible
- Check Stripe Dashboard > Webhooks for errors
- Ensure HTTPS in production
- Check webhook signing secret
Subscription not updating
- Check webhook handler logs
- Verify database updates in webhook code
- Ensure userId is in metadata
- Check Stripe event logs
Payment fails
- Verify test card numbers
- Check customer has valid payment method
- Review failed payment in Stripe Dashboard
- Ensure SCA (3D Secure) is handled