Integrating payments is one of the most critical parts of building a SaaS application. Stripe makes this process straightforward, but there are important patterns and pitfalls to understand.
Setting Up Stripe
First, install the Stripe SDK:
pnpm add stripe @stripe/stripe-js
Create a server-side Stripe client:
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-11-20.acacia",
typescript: true,
});
Creating Products and Prices
Before accepting payments, set up your products in Stripe Dashboard or via API:
// Create a product
const product = await stripe.products.create({
name: "Pro Plan",
description: "Full access to all features",
});
// Create a recurring price
const price = await stripe.prices.create({
product: product.id,
unit_amount: 2900, // $29.00
currency: "usd",
recurring: {
interval: "month",
},
});
Checkout Sessions
The simplest way to accept payments is through Checkout Sessions:
export async function createCheckoutSession(
userId: string,
priceId: string
) {
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?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
metadata: {
userId,
},
});
return session;
}
Webhook Handling
Webhooks are essential for keeping your database in sync with Stripe:
import { headers } from "next/headers";
import { stripe } from "@/lib/billing/stripe";
export async function POST(req: Request) {
const body = await req.text();
const signature = headers().get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response("Webhook signature verification failed", {
status: 400,
});
}
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutComplete(event.data.object);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdate(event.data.object);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object);
break;
}
return new Response("OK", { status: 200 });
}
Managing Subscriptions
Checking Subscription Status
Always check subscription status server-side:
export async function getUserSubscription(userId: string) {
const subscription = await prisma.subscription.findUnique({
where: { userId },
});
if (!subscription) {
return { plan: "free", status: "none" };
}
return {
plan: subscription.plan,
status: subscription.status,
currentPeriodEnd: subscription.currentPeriodEnd,
};
}
Upgrading and Downgrading
Handle plan changes gracefully:
export async function updateSubscription(
subscriptionId: string,
newPriceId: string
) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
await stripe.subscriptions.update(subscriptionId, {
items: [
{
id: subscription.items.data[0].id,
price: newPriceId,
},
],
proration_behavior: "create_prorations",
});
}
Cancellation
Let users cancel while retaining access until period end:
export async function cancelSubscription(subscriptionId: string) {
await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
}
Customer Portal
Stripe's Customer Portal handles billing management:
export async function createPortalSession(customerId: string) {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/settings/billing`,
});
return session.url;
}
Testing Payments
Use Stripe's test mode and test card numbers:
| Card Number | Scenario |
|---|---|
| 4242 4242 4242 4242 | Successful payment |
| 4000 0000 0000 0002 | Declined card |
| 4000 0000 0000 3220 | 3D Secure required |
Handling Failed Payments
Set up dunning management in Stripe Dashboard, and handle failed payment webhooks:
async function handlePaymentFailed(invoice: Stripe.Invoice) {
const userId = invoice.metadata?.userId;
if (userId) {
// Send email notification
await sendEmail({
to: invoice.customer_email!,
subject: "Payment failed",
template: "payment-failed",
});
// Update user status in database
await prisma.user.update({
where: { id: userId },
data: { paymentStatus: "failed" },
});
}
}
Security Best Practices
- Never expose secret keys - Keep
STRIPE_SECRET_KEYserver-side only - Verify webhook signatures - Always validate incoming webhooks
- Use idempotency keys - Prevent duplicate charges on retries
- Log everything - Keep audit trails of all payment events
Common Pitfalls
1. Not Handling Webhook Retries
Stripe retries failed webhooks. Make your handlers idempotent:
async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
// Check if already processed
const existing = await prisma.subscription.findFirst({
where: { stripeSessionId: session.id },
});
if (existing) {
return; // Already processed
}
// Process the checkout...
}
2. Ignoring Subscription States
Subscriptions have multiple states (active, past_due, canceled, etc.). Handle them appropriately:
function hasActiveSubscription(status: string) {
return ["active", "trialing"].includes(status);
}
3. Not Testing Edge Cases
Test these scenarios:
- Payment failures during checkout
- Subscription upgrades and downgrades
- Cancellations and reactivations
- Currency changes
- Tax calculations
Conclusion
Stripe integration doesn't have to be complex. Start with Checkout Sessions, add webhook handling for state synchronization, and use the Customer Portal for billing management. This combination covers 90% of SaaS payment needs.