Stripe Payment Integration: From Zero to Production in 2025.
Complete guide to integrating Stripe payments in your SaaS. Subscriptions, webhooks, customer portal, and billing management made simple.
Stripe Payment Integration: From Zero to Production in 2025
Stripe is the backbone of successful SaaS businesses. Get payments right, and you can focus on building features. Get them wrong, and you'll lose customers and sleep.
Here's the complete blueprint for Stripe integration that I use for every SaaS project.
Why Stripe Dominates SaaS Payments
- Global reach → Accept payments worldwide
- Subscription management → Built-in recurring billing
- Developer experience → Best-in-class APIs
- Compliance → PCI DSS handled for you
- Webhooks → Real-time event notifications
- Customer portal → Self-service billing
Bottom line: Stripe handles the complexity, you handle the growth.
Project Setup
1. Install Dependencies
npm install stripe @stripe/stripe-js
npm install -D @types/stripe
2. Environment Variables
# .env.local
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
3. Stripe Configuration
// lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2023-10-16",
typescript: true,
});
// Client-side configuration
// lib/stripe-client.ts
import { loadStripe } from "@stripe/stripe-js";
export const getStripe = () => {
return loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
};
Database Schema for Payments
model User {
id String @id @default(cuid())
email String @unique
name String?
// Stripe customer data
stripeCustomerId String? @unique
// Subscription info
stripeSubscriptionId String? @unique
stripePriceId String?
stripeCurrentPeriodEnd DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
model Product {
id String @id @default(cuid())
name String
description String?
// Stripe product ID
stripeId String @unique
// Pricing tiers
prices Price[]
active Boolean @default(true)
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("products")
}
model Price {
id String @id @default(cuid())
productId String
// Stripe price ID
stripeId String @unique
// Pricing details
unitAmount Int // Amount in cents
currency String @default("usd")
interval PriceInterval
intervalCount Int @default(1)
// Display
nickname String?
active Boolean @default(true)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@map("prices")
}
enum PriceInterval {
MONTH
YEAR
}
Subscription Checkout Flow
1. Create Checkout Session
// app/api/stripe/create-checkout/route.ts
import { stripe } from "@/lib/stripe";
import { getCurrentUser } from "@/lib/auth";
export async function POST(request: Request) {
try {
const { priceId } = await request.json();
const user = await getCurrentUser();
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
// Create or retrieve Stripe customer
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
name: user.name || undefined,
metadata: {
userId: user.id,
},
});
customerId = customer.id;
// Update user with customer ID
await prisma.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
// Create checkout session
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
payment_method_types: ["card"],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
allow_promotion_codes: true,
billing_address_collection: "required",
customer_update: {
address: "auto",
name: "auto",
},
});
return Response.json({ sessionId: session.id });
} catch (error) {
console.error("Error creating checkout session:", error);
return new Response("Internal Server Error", { status: 500 });
}
}
2. Client-Side Checkout
// components/checkout-button.tsx
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { getStripe } from "@/lib/stripe-client";
interface CheckoutButtonProps {
priceId: string;
children: React.ReactNode;
}
export function CheckoutButton({ priceId, children }: CheckoutButtonProps) {
const [loading, setLoading] = useState(false);
const handleCheckout = async () => {
setLoading(true);
try {
// Create checkout session
const response = await fetch("/api/stripe/create-checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId }),
});
const { sessionId } = await response.json();
// Redirect to Stripe Checkout
const stripe = await getStripe();
const { error } = await stripe!.redirectToCheckout({ sessionId });
if (error) {
console.error("Stripe error:", error);
}
} catch (error) {
console.error("Checkout error:", error);
} finally {
setLoading(false);
}
};
return (
<Button onClick={handleCheckout} disabled={loading} className="w-full">
{loading ? "Loading..." : children}
</Button>
);
}
Webhook Integration
1. Webhook Handler
// app/api/stripe/webhooks/route.ts
import { headers } from "next/headers";
import { stripe } from "@/lib/stripe";
import { prisma } from "@/lib/prisma";
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: Request) {
const body = await request.text();
const sig = headers().get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return new Response("Webhook signature verification failed", {
status: 400,
});
}
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
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_succeeded": {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentSucceeded(invoice);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailed(invoice);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
return new Response("Webhook handled successfully", { status: 200 });
} catch (error) {
console.error("Webhook handler error:", error);
return new Response("Webhook handler failed", { status: 500 });
}
}
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
if (session.mode === "subscription") {
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
await updateUserSubscription(session.customer as string, subscription);
}
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
await updateUserSubscription(subscription.customer as string, subscription);
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
await prisma.user.updateMany({
where: { stripeCustomerId: subscription.customer as string },
data: {
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
},
});
}
async function updateUserSubscription(
customerId: string,
subscription: Stripe.Subscription
) {
const price = subscription.items.data[0]?.price;
await prisma.user.updateMany({
where: { stripeCustomerId: customerId },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: price?.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
}
2. Webhook Testing
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login to Stripe
stripe login
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/stripe/webhooks
Customer Portal Integration
1. Create Portal Session
// app/api/stripe/create-portal/route.ts
export async function POST(request: Request) {
try {
const user = await getCurrentUser();
if (!user?.stripeCustomerId) {
return new Response("No customer found", { status: 404 });
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
});
return Response.json({ url: session.url });
} catch (error) {
console.error("Error creating portal session:", error);
return new Response("Internal Server Error", { status: 500 });
}
}
2. Portal Button Component
// components/manage-subscription-button.tsx
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
export function ManageSubscriptionButton() {
const [loading, setLoading] = useState(false);
const handleManageSubscription = async () => {
setLoading(true);
try {
const response = await fetch("/api/stripe/create-portal", {
method: "POST",
});
const { url } = await response.json();
window.location.href = url;
} catch (error) {
console.error("Error redirecting to portal:", error);
} finally {
setLoading(false);
}
};
return (
<Button
onClick={handleManageSubscription}
disabled={loading}
variant="outline"
>
{loading ? "Loading..." : "Manage Subscription"}
</Button>
);
}
Usage-Based Billing
1. Report Usage
// Report usage for metered billing
export async function reportUsage(
subscriptionItemId: string,
quantity: number,
timestamp?: number
) {
try {
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity,
timestamp: timestamp || Math.floor(Date.now() / 1000),
action: "increment",
});
} catch (error) {
console.error("Error reporting usage:", error);
}
}
// Usage tracking middleware
export async function trackAPIUsage(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { stripeSubscriptionId: true },
});
if (user?.stripeSubscriptionId) {
const subscription = await stripe.subscriptions.retrieve(
user.stripeSubscriptionId
);
// Find metered item
const meteredItem = subscription.items.data.find(
(item) => item.price.billing_scheme === "per_unit"
);
if (meteredItem) {
await reportUsage(meteredItem.id, 1);
}
}
}
Subscription Status Utilities
// lib/subscription.ts
export async function getUserSubscription(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
stripeSubscriptionId: true,
stripePriceId: true,
stripeCurrentPeriodEnd: true,
stripeCustomerId: true,
},
});
if (!user) return null;
const isActive =
user.stripeSubscriptionId &&
user.stripeCurrentPeriodEnd &&
user.stripeCurrentPeriodEnd > new Date();
return {
...user,
isActive,
isPro: isActive && user.stripePriceId === "price_pro_monthly",
};
}
export async function requireSubscription(userId: string) {
const subscription = await getUserSubscription(userId);
if (!subscription?.isActive) {
throw new Error("Active subscription required");
}
return subscription;
}
// Usage in API routes
export async function GET(request: Request) {
const user = await getCurrentUser();
const subscription = await requireSubscription(user.id);
// Protected functionality here
return Response.json({ data: "pro-only-data" });
}
Error Handling & Monitoring
1. Payment Error Handling
// components/checkout-button.tsx
const handleCheckout = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/stripe/create-checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Checkout failed");
}
const { sessionId } = await response.json();
const stripe = await getStripe();
const { error } = await stripe!.redirectToCheckout({ sessionId });
if (error) {
setError(error.message);
}
} catch (error) {
setError(error instanceof Error ? error.message : "Something went wrong");
} finally {
setLoading(false);
}
};
2. Webhook Monitoring
// lib/monitoring.ts
export async function logWebhookEvent(
eventType: string,
success: boolean,
error?: string
) {
await prisma.webhookLog.create({
data: {
eventType,
success,
error,
timestamp: new Date(),
},
});
}
// Add to webhook handler
try {
// Process webhook...
await logWebhookEvent(event.type, true);
} catch (error) {
await logWebhookEvent(event.type, false, error.message);
throw error;
}
Testing Strategy
1. Test Cards
// Use Stripe test cards
const testCards = {
visa: "4242424242424242",
visaDebit: "4000056655665556",
declined: "4000000000000002",
requiresAuthentication: "4000002760003184",
};
2. Integration Tests
// __tests__/stripe-integration.test.ts
describe("Stripe Integration", () => {
test("creates checkout session", async () => {
const response = await fetch("/api/stripe/create-checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId: "price_test_123" }),
});
const data = await response.json();
expect(data.sessionId).toBeTruthy();
});
test("handles webhook events", async () => {
const mockEvent = createMockWebhookEvent("checkout.session.completed");
const response = await fetch("/api/stripe/webhooks", {
method: "POST",
headers: { "stripe-signature": "test_signature" },
body: JSON.stringify(mockEvent),
});
expect(response.status).toBe(200);
});
});
Security Best Practices
- Always verify webhooks with signature verification
- Use HTTPS in production for all Stripe interactions
- Store minimal data - let Stripe be the source of truth
- Implement idempotency for webhook handlers
- Rate limit checkout endpoints
- Log all payment events for debugging
This Stripe integration approach has processed millions in revenue for SaaS products I've built.
Need this implemented in your product? €900/month gets you production-ready payment flows.
P.S. Payments are the heart of your SaaS. Get them right, and everything else becomes easier.