D
Dev SOPKnowledge Base
Search
← All topics

Stripe Integration: Subscriptions, Webhooks, and Customer Portal

Stripe integration patterns for SaaS — checkout sessions, subscription management, webhook signature verification, customer portal, metered billing, and sandbox-to-production migration checklist.

stripepaymentssubscriptionswebhookssaastypescript
Agent trigger phrases: Stripe · Stripe checkout · Stripe webhook · Stripe subscription · Stripe customer portal · Stripe integration · Stripe SaaS · stripe.js

Overview

Stripe patterns for SaaS subscriptions. Key principle: webhook-first architecture — never trust the client to confirm payment. Always verify via webhook events.

Setup

npm install stripe @stripe/stripe-js

# CLI for local webhook testing
brew install stripe/stripe-cli/stripe
stripe login

Checkout Session (Server-Side)

// src/app/api/stripe/create-checkout/route.ts
import { stripe } from "@/lib/stripe";
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const { priceId, userId } = await request.json();

  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?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
    metadata: { userId },  // passed to webhook events
    allow_promotion_codes: true,
    billing_address_collection: "required",
  });

  return NextResponse.json({ url: session.url });
}

Stripe Client Singleton

// src/lib/stripe.ts
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-06-20",
});

// Test card: 4242 4242 4242 4242 | any future date | any CVC

Webhook Handler

// src/app/api/stripe/webhook/route.ts
import { stripe } from "@/lib/stripe";
import { NextRequest, NextResponse } from "next/server";

export const config = { api: { bodyParser: false } };

const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;

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

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, WEBHOOK_SECRET);
  } catch (err) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      await handleCheckoutComplete(session);
      break;
    }
    case "customer.subscription.created":
    case "customer.subscription.updated": {
      const subscription = event.data.object as Stripe.Subscription;
      await syncSubscription(subscription);
      break;
    }
    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      await cancelSubscription(subscription.metadata.userId);
      break;
    }
    case "invoice.payment_failed": {
      const invoice = event.data.object as Stripe.Invoice;
      await handlePaymentFailure(invoice);
      break;
    }
  }

  return NextResponse.json({ received: true });
}

async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
  const userId = session.metadata?.userId;
  if (!userId) return;

  await db.from("subscriptions").upsert({
    user_id: userId,
    stripe_customer_id: session.customer as string,
    stripe_subscription_id: session.subscription as string,
    status: "active",
  });
}

Customer Portal

// src/app/api/stripe/portal/route.ts
export async function POST(request: NextRequest) {
  const { userId } = await request.json();

  const subscription = await db
    .from("subscriptions")
    .select("stripe_customer_id")
    .eq("user_id", userId)
    .single();

  const session = await stripe.billingPortal.sessions.create({
    customer: subscription.stripe_customer_id,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`,
  });

  return NextResponse.json({ url: session.url });
}

Local Webhook Testing

# Forward events to local server
stripe listen --forward-to localhost:3000/api/stripe/webhook

# Trigger specific events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed

Price and Product Setup

// Create via API (or use dashboard)
const product = await stripe.products.create({
  name: "Pro Plan",
  description: "Unlimited projects and API access",
});

const price = await stripe.prices.create({
  product: product.id,
  unit_amount: 2900,  // $29.00 in cents
  currency: "usd",
  recurring: { interval: "month" },
});

console.log(price.id);  // store this as STRIPE_PRO_PRICE_ID

Subscription Status Sync

async function syncSubscription(subscription: Stripe.Subscription) {
  const userId = subscription.metadata.userId;

  await db.from("subscriptions").upsert({
    user_id: userId,
    stripe_subscription_id: subscription.id,
    status: subscription.status,
    current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
    cancel_at_period_end: subscription.cancel_at_period_end,
    price_id: subscription.items.data[0]?.price.id,
  });
}

Subscription Guard (Middleware)

export async function requireActiveSubscription(userId: string) {
  const sub = await db
    .from("subscriptions")
    .select("status")
    .eq("user_id", userId)
    .single();

  if (!sub || !["active", "trialing"].includes(sub.status)) {
    throw new Error("Subscription required");
  }
}

Sandbox to Production Checklist

[ ] Go to Stripe Dashboard → toggle to Live mode
[ ] Activate account (business details, bank account)
[ ] Recreate all products and prices in live mode
[ ] Update price IDs in app (price_live_... not price_test_...)
[ ] Create production webhook at https://yourdomain.com/api/stripe/webhook
[ ] Add all required events to the webhook
[ ] Copy webhook signing secret → update STRIPE_WEBHOOK_SECRET in Vercel
[ ] Update STRIPE_SECRET_KEY to sk_live_... in Vercel
[ ] Update NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY to pk_live_... in Vercel
[ ] Test with a real card (small amount → refund)
[ ] Verify webhook events arriving in Stripe dashboard

Common Mistakes

| Mistake | Fix | |---------|-----| | Trusting client to confirm payment | Always use webhooks | | No webhook signature verification | Always call constructEvent() | | Using test price IDs in production | Recreate prices in live mode | | Missing checkout.session.completed handler | This is the primary entry point | | Storing card data | Never — Stripe handles this | | Not syncing subscription updates | Handle customer.subscription.updated |