{"slug":"stripe-integration-patterns","title":"Stripe Integration: Subscriptions, Webhooks, and Customer Portal","tags":["stripe","payments","subscriptions","webhooks","saas","typescript"],"agent_summary":"Stripe integration patterns for SaaS — checkout sessions, subscription management, webhook signature verification, customer portal, metered billing, and sandbox-to-production migration checklist.","trigger_phrases":["Stripe","Stripe checkout","Stripe webhook","Stripe subscription","Stripe customer portal","Stripe integration","Stripe SaaS","stripe.js"],"runnable":false,"markdown":"\n## Overview\n\nStripe patterns for SaaS subscriptions. Key principle: webhook-first architecture — never trust the client to confirm payment. Always verify via webhook events.\n\n## Setup\n\n```bash\nnpm install stripe @stripe/stripe-js\n\n# CLI for local webhook testing\nbrew install stripe/stripe-cli/stripe\nstripe login\n```\n\n## Checkout Session (Server-Side)\n\n```typescript\n// src/app/api/stripe/create-checkout/route.ts\nimport { stripe } from \"@/lib/stripe\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\nexport async function POST(request: NextRequest) {\n  const { priceId, userId } = await request.json();\n\n  const session = await stripe.checkout.sessions.create({\n    mode: \"subscription\",\n    payment_method_types: [\"card\"],\n    line_items: [{ price: priceId, quantity: 1 }],\n    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,\n    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,\n    metadata: { userId },  // passed to webhook events\n    allow_promotion_codes: true,\n    billing_address_collection: \"required\",\n  });\n\n  return NextResponse.json({ url: session.url });\n}\n```\n\n## Stripe Client Singleton\n\n```typescript\n// src/lib/stripe.ts\nimport Stripe from \"stripe\";\n\nexport const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {\n  apiVersion: \"2024-06-20\",\n});\n\n// Test card: 4242 4242 4242 4242 | any future date | any CVC\n```\n\n## Webhook Handler\n\n```typescript\n// src/app/api/stripe/webhook/route.ts\nimport { stripe } from \"@/lib/stripe\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\nexport const config = { api: { bodyParser: false } };\n\nconst WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;\n\nexport async function POST(request: NextRequest) {\n  const body = await request.text();\n  const signature = request.headers.get(\"stripe-signature\")!;\n\n  let event: Stripe.Event;\n  try {\n    event = stripe.webhooks.constructEvent(body, signature, WEBHOOK_SECRET);\n  } catch (err) {\n    return NextResponse.json({ error: \"Invalid signature\" }, { status: 400 });\n  }\n\n  switch (event.type) {\n    case \"checkout.session.completed\": {\n      const session = event.data.object as Stripe.Checkout.Session;\n      await handleCheckoutComplete(session);\n      break;\n    }\n    case \"customer.subscription.created\":\n    case \"customer.subscription.updated\": {\n      const subscription = event.data.object as Stripe.Subscription;\n      await syncSubscription(subscription);\n      break;\n    }\n    case \"customer.subscription.deleted\": {\n      const subscription = event.data.object as Stripe.Subscription;\n      await cancelSubscription(subscription.metadata.userId);\n      break;\n    }\n    case \"invoice.payment_failed\": {\n      const invoice = event.data.object as Stripe.Invoice;\n      await handlePaymentFailure(invoice);\n      break;\n    }\n  }\n\n  return NextResponse.json({ received: true });\n}\n\nasync function handleCheckoutComplete(session: Stripe.Checkout.Session) {\n  const userId = session.metadata?.userId;\n  if (!userId) return;\n\n  await db.from(\"subscriptions\").upsert({\n    user_id: userId,\n    stripe_customer_id: session.customer as string,\n    stripe_subscription_id: session.subscription as string,\n    status: \"active\",\n  });\n}\n```\n\n## Customer Portal\n\n```typescript\n// src/app/api/stripe/portal/route.ts\nexport async function POST(request: NextRequest) {\n  const { userId } = await request.json();\n\n  const subscription = await db\n    .from(\"subscriptions\")\n    .select(\"stripe_customer_id\")\n    .eq(\"user_id\", userId)\n    .single();\n\n  const session = await stripe.billingPortal.sessions.create({\n    customer: subscription.stripe_customer_id,\n    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`,\n  });\n\n  return NextResponse.json({ url: session.url });\n}\n```\n\n## Local Webhook Testing\n\n```bash\n# Forward events to local server\nstripe listen --forward-to localhost:3000/api/stripe/webhook\n\n# Trigger specific events\nstripe trigger checkout.session.completed\nstripe trigger customer.subscription.updated\nstripe trigger invoice.payment_failed\n```\n\n## Price and Product Setup\n\n```typescript\n// Create via API (or use dashboard)\nconst product = await stripe.products.create({\n  name: \"Pro Plan\",\n  description: \"Unlimited projects and API access\",\n});\n\nconst price = await stripe.prices.create({\n  product: product.id,\n  unit_amount: 2900,  // $29.00 in cents\n  currency: \"usd\",\n  recurring: { interval: \"month\" },\n});\n\nconsole.log(price.id);  // store this as STRIPE_PRO_PRICE_ID\n```\n\n## Subscription Status Sync\n\n```typescript\nasync function syncSubscription(subscription: Stripe.Subscription) {\n  const userId = subscription.metadata.userId;\n\n  await db.from(\"subscriptions\").upsert({\n    user_id: userId,\n    stripe_subscription_id: subscription.id,\n    status: subscription.status,\n    current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),\n    cancel_at_period_end: subscription.cancel_at_period_end,\n    price_id: subscription.items.data[0]?.price.id,\n  });\n}\n```\n\n## Subscription Guard (Middleware)\n\n```typescript\nexport async function requireActiveSubscription(userId: string) {\n  const sub = await db\n    .from(\"subscriptions\")\n    .select(\"status\")\n    .eq(\"user_id\", userId)\n    .single();\n\n  if (!sub || ![\"active\", \"trialing\"].includes(sub.status)) {\n    throw new Error(\"Subscription required\");\n  }\n}\n```\n\n## Sandbox to Production Checklist\n\n```\n[ ] Go to Stripe Dashboard → toggle to Live mode\n[ ] Activate account (business details, bank account)\n[ ] Recreate all products and prices in live mode\n[ ] Update price IDs in app (price_live_... not price_test_...)\n[ ] Create production webhook at https://yourdomain.com/api/stripe/webhook\n[ ] Add all required events to the webhook\n[ ] Copy webhook signing secret → update STRIPE_WEBHOOK_SECRET in Vercel\n[ ] Update STRIPE_SECRET_KEY to sk_live_... in Vercel\n[ ] Update NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY to pk_live_... in Vercel\n[ ] Test with a real card (small amount → refund)\n[ ] Verify webhook events arriving in Stripe dashboard\n```\n\n## Common Mistakes\n\n| Mistake | Fix |\n|---------|-----|\n| Trusting client to confirm payment | Always use webhooks |\n| No webhook signature verification | Always call `constructEvent()` |\n| Using test price IDs in production | Recreate prices in live mode |\n| Missing `checkout.session.completed` handler | This is the primary entry point |\n| Storing card data | Never — Stripe handles this |\n| Not syncing subscription updates | Handle `customer.subscription.updated` |\n","html":"<h2>Overview</h2>\n<p>Stripe patterns for SaaS subscriptions. Key principle: webhook-first architecture — never trust the client to confirm payment. Always verify via webhook events.</p>\n<h2>Setup</h2>\n<pre><code class=\"language-bash\">npm install stripe @stripe/stripe-js\n\n# CLI for local webhook testing\nbrew install stripe/stripe-cli/stripe\nstripe login\n</code></pre>\n<h2>Checkout Session (Server-Side)</h2>\n<pre><code class=\"language-typescript\">// src/app/api/stripe/create-checkout/route.ts\nimport { stripe } from \"@/lib/stripe\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\nexport async function POST(request: NextRequest) {\n  const { priceId, userId } = await request.json();\n\n  const session = await stripe.checkout.sessions.create({\n    mode: \"subscription\",\n    payment_method_types: [\"card\"],\n    line_items: [{ price: priceId, quantity: 1 }],\n    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,\n    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,\n    metadata: { userId },  // passed to webhook events\n    allow_promotion_codes: true,\n    billing_address_collection: \"required\",\n  });\n\n  return NextResponse.json({ url: session.url });\n}\n</code></pre>\n<h2>Stripe Client Singleton</h2>\n<pre><code class=\"language-typescript\">// src/lib/stripe.ts\nimport Stripe from \"stripe\";\n\nexport const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {\n  apiVersion: \"2024-06-20\",\n});\n\n// Test card: 4242 4242 4242 4242 | any future date | any CVC\n</code></pre>\n<h2>Webhook Handler</h2>\n<pre><code class=\"language-typescript\">// src/app/api/stripe/webhook/route.ts\nimport { stripe } from \"@/lib/stripe\";\nimport { NextRequest, NextResponse } from \"next/server\";\n\nexport const config = { api: { bodyParser: false } };\n\nconst WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;\n\nexport async function POST(request: NextRequest) {\n  const body = await request.text();\n  const signature = request.headers.get(\"stripe-signature\")!;\n\n  let event: Stripe.Event;\n  try {\n    event = stripe.webhooks.constructEvent(body, signature, WEBHOOK_SECRET);\n  } catch (err) {\n    return NextResponse.json({ error: \"Invalid signature\" }, { status: 400 });\n  }\n\n  switch (event.type) {\n    case \"checkout.session.completed\": {\n      const session = event.data.object as Stripe.Checkout.Session;\n      await handleCheckoutComplete(session);\n      break;\n    }\n    case \"customer.subscription.created\":\n    case \"customer.subscription.updated\": {\n      const subscription = event.data.object as Stripe.Subscription;\n      await syncSubscription(subscription);\n      break;\n    }\n    case \"customer.subscription.deleted\": {\n      const subscription = event.data.object as Stripe.Subscription;\n      await cancelSubscription(subscription.metadata.userId);\n      break;\n    }\n    case \"invoice.payment_failed\": {\n      const invoice = event.data.object as Stripe.Invoice;\n      await handlePaymentFailure(invoice);\n      break;\n    }\n  }\n\n  return NextResponse.json({ received: true });\n}\n\nasync function handleCheckoutComplete(session: Stripe.Checkout.Session) {\n  const userId = session.metadata?.userId;\n  if (!userId) return;\n\n  await db.from(\"subscriptions\").upsert({\n    user_id: userId,\n    stripe_customer_id: session.customer as string,\n    stripe_subscription_id: session.subscription as string,\n    status: \"active\",\n  });\n}\n</code></pre>\n<h2>Customer Portal</h2>\n<pre><code class=\"language-typescript\">// src/app/api/stripe/portal/route.ts\nexport async function POST(request: NextRequest) {\n  const { userId } = await request.json();\n\n  const subscription = await db\n    .from(\"subscriptions\")\n    .select(\"stripe_customer_id\")\n    .eq(\"user_id\", userId)\n    .single();\n\n  const session = await stripe.billingPortal.sessions.create({\n    customer: subscription.stripe_customer_id,\n    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`,\n  });\n\n  return NextResponse.json({ url: session.url });\n}\n</code></pre>\n<h2>Local Webhook Testing</h2>\n<pre><code class=\"language-bash\"># Forward events to local server\nstripe listen --forward-to localhost:3000/api/stripe/webhook\n\n# Trigger specific events\nstripe trigger checkout.session.completed\nstripe trigger customer.subscription.updated\nstripe trigger invoice.payment_failed\n</code></pre>\n<h2>Price and Product Setup</h2>\n<pre><code class=\"language-typescript\">// Create via API (or use dashboard)\nconst product = await stripe.products.create({\n  name: \"Pro Plan\",\n  description: \"Unlimited projects and API access\",\n});\n\nconst price = await stripe.prices.create({\n  product: product.id,\n  unit_amount: 2900,  // $29.00 in cents\n  currency: \"usd\",\n  recurring: { interval: \"month\" },\n});\n\nconsole.log(price.id);  // store this as STRIPE_PRO_PRICE_ID\n</code></pre>\n<h2>Subscription Status Sync</h2>\n<pre><code class=\"language-typescript\">async function syncSubscription(subscription: Stripe.Subscription) {\n  const userId = subscription.metadata.userId;\n\n  await db.from(\"subscriptions\").upsert({\n    user_id: userId,\n    stripe_subscription_id: subscription.id,\n    status: subscription.status,\n    current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),\n    cancel_at_period_end: subscription.cancel_at_period_end,\n    price_id: subscription.items.data[0]?.price.id,\n  });\n}\n</code></pre>\n<h2>Subscription Guard (Middleware)</h2>\n<pre><code class=\"language-typescript\">export async function requireActiveSubscription(userId: string) {\n  const sub = await db\n    .from(\"subscriptions\")\n    .select(\"status\")\n    .eq(\"user_id\", userId)\n    .single();\n\n  if (!sub || ![\"active\", \"trialing\"].includes(sub.status)) {\n    throw new Error(\"Subscription required\");\n  }\n}\n</code></pre>\n<h2>Sandbox to Production Checklist</h2>\n<pre><code>[ ] Go to Stripe Dashboard → toggle to Live mode\n[ ] Activate account (business details, bank account)\n[ ] Recreate all products and prices in live mode\n[ ] Update price IDs in app (price_live_... not price_test_...)\n[ ] Create production webhook at https://yourdomain.com/api/stripe/webhook\n[ ] Add all required events to the webhook\n[ ] Copy webhook signing secret → update STRIPE_WEBHOOK_SECRET in Vercel\n[ ] Update STRIPE_SECRET_KEY to sk_live_... in Vercel\n[ ] Update NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY to pk_live_... in Vercel\n[ ] Test with a real card (small amount → refund)\n[ ] Verify webhook events arriving in Stripe dashboard\n</code></pre>\n<h2>Common Mistakes</h2>\n<p>| Mistake | Fix |\n|---------|-----|\n| Trusting client to confirm payment | Always use webhooks |\n| No webhook signature verification | Always call <code>constructEvent()</code> |\n| Using test price IDs in production | Recreate prices in live mode |\n| Missing <code>checkout.session.completed</code> handler | This is the primary entry point |\n| Storing card data | Never — Stripe handles this |\n| Not syncing subscription updates | Handle <code>customer.subscription.updated</code> |</p>\n"}