Tutorial: Payment Integration
Add Stripe subscription payments to your application using Bootspring.
What You'll Build#
- Pricing page with plan selection
- Stripe Checkout integration
- Customer portal access
- Webhook handling
- Subscription status management
Prerequisites#
- Next.js project with authentication
- Bootspring initialized
- Stripe account (test mode)
- Database with User model
Time Required#
Approximately 45 minutes.
Step 1: Set Up Stripe#
Create Stripe Account#
- Go to stripe.com and sign up
- Enable test mode (toggle in dashboard)
- Create products and prices
Create Products#
In Stripe Dashboard → Products:
Free Plan
- Name: Free
- Price: $0/month (or skip)
Pro Plan
- Name: Pro
- Price: $29/month
- Price ID:
price_pro_monthly - Annual: $290/year
- Price ID:
price_pro_annual
Team Plan
- Name: Team
- Price: $99/month per seat
- Price ID:
price_team_monthly
Add Environment Variables#
1# .env.local
2STRIPE_SECRET_KEY=sk_test_...
3STRIPE_WEBHOOK_SECRET=whsec_...
4NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
5
6# Price IDs
7STRIPE_PRICE_PRO_MONTHLY=price_...
8STRIPE_PRICE_PRO_ANNUAL=price_...
9STRIPE_PRICE_TEAM_MONTHLY=price_...Step 2: Apply Payment Skills#
bootspring skill apply payments/stripe-checkout
bootspring skill apply payments/stripe-webhooks
bootspring skill apply payments/stripe-portalStep 3: Install Dependencies#
npm install stripe @stripe/stripe-jsStep 4: Create Stripe Client#
Ask the payment-expert:
Set up Stripe client for server and client-side usage.
Server Client#
1// lib/stripe.ts
2import Stripe from 'stripe';
3
4export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
5 apiVersion: '2023-10-16',
6 typescript: true,
7});
8
9export const PLANS = {
10 FREE: {
11 name: 'Free',
12 price: 0,
13 features: ['5 projects', '1,000 API calls', 'Community support'],
14 },
15 PRO: {
16 name: 'Pro',
17 price: 29,
18 priceId: {
19 monthly: process.env.STRIPE_PRICE_PRO_MONTHLY!,
20 annual: process.env.STRIPE_PRICE_PRO_ANNUAL!,
21 },
22 features: [
23 '10 projects',
24 '10,000 API calls',
25 'Priority support',
26 'Business agents',
27 ],
28 },
29 TEAM: {
30 name: 'Team',
31 price: 99,
32 priceId: {
33 monthly: process.env.STRIPE_PRICE_TEAM_MONTHLY!,
34 },
35 features: [
36 'Unlimited projects',
37 '50,000 API calls',
38 'Dedicated support',
39 'All agents',
40 'Custom workflows',
41 ],
42 },
43} as const;Client-Side#
1// lib/stripe-client.ts
2import { loadStripe } from '@stripe/stripe-js';
3
4export const getStripe = () => {
5 return loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
6};Step 5: Update Database Schema#
1// prisma/schema.prisma
2model User {
3 id String @id @default(cuid())
4 clerkId String @unique
5 email String @unique
6 name String?
7
8 // Stripe fields
9 stripeCustomerId String? @unique
10 stripeSubscriptionId String? @unique
11 stripePriceId String?
12 stripeCurrentPeriodEnd DateTime?
13
14 createdAt DateTime @default(now())
15 updatedAt DateTime @updatedAt
16}Run migration:
npx prisma db pushStep 6: Create Checkout API#
1// app/api/stripe/checkout/route.ts
2import { auth } from '@clerk/nextjs';
3import { NextResponse } from 'next/server';
4import { stripe } from '@/lib/stripe';
5import { prisma } from '@/lib/prisma';
6
7export async function POST(req: Request) {
8 const { userId } = auth();
9
10 if (!userId) {
11 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
12 }
13
14 const { priceId } = await req.json();
15
16 if (!priceId) {
17 return NextResponse.json({ error: 'Price ID required' }, { status: 400 });
18 }
19
20 const user = await prisma.user.findUnique({
21 where: { clerkId: userId },
22 });
23
24 if (!user) {
25 return NextResponse.json({ error: 'User not found' }, { status: 404 });
26 }
27
28 // Create or get Stripe customer
29 let customerId = user.stripeCustomerId;
30
31 if (!customerId) {
32 const customer = await stripe.customers.create({
33 email: user.email,
34 metadata: {
35 userId: user.id,
36 clerkId: userId,
37 },
38 });
39
40 await prisma.user.update({
41 where: { id: user.id },
42 data: { stripeCustomerId: customer.id },
43 });
44
45 customerId = customer.id;
46 }
47
48 // Create checkout session
49 const session = await stripe.checkout.sessions.create({
50 customer: customerId,
51 mode: 'subscription',
52 payment_method_types: ['card'],
53 line_items: [
54 {
55 price: priceId,
56 quantity: 1,
57 },
58 ],
59 success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
60 cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
61 metadata: {
62 userId: user.id,
63 },
64 });
65
66 return NextResponse.json({ url: session.url });
67}Step 7: Create Customer Portal API#
1// app/api/stripe/portal/route.ts
2import { auth } from '@clerk/nextjs';
3import { NextResponse } from 'next/server';
4import { stripe } from '@/lib/stripe';
5import { prisma } from '@/lib/prisma';
6
7export async function POST() {
8 const { userId } = auth();
9
10 if (!userId) {
11 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
12 }
13
14 const user = await prisma.user.findUnique({
15 where: { clerkId: userId },
16 });
17
18 if (!user?.stripeCustomerId) {
19 return NextResponse.json(
20 { error: 'No billing account' },
21 { status: 400 }
22 );
23 }
24
25 const session = await stripe.billingPortal.sessions.create({
26 customer: user.stripeCustomerId,
27 return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
28 });
29
30 return NextResponse.json({ url: session.url });
31}Step 8: Create Webhook Handler#
1// app/api/webhooks/stripe/route.ts
2import { headers } from 'next/headers';
3import { NextResponse } from 'next/server';
4import Stripe from 'stripe';
5import { stripe } from '@/lib/stripe';
6import { prisma } from '@/lib/prisma';
7
8export async function POST(req: Request) {
9 const body = await req.text();
10 const signature = headers().get('Stripe-Signature') as string;
11
12 let event: Stripe.Event;
13
14 try {
15 event = stripe.webhooks.constructEvent(
16 body,
17 signature,
18 process.env.STRIPE_WEBHOOK_SECRET!
19 );
20 } catch (error) {
21 console.error('Webhook signature verification failed:', error);
22 return NextResponse.json(
23 { error: 'Invalid signature' },
24 { status: 400 }
25 );
26 }
27
28 try {
29 switch (event.type) {
30 case 'checkout.session.completed': {
31 const session = event.data.object as Stripe.Checkout.Session;
32
33 if (session.mode === 'subscription') {
34 const subscription = await stripe.subscriptions.retrieve(
35 session.subscription as string
36 );
37
38 await prisma.user.update({
39 where: { stripeCustomerId: session.customer as string },
40 data: {
41 stripeSubscriptionId: subscription.id,
42 stripePriceId: subscription.items.data[0].price.id,
43 stripeCurrentPeriodEnd: new Date(
44 subscription.current_period_end * 1000
45 ),
46 },
47 });
48 }
49 break;
50 }
51
52 case 'customer.subscription.updated': {
53 const subscription = event.data.object as Stripe.Subscription;
54
55 await prisma.user.update({
56 where: { stripeCustomerId: subscription.customer as string },
57 data: {
58 stripePriceId: subscription.items.data[0].price.id,
59 stripeCurrentPeriodEnd: new Date(
60 subscription.current_period_end * 1000
61 ),
62 },
63 });
64 break;
65 }
66
67 case 'customer.subscription.deleted': {
68 const subscription = event.data.object as Stripe.Subscription;
69
70 await prisma.user.update({
71 where: { stripeCustomerId: subscription.customer as string },
72 data: {
73 stripeSubscriptionId: null,
74 stripePriceId: null,
75 stripeCurrentPeriodEnd: null,
76 },
77 });
78 break;
79 }
80 }
81
82 return NextResponse.json({ received: true });
83 } catch (error) {
84 console.error('Webhook handler error:', error);
85 return NextResponse.json(
86 { error: 'Webhook handler failed' },
87 { status: 500 }
88 );
89 }
90}Step 9: Create Pricing Page#
1// app/pricing/page.tsx
2import { auth } from '@clerk/nextjs';
3import { prisma } from '@/lib/prisma';
4import { PLANS } from '@/lib/stripe';
5import { PricingCard } from '@/components/pricing/PricingCard';
6
7export default async function PricingPage() {
8 const { userId } = auth();
9
10 let currentPlan = 'FREE';
11
12 if (userId) {
13 const user = await prisma.user.findUnique({
14 where: { clerkId: userId },
15 select: { stripePriceId: true },
16 });
17
18 if (user?.stripePriceId) {
19 if (user.stripePriceId.includes('pro')) {
20 currentPlan = 'PRO';
21 } else if (user.stripePriceId.includes('team')) {
22 currentPlan = 'TEAM';
23 }
24 }
25 }
26
27 return (
28 <div className="py-16">
29 <div className="text-center mb-12">
30 <h1 className="text-4xl font-bold mb-4">Simple, Transparent Pricing</h1>
31 <p className="text-xl text-gray-600">
32 Start free, upgrade when you need more
33 </p>
34 </div>
35
36 <div className="container mx-auto px-4">
37 <div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
38 <PricingCard
39 plan="FREE"
40 name={PLANS.FREE.name}
41 price={PLANS.FREE.price}
42 features={PLANS.FREE.features}
43 current={currentPlan === 'FREE'}
44 />
45 <PricingCard
46 plan="PRO"
47 name={PLANS.PRO.name}
48 price={PLANS.PRO.price}
49 priceId={PLANS.PRO.priceId.monthly}
50 features={PLANS.PRO.features}
51 current={currentPlan === 'PRO'}
52 popular
53 />
54 <PricingCard
55 plan="TEAM"
56 name={PLANS.TEAM.name}
57 price={PLANS.TEAM.price}
58 priceId={PLANS.TEAM.priceId.monthly}
59 features={PLANS.TEAM.features}
60 current={currentPlan === 'TEAM'}
61 />
62 </div>
63 </div>
64 </div>
65 );
66}Pricing Card Component#
1// components/pricing/PricingCard.tsx
2'use client';
3
4import { useState } from 'react';
5import { useRouter } from 'next/navigation';
6import { useAuth } from '@clerk/nextjs';
7
8interface PricingCardProps {
9 plan: string;
10 name: string;
11 price: number;
12 priceId?: string;
13 features: string[];
14 current?: boolean;
15 popular?: boolean;
16}
17
18export function PricingCard({
19 plan,
20 name,
21 price,
22 priceId,
23 features,
24 current,
25 popular,
26}: PricingCardProps) {
27 const router = useRouter();
28 const { isSignedIn } = useAuth();
29 const [loading, setLoading] = useState(false);
30
31 const handleSubscribe = async () => {
32 if (!isSignedIn) {
33 router.push('/sign-up');
34 return;
35 }
36
37 if (!priceId) return;
38
39 setLoading(true);
40
41 try {
42 const response = await fetch('/api/stripe/checkout', {
43 method: 'POST',
44 headers: { 'Content-Type': 'application/json' },
45 body: JSON.stringify({ priceId }),
46 });
47
48 const { url } = await response.json();
49 window.location.href = url;
50 } catch (error) {
51 console.error('Checkout error:', error);
52 } finally {
53 setLoading(false);
54 }
55 };
56
57 return (
58 <div
59 className={`
60 rounded-2xl p-8 border-2
61 ${popular ? 'border-blue-500 shadow-lg' : 'border-gray-200'}
62 ${current ? 'bg-blue-50' : 'bg-white'}
63 `}
64 >
65 {popular && (
66 <div className="text-blue-600 text-sm font-semibold mb-2">
67 Most Popular
68 </div>
69 )}
70
71 <h3 className="text-xl font-bold">{name}</h3>
72
73 <div className="mt-4 mb-6">
74 <span className="text-4xl font-bold">${price}</span>
75 {price > 0 && <span className="text-gray-600">/month</span>}
76 </div>
77
78 <ul className="space-y-3 mb-8">
79 {features.map((feature) => (
80 <li key={feature} className="flex items-center gap-2">
81 <svg
82 className="w-5 h-5 text-green-500"
83 fill="none"
84 viewBox="0 0 24 24"
85 stroke="currentColor"
86 >
87 <path
88 strokeLinecap="round"
89 strokeLinejoin="round"
90 strokeWidth={2}
91 d="M5 13l4 4L19 7"
92 />
93 </svg>
94 {feature}
95 </li>
96 ))}
97 </ul>
98
99 <button
100 onClick={handleSubscribe}
101 disabled={loading || current || plan === 'FREE'}
102 className={`
103 w-full py-3 px-4 rounded-lg font-semibold
104 ${current
105 ? 'bg-gray-200 text-gray-600 cursor-not-allowed'
106 : popular
107 ? 'bg-blue-600 text-white hover:bg-blue-700'
108 : 'bg-gray-900 text-white hover:bg-gray-800'
109 }
110 disabled:opacity-50
111 `}
112 >
113 {loading
114 ? 'Loading...'
115 : current
116 ? 'Current Plan'
117 : plan === 'FREE'
118 ? 'Free Forever'
119 : 'Get Started'}
120 </button>
121 </div>
122 );
123}Step 10: Add Billing Settings#
1// app/(dashboard)/settings/billing/page.tsx
2import { auth } from '@clerk/nextjs';
3import { redirect } from 'next/navigation';
4import { prisma } from '@/lib/prisma';
5import { ManageSubscription } from '@/components/billing/ManageSubscription';
6
7export default async function BillingPage() {
8 const { userId } = auth();
9
10 if (!userId) {
11 redirect('/sign-in');
12 }
13
14 const user = await prisma.user.findUnique({
15 where: { clerkId: userId },
16 select: {
17 stripePriceId: true,
18 stripeCurrentPeriodEnd: true,
19 },
20 });
21
22 return (
23 <div className="max-w-2xl">
24 <h1 className="text-2xl font-bold mb-6">Billing</h1>
25 <ManageSubscription
26 priceId={user?.stripePriceId}
27 periodEnd={user?.stripeCurrentPeriodEnd}
28 />
29 </div>
30 );
31}Manage Subscription Component#
1// components/billing/ManageSubscription.tsx
2'use client';
3
4import { useState } from 'react';
5
6interface ManageSubscriptionProps {
7 priceId: string | null | undefined;
8 periodEnd: Date | null | undefined;
9}
10
11export function ManageSubscription({
12 priceId,
13 periodEnd,
14}: ManageSubscriptionProps) {
15 const [loading, setLoading] = useState(false);
16
17 const handleManage = async () => {
18 setLoading(true);
19
20 try {
21 const response = await fetch('/api/stripe/portal', {
22 method: 'POST',
23 });
24
25 const { url } = await response.json();
26 window.location.href = url;
27 } catch (error) {
28 console.error('Portal error:', error);
29 } finally {
30 setLoading(false);
31 }
32 };
33
34 if (!priceId) {
35 return (
36 <div className="bg-gray-50 rounded-lg p-6">
37 <h3 className="font-semibold mb-2">Free Plan</h3>
38 <p className="text-gray-600 mb-4">
39 You're on the free plan.
40 </p>
41 <a
42 href="/pricing"
43 className="inline-block bg-blue-600 text-white px-4 py-2 rounded-lg"
44 >
45 Upgrade
46 </a>
47 </div>
48 );
49 }
50
51 return (
52 <div className="bg-gray-50 rounded-lg p-6">
53 <h3 className="font-semibold mb-2">Pro Plan</h3>
54 {periodEnd && (
55 <p className="text-gray-600 mb-4">
56 Next billing date: {new Date(periodEnd).toLocaleDateString()}
57 </p>
58 )}
59 <button
60 onClick={handleManage}
61 disabled={loading}
62 className="bg-gray-900 text-white px-4 py-2 rounded-lg hover:bg-gray-800"
63 >
64 {loading ? 'Loading...' : 'Manage Subscription'}
65 </button>
66 </div>
67 );
68}Step 11: Test the Integration#
Set Up Stripe CLI#
1# Install Stripe CLI
2brew install stripe/stripe-cli/stripe
3
4# Login
5stripe login
6
7# Forward webhooks to local
8stripe listen --forward-to localhost:3000/api/webhooks/stripeCopy the webhook signing secret to .env.local.
Test Checkout Flow#
- Go to
/pricing - Select a plan
- Use test card:
4242 4242 4242 4242 - Complete checkout
- Verify redirect to dashboard
Test Customer Portal#
- Go to
/settings/billing - Click "Manage Subscription"
- Verify portal opens
Verification Checklist#
- Pricing page displays correctly
- Checkout creates subscription
- Webhook updates database
- Customer portal works
- Subscription status shows correctly
Security Review#
bootspring agent invoke security-expert "Review the Stripe payment integration"What You Learned#
- Setting up Stripe products
- Creating checkout sessions
- Handling webhooks
- Customer portal integration
- Subscription management