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#

  1. Go to stripe.com and sign up
  2. Enable test mode (toggle in dashboard)
  3. 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-portal

Step 3: Install Dependencies#

npm install stripe @stripe/stripe-js

Step 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 push

Step 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/stripe

Copy the webhook signing secret to .env.local.

Test Checkout Flow#

  1. Go to /pricing
  2. Select a plan
  3. Use test card: 4242 4242 4242 4242
  4. Complete checkout
  5. Verify redirect to dashboard

Test Customer Portal#

  1. Go to /settings/billing
  2. Click "Manage Subscription"
  3. 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

Next Steps#

Resources#