Tutorial
Integrating Stripe Payments in Your SaaS
DDavid Kim
December 28, 2023
12 min read
StripePaymentsTutorial
Integrating Stripe Payments in Your SaaS
Payment processing is critical for any SaaS. Here's how to integrate Stripe properly.
Why Stripe?
Stripe is the industry standard for SaaS payments because:
- Developer-friendly API
- Handles PCI compliance
- Supports 135+ currencies
- Built-in subscription management
- Excellent documentation
Setup
1. Install Dependencies
npm install stripe @stripe/stripe-js
2. Environment Variables
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
Creating Products and Prices
// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-11-20.acacia',
});
// Create products (do this once)
const product = await stripe.products.create({
name: 'Pro Plan',
description: 'Full access to all features',
});
const price = await stripe.prices.create({
product: product.id,
unit_amount: 2999, // $29.99
currency: 'usd',
recurring: {
interval: 'month',
},
});
Checkout Flow
Create Checkout Session
// app/api/checkout/route.ts
import { auth } from '@/auth';
import { stripe } from '@/lib/stripe';
export async function POST(req: Request) {
const session = await auth();
if (!session?.user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const { priceId } = await req.json();
const checkoutSession = await stripe.checkout.sessions.create({
customer_email: session.user.email,
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'subscription',
success_url: `${process.env.NEXT_PUBLIC_WEB_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_WEB_URL}/pricing?canceled=true`,
metadata: {
userId: session.user.id,
},
});
return Response.json({ url: checkoutSession.url });
}
Frontend Component
'use client';
export function SubscribeButton({ priceId }: { priceId: string }) {
const [loading, setLoading] = useState(false);
const handleSubscribe = async () => {
setLoading(true);
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
});
const { url } = await response.json();
window.location.href = url;
};
return (
<button onClick={handleSubscribe} disabled={loading}>
{loading ? 'Loading...' : 'Subscribe Now'}
</button>
);
}
Webhook Handling
Webhooks are crucial for keeping your database in sync.
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
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 Response.json({ error: 'Invalid signature' }, { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
// Update user subscription status
await db.update(users).set({
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
subscriptionStatus: 'active',
}).where(eq(users.id, session.metadata!.userId));
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await db.update(users).set({
subscriptionStatus: subscription.status,
}).where(eq(users.stripeSubscriptionId, subscription.id));
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await db.update(users).set({
subscriptionStatus: 'canceled',
stripeSubscriptionId: null,
}).where(eq(users.stripeSubscriptionId, subscription.id));
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
// Send payment failure email
await sendPaymentFailedEmail(invoice.customer_email);
break;
}
}
return Response.json({ received: true });
}
Customer Portal
Let users manage their subscription:
// app/api/billing-portal/route.ts
export async function POST(req: Request) {
const session = await auth();
const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
});
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId!,
return_url: `${process.env.NEXT_PUBLIC_WEB_URL}/dashboard`,
});
return Response.json({ url: portalSession.url });
}
Usage-Based Billing
For metered pricing (like credits):
// Report usage
await stripe.subscriptionItems.createUsageRecord(
subscriptionItemId,
{
quantity: 100, // credits used
timestamp: Math.floor(Date.now() / 1000),
action: 'increment',
}
);
Testing
Use Stripe test cards:
- Success:
4242 4242 4242 4242 - Decline:
4000 0000 0000 0002 - 3D Secure:
4000 0025 0000 3155
Security Best Practices
- Never expose secret key - Use environment variables
- Validate webhooks - Always verify signatures
- Use HTTPS - Required for Stripe
- Handle errors gracefully - Payment failures happen
Go Live Checklist
- Switch to live API keys
- Configure webhook endpoint in Stripe Dashboard
- Test all payment flows
- Set up email notifications
- Configure tax settings
- Review subscription settings
Conclusion
Stripe makes payment processing straightforward. Follow these patterns, and you'll have a robust billing system.
Happy coding! 💳
D
Written by David Kim
Content creator and developer advocate passionate about helping developers build better products.