2 min read
SaaSSubscription Billing mit Stripe
Stripe Subscription Billing implementieren. Checkout, Webhooks, Customer Portal und Metered Billing für SaaS.
StripeSubscriptionBillingWebhooksCustomer PortalMetered Billing

Subscription Billing mit Stripe
Meta-Description: Stripe Subscription Billing implementieren. Checkout, Webhooks, Customer Portal und Metered Billing für SaaS.
Keywords: Stripe, Subscription, Billing, Webhooks, Customer Portal, Metered Billing, SaaS, Payment
Einführung
Stripe ist der De-facto-Standard für SaaS Billing. Von einfachen Subscriptions bis komplexem Metered Billing – Stripe bietet alle Tools. Dieser Guide zeigt die komplette Implementation für Next.js Anwendungen.
Stripe Architecture
┌─────────────────────────────────────────────────────────────┐
│ STRIPE BILLING ARCHITECTURE │
├─────────────────────────────────────────────────────────────┤
│ │
│ User Flow: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. User clicks "Subscribe" │ │
│ │ ↓ │ │
│ │ 2. Create Stripe Customer (if new) │ │
│ │ ↓ │ │
│ │ 3. Create Checkout Session │ │
│ │ ↓ │ │
│ │ 4. Redirect to Stripe Checkout │ │
│ │ ↓ │ │
│ │ 5. User completes payment │ │
│ │ ↓ │ │
│ │ 6. Stripe sends webhook │ │
│ │ ↓ │ │
│ │ 7. Update database, activate subscription │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Key Objects: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Customer: │ │
│ │ ├── email, name, metadata │ │
│ │ └── payment methods, invoices │ │
│ │ │ │
│ │ Product: │ │
│ │ ├── name, description │ │
│ │ └── has many Prices │ │
│ │ │ │
│ │ Price: │ │
│ │ ├── amount, currency, interval │ │
│ │ └── recurring or one-time │ │
│ │ │ │
│ │ Subscription: │ │
│ │ ├── customer, items (prices) │ │
│ │ ├── status: active, past_due, canceled │ │
│ │ └── billing cycle, trial │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Webhooks to Handle: │
│ ├── checkout.session.completed │
│ ├── customer.subscription.created │
│ ├── customer.subscription.updated │
│ ├── customer.subscription.deleted │
│ ├── invoice.paid │
│ ├── invoice.payment_failed │
│ └── customer.updated │
│ │
└─────────────────────────────────────────────────────────────┘Stripe Setup
// lib/stripe/client.ts
import Stripe from 'stripe';
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY is required');
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2024-06-20',
typescript: true
});
// lib/stripe/config.ts
export const STRIPE_CONFIG = {
prices: {
pro_monthly: process.env.STRIPE_PRICE_PRO_MONTHLY!,
pro_yearly: process.env.STRIPE_PRICE_PRO_YEARLY!,
enterprise_monthly: process.env.STRIPE_PRICE_ENTERPRISE_MONTHLY!,
enterprise_yearly: process.env.STRIPE_PRICE_ENTERPRISE_YEARLY!
},
products: {
pro: process.env.STRIPE_PRODUCT_PRO!,
enterprise: process.env.STRIPE_PRODUCT_ENTERPRISE!
},
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!
};
// Map price IDs to plan names
export const PRICE_TO_PLAN: Record<string, string> = {
[STRIPE_CONFIG.prices.pro_monthly]: 'pro',
[STRIPE_CONFIG.prices.pro_yearly]: 'pro',
[STRIPE_CONFIG.prices.enterprise_monthly]: 'enterprise',
[STRIPE_CONFIG.prices.enterprise_yearly]: 'enterprise'
};Customer Management
// lib/stripe/customers.ts
import { stripe } from './client';
import { db } from '@/lib/db';
export async function getOrCreateStripeCustomer(
userId: string
): Promise<string> {
// Check if user already has a Stripe customer ID
const user = await db.user.findUnique({
where: { id: userId },
select: { stripeCustomerId: true, email: true, name: true }
});
if (!user) {
throw new Error('User not found');
}
if (user.stripeCustomerId) {
return user.stripeCustomerId;
}
// Create new Stripe customer
const customer = await stripe.customers.create({
email: user.email!,
name: user.name || undefined,
metadata: {
userId
}
});
// Save customer ID to database
await db.user.update({
where: { id: userId },
data: { stripeCustomerId: customer.id }
});
return customer.id;
}
export async function getCustomerSubscription(
customerId: string
): Promise<Stripe.Subscription | null> {
const subscriptions = await stripe.subscriptions.list({
customer: customerId,
status: 'all',
limit: 1,
expand: ['data.default_payment_method']
});
return subscriptions.data[0] || null;
}
export async function updateCustomerEmail(
customerId: string,
email: string
): Promise<void> {
await stripe.customers.update(customerId, { email });
}Checkout Session
// lib/stripe/checkout.ts
import { stripe } from './client';
import { STRIPE_CONFIG } from './config';
import { getOrCreateStripeCustomer } from './customers';
interface CreateCheckoutOptions {
userId: string;
priceId: string;
quantity?: number;
trialDays?: number;
couponId?: string;
metadata?: Record<string, string>;
}
export async function createCheckoutSession(
options: CreateCheckoutOptions
): Promise<string> {
const customerId = await getOrCreateStripeCustomer(options.userId);
const sessionConfig: Stripe.Checkout.SessionCreateParams = {
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: options.priceId,
quantity: options.quantity || 1
}
],
subscription_data: {
trial_period_days: options.trialDays,
metadata: {
userId: options.userId,
...options.metadata
}
},
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
allow_promotion_codes: true,
billing_address_collection: 'auto',
customer_update: {
address: 'auto',
name: 'auto'
}
};
// Add coupon if provided
if (options.couponId) {
sessionConfig.discounts = [{ coupon: options.couponId }];
}
const session = await stripe.checkout.sessions.create(sessionConfig);
return session.url!;
}
// API Route
// app/api/checkout/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { createCheckoutSession } from '@/lib/stripe/checkout';
export async function POST(request: Request) {
try {
const session = await getServerSession();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { priceId, quantity } = await request.json();
const checkoutUrl = await createCheckoutSession({
userId: session.user.id,
priceId,
quantity,
trialDays: 14
});
return NextResponse.json({ url: checkoutUrl });
} catch (error) {
console.error('Checkout error:', error);
return NextResponse.json(
{ error: 'Failed to create checkout session' },
{ status: 500 }
);
}
}Webhook Handler
// app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe/client';
import { STRIPE_CONFIG, PRICE_TO_PLAN } from '@/lib/stripe/config';
import { db } from '@/lib/db';
import Stripe from 'stripe';
export async function POST(request: Request) {
const body = await request.text();
const headersList = await headers();
const signature = headersList.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
STRIPE_CONFIG.webhookSecret
);
} catch (error) {
console.error('Webhook signature verification failed:', error);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
try {
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
await handleSubscriptionChange(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
case 'invoice.paid':
await handleInvoicePaid(event.data.object as Stripe.Invoice);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook handler error:', error);
return NextResponse.json(
{ error: 'Webhook handler failed' },
{ status: 500 }
);
}
}
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
// Get user by Stripe customer ID
const user = await db.user.findFirst({
where: { stripeCustomerId: customerId }
});
if (!user) {
throw new Error(`User not found for customer: ${customerId}`);
}
// Get subscription details
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const priceId = subscription.items.data[0].price.id;
const plan = PRICE_TO_PLAN[priceId] || 'free';
// Update user subscription
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscriptionId,
plan,
subscriptionStatus: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000)
}
});
// Send welcome email
await sendWelcomeEmail(user.email!, plan);
}
async function handleSubscriptionChange(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string;
const user = await db.user.findFirst({
where: { stripeCustomerId: customerId }
});
if (!user) return;
const priceId = subscription.items.data[0].price.id;
const plan = PRICE_TO_PLAN[priceId] || 'free';
await db.user.update({
where: { id: user.id },
data: {
plan,
subscriptionStatus: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end
}
});
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string;
const user = await db.user.findFirst({
where: { stripeCustomerId: customerId }
});
if (!user) return;
await db.user.update({
where: { id: user.id },
data: {
plan: 'free',
subscriptionStatus: 'canceled',
stripeSubscriptionId: null
}
});
}
async function handleInvoicePaid(invoice: Stripe.Invoice) {
// Record payment in database
if (invoice.subscription) {
await db.payment.create({
data: {
stripeInvoiceId: invoice.id,
stripeCustomerId: invoice.customer as string,
amount: invoice.amount_paid,
currency: invoice.currency,
status: 'paid',
paidAt: new Date()
}
});
}
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string;
const user = await db.user.findFirst({
where: { stripeCustomerId: customerId }
});
if (!user) return;
// Update subscription status
await db.user.update({
where: { id: user.id },
data: { subscriptionStatus: 'past_due' }
});
// Send payment failed email
await sendPaymentFailedEmail(user.email!);
}Customer Portal
// lib/stripe/portal.ts
import { stripe } from './client';
export async function createBillingPortalSession(
customerId: string,
returnUrl?: string
): Promise<string> {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl || `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`
});
return session.url;
}
// API Route
// app/api/billing/portal/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { createBillingPortalSession } from '@/lib/stripe/portal';
import { db } from '@/lib/db';
export async function POST(request: Request) {
try {
const session = await getServerSession();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true }
});
if (!user?.stripeCustomerId) {
return NextResponse.json(
{ error: 'No billing account found' },
{ status: 400 }
);
}
const portalUrl = await createBillingPortalSession(user.stripeCustomerId);
return NextResponse.json({ url: portalUrl });
} catch (error) {
console.error('Portal error:', error);
return NextResponse.json(
{ error: 'Failed to create portal session' },
{ status: 500 }
);
}
}Metered Billing
// lib/stripe/metered.ts
import { stripe } from './client';
import { db } from '@/lib/db';
export async function reportUsage(
userId: string,
quantity: number,
idempotencyKey?: string
): Promise<void> {
const user = await db.user.findUnique({
where: { id: userId },
select: { stripeSubscriptionId: true }
});
if (!user?.stripeSubscriptionId) {
throw new Error('User has no active subscription');
}
// Get the subscription item for metered billing
const subscription = await stripe.subscriptions.retrieve(
user.stripeSubscriptionId,
{ expand: ['items.data.price'] }
);
const meteredItem = subscription.items.data.find(
item => (item.price as Stripe.Price).recurring?.usage_type === 'metered'
);
if (!meteredItem) {
throw new Error('No metered billing item found');
}
// Report usage
await stripe.subscriptionItems.createUsageRecord(
meteredItem.id,
{
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: 'increment'
},
{
idempotencyKey
}
);
}
// Get usage for current period
export async function getCurrentUsage(userId: string): Promise<{
used: number;
limit: number | null;
}> {
const user = await db.user.findUnique({
where: { id: userId },
select: { stripeSubscriptionId: true }
});
if (!user?.stripeSubscriptionId) {
return { used: 0, limit: null };
}
const subscription = await stripe.subscriptions.retrieve(
user.stripeSubscriptionId
);
const meteredItem = subscription.items.data.find(
item => item.price.recurring?.usage_type === 'metered'
);
if (!meteredItem) {
return { used: 0, limit: null };
}
// Get usage records
const usageRecords = await stripe.subscriptionItems.listUsageRecordSummaries(
meteredItem.id,
{ limit: 1 }
);
const currentUsage = usageRecords.data[0]?.total_usage || 0;
return {
used: currentUsage,
limit: null // or get from subscription metadata
};
}Subscription Management UI
// components/billing/SubscriptionStatus.tsx
'use client';
import { useState } from 'react';
import { useSubscription } from '@/hooks/useSubscription';
export function SubscriptionStatus() {
const { subscription, isLoading } = useSubscription();
const [isPortalLoading, setIsPortalLoading] = useState(false);
const openBillingPortal = async () => {
setIsPortalLoading(true);
try {
const res = await fetch('/api/billing/portal', { method: 'POST' });
const { url } = await res.json();
window.location.href = url;
} catch (error) {
console.error('Portal error:', error);
}
setIsPortalLoading(false);
};
if (isLoading) return <div>Loading...</div>;
return (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">Subscription</h2>
<div className="space-y-4">
<div className="flex justify-between">
<span className="text-gray-600">Current Plan</span>
<span className="font-semibold capitalize">
{subscription?.plan || 'Free'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Status</span>
<StatusBadge status={subscription?.status || 'inactive'} />
</div>
{subscription?.currentPeriodEnd && (
<div className="flex justify-between">
<span className="text-gray-600">
{subscription.cancelAtPeriodEnd ? 'Cancels on' : 'Renews on'}
</span>
<span>
{new Date(subscription.currentPeriodEnd).toLocaleDateString()}
</span>
</div>
)}
<hr />
<button
onClick={openBillingPortal}
disabled={isPortalLoading}
className="w-full py-2 bg-gray-100 hover:bg-gray-200 rounded-lg"
>
{isPortalLoading ? 'Loading...' : 'Manage Subscription'}
</button>
</div>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
active: 'bg-green-100 text-green-800',
trialing: 'bg-blue-100 text-blue-800',
past_due: 'bg-yellow-100 text-yellow-800',
canceled: 'bg-red-100 text-red-800',
inactive: 'bg-gray-100 text-gray-800'
};
return (
<span className={`px-2 py-1 rounded-full text-sm ${colors[status] || colors.inactive}`}>
{status.replace('_', ' ')}
</span>
);
}Best Practices
| Aspect | Recommendation |
|---|---|
| **Webhooks** | Always verify signatures |
| **Idempotency** | Use idempotency keys |
| **Retries** | Handle webhook retries |
| **Testing** | Use Stripe CLI for local testing |
| **Errors** | Log all Stripe errors |
| **Security** | Never expose secret keys |
Fazit
Stripe Subscription Billing erfordert:
- Customer Management: Stripe Customer ID syncen
- Webhooks: Alle relevanten Events handeln
- Portal: Kunden Self-Service ermöglichen
- Metered: Usage-Based Billing für Flexibilität
Robustes Billing ist Grundlage für SaaS-Erfolg.
Bildprompts
- "Stripe checkout page, credit card form with security badges"
- "Subscription management dashboard, plan upgrade flow"
- "Webhook event flow diagram, Stripe to application"