Courses Curriculum Cities Blog Enroll Now
Build a SaaS with AI · Day 3 of 5 ~40 minutes

Day 3: Stripe Integration: Subscriptions and Webhooks

Add real payment processing — a pricing page, Stripe Checkout for subscriptions, and webhooks that automatically upgrade user accounts when payment succeeds.

1
Day 1
2
Day 2
3
Day 3
4
Day 4
5
Day 5
What You'll Build

A complete Stripe integration: a pricing page with a Subscribe button, a Stripe Checkout session that collects payment, and a webhook handler that upgrades the user's plan in the database when Stripe confirms the payment.

1
Section 1 · 10 min

Create a Stripe Checkout Session

When a user clicks Subscribe, your API creates a Stripe Checkout Session and redirects them to Stripe's hosted payment page. Stripe handles everything: card collection, 3D Secure, receipts.

typescriptsrc/app/api/stripe/checkout/route.ts
import Stripe from 'stripe'
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

// Your Stripe price ID from dashboard (price_xxx)
const PRO_PRICE_ID = 'price_xxxxx'

export async function POST() {
  const session = await auth()
  if (!session?.user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  // Get or create Stripe customer
  let stripeCustomerId = (session.user as any).stripeCustomerId

  if (!stripeCustomerId) {
    const customer = await stripe.customers.create({
      email: session.user.email!,
      metadata: { userId: session.user.id! }
    })
    stripeCustomerId = customer.id
    await prisma.user.update({
      where: { id: session.user.id },
      data: { stripeCustomerId }
    })
  }

  // Create checkout session
  const checkoutSession = await stripe.checkout.sessions.create({
    customer: stripeCustomerId,
    mode: 'subscription',
    line_items: [{ price: PRO_PRICE_ID, quantity: 1 }],
    success_url: `${process.env.NEXTAUTH_URL}/dashboard?upgraded=true`,
    cancel_url: `${process.env.NEXTAUTH_URL}/pricing`,
  })

  return NextResponse.json({ url: checkoutSession.url })
}
2
Section 2 · 12 min

Stripe Webhook Handler

Webhooks are Stripe's way of telling you what happened. When a payment succeeds, Stripe sends a POST to your webhook endpoint. You verify it's really from Stripe and update the database.

typescriptsrc/app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { prisma } from '@/lib/prisma'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: Request) {
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')!

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
  } catch (err) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const checkoutSession = event.data.object as Stripe.Checkout.Session
      await prisma.user.update({
        where: { stripeCustomerId: checkoutSession.customer as string },
        data: { plan: 'PRO', creditsLimit: 999999 }
      })
      break
    }
    case 'customer.subscription.deleted': {
      const sub = event.data.object as Stripe.Subscription
      await prisma.user.update({
        where: { stripeCustomerId: sub.customer as string },
        data: { plan: 'FREE', creditsLimit: 10 }
      })
      break
    }
  }

  return NextResponse.json({ received: true })
}
3
Section 3 · 9 min

Test Webhooks Locally with Stripe CLI

Stripe can't reach localhost. The Stripe CLI creates a tunnel that forwards webhook events from Stripe to your local server.

bashterminal
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
stripe login

# Forward webhook events to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Your webhook signing secret is: whsec_...
# Copy this to STRIPE_WEBHOOK_SECRET in .env.local

# Trigger a test event in another terminal
stripe trigger checkout.session.completed
# Watch the first terminal — you'll see the event arrive
4
Section 4 · 9 min

Pricing Page and Subscribe Button

The pricing page shows your plans. The Subscribe button calls your checkout API and redirects to Stripe. Keep it simple — one CTA, clear value proposition.

typescriptsrc/app/(marketing)/pricing/page.tsx
'use client'

export default function Pricing() {
  const subscribe = async () => {
    const res = await fetch('/api/stripe/checkout', { method: 'POST' })
    const { url } = await res.json()
    window.location.href = url  // redirect to Stripe Checkout
  }

  return (
    <div className="pricing">
      <div className="plan">
        <h2>Free</h2>
        <p>$0/mo</p><p>10 generations/month</p>
      </div>
      <div className="plan plan-pro">
        <h2>Pro</h2>
        <p>$29/mo</p><p>Unlimited generations</p>
        <button onClick={subscribe}>Subscribe</button>
      </div>
    </div>
  )
}

What You Learned Today

  • Created a Stripe Checkout Session that redirects users to Stripe's hosted payment page
  • Built a webhook handler that verifies the Stripe signature and upgrades user plan on checkout.session.completed
  • Tested webhooks locally using the Stripe CLI's listen and trigger commands
  • Built a pricing page with a Subscribe button that calls the checkout API
Your Challenge

Go Further on Your Own

  • Add a Stripe Customer Portal button: stripe.billingPortal.sessions.create() lets users manage their subscription without you building a UI
  • Handle the invoice.payment_failed webhook by sending a notification email and potentially downgrading the account
  • Create a /dashboard/billing page that shows the current plan, next billing date, and a cancel button
Day 3 Complete

Nice work. Keep going.

Day 4 is ready when you are.

Continue to Day 4
Course Progress
60%

Want live instruction and hands-on projects? Join the AI bootcamp — 3 days, 5 cities.

Finished this lesson?