Full Stack29

Stripe Integration Complete Guide: Subscriptions, Webhooks & Usage Billing (2026)

The complete Stripe integration guide for SaaS in 2026. Subscriptions, Stripe Checkout, Customer Portal, webhooks, usage-based billing, free trials, failed payment recovery — with ...

Stripe Integration Complete Guide: Subscriptions, Webhooks & Usage Billing (2026)

The Complete Stripe Billing System

Stripe is the gold standard for SaaS billing — but the documentation is vast and the "right" way to integrate it for a modern Next.js SaaS isn't always obvious. This guide cuts through everything and gives you the exact production pattern used by thousands of successful SaaS products.

By the end of this guide, your SaaS will handle: subscription checkout with multiple plans, Stripe-hosted Customer Portal for plan changes and cancellations, webhooks that keep your database in sync with Stripe's state, usage-based billing for metered features, free trials with card capture, and dunning that recovers failed payments automatically.

There are two ways to build Stripe billing: (1) Custom Stripe.js UI — you build the payment form, handle card elements, manage state. (2) Stripe Checkout + Customer Portal — Stripe hosts the payment page and billing management. This guide uses Option 2. It's faster to implement, Stripe handles PCI compliance, and conversion rates are typically higher on Stripe's optimized checkout pages. Option 1 is only worth building if your design requirements are extremely specific.

Setup: Stripe Dashboard, Products, and Prices

Before writing a line of code, configure your Stripe products and prices in the Stripe Dashboard. This step is critical — your pricing model in Stripe determines everything downstream : how checkout works, how webhooks fire, and how plan changes are handled.

1. Create Your Products and Prices in Stripe Dashboard

In Stripe Dashboard → Products, create one product per plan tier. For each product, create a price:

  • Free plan: No Stripe product needed — just skip billing for free users
  • Pro plan: Create a product "Pro", add a recurring monthly price at $29/month
  • Enterprise plan: Create a product "Enterprise", add a recurring monthly price at $99/month

After creating prices, copy their Price IDs (format: price_xxxx ). These go in your environment variables.

2. Environment Variables

# Stripe keys — get from Stripe Dashboard > Developers > API keys 
STRIPE_SECRET_KEY=sk_live_...            # Never expose to client 
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...   # Safe for client 

 # Webhook secret — get from Stripe Dashboard > Webhooks after creating endpoint 
STRIPE_WEBHOOK_SECRET=whsec_...

 # Your Price IDs from Stripe Dashboard > Products 
STRIPE_PRICE_PRO_MONTHLY=price_...
STRIPE_PRICE_PRO_ANNUAL=price_...
STRIPE_PRICE_ENTERPRISE_MONTHLY=price_...

 # App URL — for redirect URLs in Checkout 
NEXT_PUBLIC_APP_URL=https://yourdomain.com

3. Install Stripe SDK

npm install stripe @stripe/stripe-js

4. Initialize Stripe (Server + Client)

import  Stripe  from   'stripe' 

 // Server-side Stripe instance (never expose secret key to client) 
 export const  stripe =  new   Stripe (process.env.STRIPE_SECRET_KEY!, {
  apiVersion:  '2026-03-31.basil' ,   // Always pin the API version 
  typescript:  true ,
})

 // Pricing config — single source of truth 
 export const  PLANS = {
  free: {
    name:  'Free' ,
    priceMonthly:  0 ,
    priceId:  null ,
    features: [ 'Up to 3 projects' ,  '5GB storage' ,  'Email support' ],
    limits: { projects:  3 , storage:  5  }
  },
  pro: {
    name:  'Pro' ,
    priceMonthly:  29 ,
    priceId: process.env.STRIPE_PRICE_PRO_MONTHLY,
    features: [ 'Unlimited projects' ,  '50GB storage' ,  'Priority support' ,  'API access' ],
    limits: { projects:  Infinity , storage:  50  }
  },
  enterprise: {
    name:  'Enterprise' ,
    priceMonthly:  99 ,
    priceId: process.env.STRIPE_PRICE_ENTERPRISE_MONTHLY,
    features: [ 'Everything in Pro' ,  '500GB storage' ,  'SAML SSO' ,  'Dedicated support' ],
    limits: { projects:  Infinity , storage:  500  }
  }
}  as const 

 export type   Plan  =  keyof typeof  PLANS

Database Schema: What to Store About Each Subscriber

Your database is the source of truth for access control. Stripe is the source of truth for billing. Your job is to keep them in sync via webhooks. Here's the exact schema you need.

-- Extend your existing profiles/users table 
 alter table  profiles
   add column  stripe_customer_id text  unique ,
   add column  plan text  not null default   'free' ,
   add column  plan_status text  not null default   'active' ;
   -- plan_status: 'active' | 'trialing' | 'past_due' | 'canceled' 

 -- Subscriptions table — tracks the active Stripe subscription 
 create table   subscriptions  (
  id uuid  default  gen_random_uuid()  primary key ,
  user_id uuid  references  profiles(id)  on delete cascade unique ,
  stripe_subscription_id text  unique not null ,
  stripe_customer_id text  not null ,
  stripe_price_id text  not null ,
  plan text  not null ,                    -- 'pro' | 'enterprise' 
  status text  not null ,                   -- Stripe subscription status 
  current_period_start timestamptz,
  current_period_end timestamptz,
  cancel_at_period_end boolean  default false ,
  trial_end timestamptz,
  created_at timestamptz  default  now(),
  updated_at timestamptz  default  now()
);

 -- Usage records for metered billing 
 create table   usage_records  (
  id uuid  default  gen_random_uuid()  primary key ,
  user_id uuid  references  profiles(id)  on delete cascade ,
  metric text  not null ,          -- 'api_calls' | 'storage_gb' etc. 
  quantity bigint  not null ,
  recorded_at timestamptz  default  now()
);

 -- Index for fast subscription lookups by Stripe IDs 
 create index   subs_stripe_id_idx   on  subscriptions (stripe_subscription_id);
 create index   subs_customer_id_idx   on  subscriptions (stripe_customer_id);

Never use Stripe's subscription status as your access control directly. Your database is what your app reads on every request. Stripe fires webhooks to keep your database updated. If a webhook fails and Stripe says "active" but your DB says "past_due" — your app uses your DB. This means your webhook handling must be reliable and idempotent.

Stripe Checkout: The Subscription Flow

When a user clicks "Upgrade to Pro", you create a Stripe Checkout Session on the server and redirect them to Stripe's hosted checkout page. After payment, Stripe redirects back to your success URL and fires a webhook.

'use server' 

 import  { stripe }  from   '@/lib/stripe' 
 import  {  createClient  }  from   '@/lib/supabase/server' 
 import  {  redirect  }  from   'next/navigation' 

 export async function   createCheckoutSession (priceId:  string ) {
   const  supabase =  createClient ()
   const  { data: { user } } =  await  supabase.auth. getUser ()
   if  (!user)  throw new   Error ( 'Unauthorized' )

   // Get or create Stripe customer 
   const  { data: profile } =  await  supabase
    .from( 'profiles' )
    .select( 'stripe_customer_id' )
    .eq( 'id' , user.id)
    .single()

   let  customerId = profile?.stripe_customer_id

   if  (!customerId) {
     const  customer =  await  stripe.customers. create ({
      email: user.email!,
      metadata: { supabase_user_id: user.id },
    })
    customerId = customer.id

     await  supabase
      .from( 'profiles' )
      .update({ stripe_customer_id: customerId })
      .eq( 'id' , user.id)
  }

   // Create the Checkout Session 
   const  session =  await  stripe.checkout.sessions. create ({
    customer: customerId,
    mode:  'subscription' ,
    line_items: [{ price: priceId, quantity:  1  }],

     // Trial period (optional) — 14 days free 
    subscription_data: {
      trial_period_days:  14 ,
      metadata: { user_id: user.id },
    },

     // Where to redirect after payment 
    success_url:  `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=true` ,
    cancel_url:  `${process.env.NEXT_PUBLIC_APP_URL}/pricing` ,

     // Collect billing address (required for tax) 
    billing_address_collection:  'auto' ,

     // Enable tax collection 
    automatic_tax: { enabled:  true  },

     // Allow promotion codes 
    allow_promotion_codes:  true ,
  })

   if  (!session.url)  throw new   Error ( 'Failed to create checkout session' )
   redirect (session.url)
}

The Pricing Page UI

  • ✓ 3 projects
  • ✓ 5GB storage
  • ✓ Email support
  • ✗ API access
  • ✗ Custom domains
  • ✓ Unlimited projects
  • ✓ 50GB storage
  • ✓ Priority support
  • ✓ API access
  • ✗ Custom domains
  • ✓ Everything in Pro
  • ✓ 500GB storage
  • ✓ Dedicated support
  • ✓ SAML SSO
  • ✓ Custom domains
'use client' 

 import  {  createCheckoutSession  }  from   '@/app/actions/billing' 
 import  { PLANS }  from   '@/lib/stripe' 
 import  {  useState  }  from   'react' 

 export default function   PricingPage () {
   const  [loading,  setLoading ] =  useState < string  |  null >( null )

   async function   handleUpgrade (plan:  'pro'  |  'enterprise' ) {
     const  priceId = PLANS[plan].priceId
     if  (!priceId)  return 

     setLoading (plan)
     try  {
       await   createCheckoutSession (priceId)
       // redirect() in the server action handles navigation 
    }  catch  (err) {
      console. error ( 'Checkout failed:' , err)
       setLoading ( null )
    }
  }

   return  (
    <div className= "grid grid-cols-3 gap-6" >
      {[ 'pro' ,  'enterprise' ]. map ((plan) => (
        <button
          key={plan}
          onClick={() =>  handleUpgrade (plan  as   any )}
          disabled={loading === plan}
          className= "bg-primary text-white rounded-lg p-4" 
        >
          {loading === plan ?  'Loading...'  :  `Upgrade to ${PLANS[plan as keyof typeof PLANS].name}` }
        </button>
      ))}
    </div>
  )
}

Webhooks: The Heart of Stripe Integration

Webhooks are where most Stripe integrations break down. Stripe sends events to your endpoint for everything that happens — payment succeeded, subscription renewed, trial ending, payment failed. Your webhook handler is what keeps your database in sync with Stripe's reality.

Get this wrong and you'll have users who paid but don't have access, or users who cancelled but still do. Webhooks are non-negotiable.

Which Webhook Events You Must Handle

The Complete Webhook Handler

import  { headers }  from   'next/headers' 
 import  { stripe }  from   '@/lib/stripe' 
 import  {  createAdminClient  }  from   '@/lib/supabase/admin' 
 import  Stripe  from   'stripe' 

 // Required for raw body access (signature verification needs raw bytes) 
 export const  dynamic =  'force-dynamic' 

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

   if  (!sig) {
     return   Response . json ({ error:  'No signature'  }, { status:  400  })
  }

   let  event: Stripe. Event 
   try  {
    event = stripe.webhooks. constructEvent (
      body, sig, process.env.STRIPE_WEBHOOK_SECRET!
    )
  }  catch  (err) {
    console. error ( 'Webhook signature failed:' , err)
     return   Response . json ({ error:  'Invalid signature'  }, { status:  400  })
  }

   const  supabase =  createAdminClient ()   // Bypass RLS for webhook updates 

   try  {
     switch  (event.type) {
       case   'checkout.session.completed' : {
         const  session = event.data.object  as  Stripe. Checkout.Session 
         if  (session.mode !==  'subscription' )  break 

         const  subscription =  await  stripe.subscriptions. retrieve (
          session.subscription  as   string 
        )
         await   upsertSubscription (supabase, subscription)
         break 
      }

       case   'customer.subscription.updated' :
       case   'customer.subscription.deleted' : {
         const  subscription = event.data.object  as  Stripe. Subscription 
         await   upsertSubscription (supabase, subscription)
         break 
      }

       case   'invoice.payment_failed' : {
         const  invoice = event.data.object  as  Stripe. Invoice 
         if  (!invoice.subscription)  break 

         await  supabase
          .from( 'subscriptions' )
          .update({ status:  'past_due'  })
          .eq( 'stripe_subscription_id' , invoice.subscription)

         // Also update the profile plan_status for quick access control checks 
         const  { data: sub } =  await  supabase
          .from( 'subscriptions' )
          .select( 'user_id' )
          .eq( 'stripe_subscription_id' , invoice.subscription)
          .single()

         if  (sub) {
           await  supabase
            .from( 'profiles' )
            .update({ plan_status:  'past_due'  })
            .eq( 'id' , sub.user_id)
        }
         break 
      }

       case   'invoice.payment_succeeded' : {
         const  invoice = event.data.object  as  Stripe. Invoice 
         if  (!invoice.subscription)  break 

         // Payment recovered — restore active status 
         await  supabase
          .from( 'subscriptions' )
          .update({ status:  'active'  })
          .eq( 'stripe_subscription_id' , invoice.subscription)

         break 
      }

       default :
        console. log ( `Unhandled event: ${event.type}` )
    }

     return   Response . json ({ received:  true  })
  }  catch  (err) {
    console. error ( `Error handling ${event.type}:` , err)
     // Return 200 to prevent Stripe retrying a permanent error 
     return   Response . json ({ error:  'Internal error'  }, { status:  500  })
  }
}

 // Helper: upsert subscription in DB from Stripe object 
 async function   upsertSubscription (supabase:  any , sub: Stripe. Subscription ) {
   const  priceId = sub.items.data[ 0 ]?.price.id
   const  plan =  getPlanFromPriceId (priceId)
   const  userId = sub.metadata?.user_id

   if  (!userId)  throw new   Error ( 'No user_id in subscription metadata' )

   await  supabase.from( 'subscriptions' ). upsert ({
    user_id: userId,
    stripe_subscription_id: sub.id,
    stripe_customer_id: sub.customer  as   string ,
    stripe_price_id: priceId,
    plan,
    status: sub.status,
    current_period_start:  new   Date (sub.current_period_start *  1000 ). toISOString (),
    current_period_end:  new   Date (sub.current_period_end *  1000 ). toISOString (),
    cancel_at

Never process a webhook without verifying its signature using stripe.webhooks.constructEvent() . Without verification, anyone can POST to your webhook endpoint with fake events — upgrading accounts without paying, or triggering actions on arbitrary subscriptions. The signature check is the entire security model for webhooks. It is not optional.

Customer Portal: Let Stripe Handle Plan Changes

The Stripe Customer Portal is a hosted page where users can upgrade, downgrade, cancel, update their payment method, and view invoice history — without you building any of that UI. Enable it in Stripe Dashboard → Settings → Customer Portal, configure which plans can be switched to, and create a portal session when the user clicks "Manage Billing".

export async function   createPortalSession () {
   const  supabase =  createClient ()
   const  { data: { user } } =  await  supabase.auth. getUser ()
   if  (!user)  throw new   Error ( 'Unauthorized' )

   const  { data: profile } =  await  supabase
    .from( 'profiles' )
    .select( 'stripe_customer_id' )
    .eq( 'id' , user.id)
    .single()

   if  (!profile?.stripe_customer_id) {
     throw new   Error ( 'No billing account found. Please upgrade first.' )
  }

   const  portalSession =  await  stripe.billingPortal.sessions. create ({
    customer: profile.stripe_customer_id,
    return_url:  `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/settings` ,
  })

   redirect (portalSession.url)
}

 // Usage in your settings page: 
 // <form action={createPortalSession}> 
 //   <button type="submit">Manage Billing</button> 
 // </form>

When users change plans via the Customer Portal, Stripe fires customer.subscription.updated — which your webhook handler already processes. The plan change flows automatically: Stripe updates the subscription → webhook fires → your DB updates → user's access level changes. No additional code needed.

Access Control: Enforcing Plan Limits

Now that your database reflects the user's plan, enforce limits everywhere in your application. The pattern: read the plan from the user's profile, compare against PLANS[plan].limits , and block or gate accordingly.

import  { PLANS,  type   Plan  }  from   './stripe' 
 import  {  createClient  }  from   './supabase/server' 

 export async function   getUserPlan ():  Promise <{ plan:  Plan ; isActive:  boolean  }> {
   const  supabase =  createClient ()
   const  { data: { user } } =  await  supabase.auth. getUser ()

   if  (!user)  return  { plan:  'free' , isActive:  true  }

   const  { data: profile } =  await  supabase
    .from( 'profiles' )
    .select( 'plan, plan_status' )
    .eq( 'id' , user.id)
    .single()

   const  plan = (profile?.plan ??  'free' )  as   Plan 
   const  isActive = [ 'active' ,  'trialing' ]. includes (profile?.plan_status ??  'active' )

   return  { plan, isActive }
}

 export async function   requirePlan (minimumPlan:  Plan ) {
   const  planOrder:  Plan [] = [ 'free' ,  'pro' ,  'enterprise' ]
   const  { plan, isActive } =  await   getUserPlan ()

   if  (!isActive) {
     throw new   Error ( 'Your subscription is not active. Please update your billing.' )
  }

   if  (planOrder. indexOf (plan)  <  planOrder. indexOf (minimumPlan)) {
     throw new   Error ( `This feature requires the ${minimumPlan} plan. Please upgrade.` )
  }
}

 // Usage in Server Actions: 
 export async function   createProject (name:  string ) {
   const  { plan } =  await   getUserPlan ()
   const  limits = PLANS[plan].limits

   // Count existing projects 
   const  { count } =  await  supabase
    .from( 'projects' )
    .select( '*' , { count:  'exact'  })
    .eq( 'user_id' , userId)

   if  (count !==  null  && count >= limits.projects) {
     throw new   Error ( `Project limit reached. Upgrade to create more.` )
  }

   // ... create the project 
}

Usage-Based Billing: Charge for What They Use

Usage-based (metered) billing is perfect for API calls, compute time, or storage — where different customers consume wildly different amounts. Instead of flat-rate plans, customers pay for what they actually use. Stripe handles the metering natively.

Setup Metered Pricing in Stripe

In Stripe Dashboard, create a price with Usage type: Metered and Aggregation mode: Sum . This price charges per unit of usage reported. Set the per-unit price (e.g., $0.01 per API call).

import  { stripe }  from   './stripe' 
 import  {  createAdminClient  }  from   './supabase/admin' 

 /**
 * Track usage in your DB and report to Stripe.
 * Call this every time a metered action is performed.
 */ 
 export async function   trackUsage (
  userId:  string ,
  metric:  string ,
  quantity:  number 
) {
   const  supabase =  createAdminClient ()

   // 1. Record in your DB for analytics and dashboards 
   await  supabase.from( 'usage_records' ). insert ({
    user_id: userId,
    metric,
    quantity,
  })

   // 2. Get the user's subscription item ID for this metric 
   const  { data: sub } =  await  supabase
    .from( 'subscriptions' )
    .select( 'stripe_subscription_id' )
    .eq( 'user_id' , userId)
    .single()

   if  (!sub)  return    // Free plan — no Stripe subscription 

   const  subscription =  await  stripe.subscriptions. retrieve (
    sub.stripe_subscription_id
  )

   // Find the subscription item for this metric's price 
   const  meteredItem = subscription.items.data. find (
    item => item.price.id === process.env[ `STRIPE_PRICE_${metric.toUpperCase()}` ]
  )

   if  (!meteredItem)  return 

   // 3. Report usage to Stripe (idempotency key prevents double-billing) 
   await  stripe.subscriptionItems. createUsageRecord (
    meteredItem.id,
    {
      quantity,
      timestamp:  Math . floor ( Date . now () /  1000 ),
      action:  'increment' ,
    },
    {
      idempotencyKey:  `${userId}-${metric}-${Date.now()}` 
    }
  )
}

 // Usage example in your API route: 
 // await trackUsage(userId, 'api_calls', 1)

Failed Payments: Recover Revenue Automatically

Failed payments are inevitable. Cards expire, banks decline charges, payment methods run out of funds. Stripe's Smart Retries automatically retries failed payments at optimal times — enable this in Stripe Dashboard → Settings → Subscriptions → Smart Retries. It typically recovers 50-70% of failed payments automatically.

Your job: respond to the invoice.payment_failed webhook (already handled above) by sending a dunning email that helps users update their payment method via the Customer Portal.

import  {  Resend  }  from   'resend' 
 import  { stripe }  from   '@/lib/stripe' 

 const  resend =  new   Resend (process.env.RESEND_API_KEY)

 export async function   sendPaymentFailedEmail (
  userEmail:  string ,
  customerId:  string ,
  amount:  number 
) {
   // Create a Customer Portal session for payment method update 
   const  portalSession =  await  stripe.billingPortal.sessions. create ({
    customer: customerId,
    return_url:  `${process.env.NEXT_PUBLIC_APP_URL}/dashboard` ,
  })

   await  resend.emails. send ({
    from:  '[email protected]' ,
    to: userEmail,
    subject:  'Action required: Payment failed' ,
    html:  `
      <h2>Your payment of $${(amount / 100).toFixed(2)} failed</h2>
      <p>We couldn't process your subscription payment. Your account
      remains active while we retry, but please update your payment
      method to avoid service interruption.</p>
      <a href="${portalSession.url}" style="background:#635bff;color:white;
        padding:12px 24px;border-radius:6px;text-decoration:none;">
        Update Payment Method
      </a>
      <p>If you have any questions, reply to this email.</p>
    ` 
  })
}

The Dunning Sequence That Works

Enable Stripe Smart Retries (retry on days 3, 5, 7 after initial failure). On first failure: send friendly email "payment failed, update here." On day 3 retry failure: send urgent email "account at risk." On day 7: downgrade to free, send final email with reactivation link. This sequence recovers 60-80% of at-risk subscriptions and feels professional rather than aggressive.

Testing Stripe: The Complete Test Checklist

Testing Stripe requires Stripe's test cards and the Stripe CLI for local webhook testing. Never use real card data in development.

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

 # Login to Stripe CLI 
stripe login

 # Forward webhooks to your local development server 
stripe listen --forward-to localhost:3000/api/webhooks/stripe

 # In a separate terminal, trigger specific events for testing 
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed

 # Test cards (use in Stripe Checkout test mode): 
 # 4242 4242 4242 4242 — Visa, succeeds 
 # 4000 0000 0000 9995 — Always fails (insufficient funds) 
 # 4000 0025 0000 3155 — Requires 3D Secure authentication 
 # 4000 0000 0000 0341 — Attaches but payment fails

Manually test: (1) New user subscribes → check DB updated and access granted. (2) User cancels via Portal → check DB updated and cancel_at_period_end set. (3) Subscription expires → check user downgraded to free. (4) Payment fails → check status becomes past_due. (5) Payment recovered → check status returns to active. Don't go live until all 5 pass manually in test mode.

The Production Billing Checklist

  • Switch to live mode keys. Replace all sk_test_ and pk_test_ keys with live mode equivalents. Create a new live webhook endpoint in Stripe Dashboard.
  • Webhook endpoint registered in Stripe Dashboard. Set up your production URL (e.g., https://yourdomain.com/api/webhooks/stripe ) and add all the events you handle.
  • Enable Stripe Tax. Turn on Automatic Tax in your Checkout Sessions. Stripe calculates and collects the correct tax for each user's location automatically.
  • Enable Smart Retries. Stripe Dashboard → Settings → Subscriptions → Smart retries: ON. This is free revenue recovery.
  • Customer Portal is configured. In Stripe Dashboard → Settings → Customer Portal — enable plan upgrades, downgrades, cancellation, and invoice history.
  • Webhook signature verification is tested. Confirm your constructEvent() call rejects tampered requests.
  • All webhook events have idempotent handlers. Stripe can send the same event twice. Your handlers should produce the same result if called multiple times.
  • Budget alerts configured. In Stripe Dashboard → Settings → Email notifications — enable alerts for unusual activity.
  • Payout schedule confirmed. Know when Stripe deposits to your bank account and factor this into your cash flow planning.

Stripe billing is not a feature you ship once. It's infrastructure that grows with your product. Build it right the first time — webhooks, idempotency, and proper error handling — and you'll never have to think about it again.

  • Building a Multi-Tenant SaaS with Next.js, Supabase, and Stripe →
  • Clerk vs Auth0 vs NextAuth vs Supabase Auth: Which for Your SaaS? →
  • DevOps for Solo Founders: Zero to $10K MRR Infrastructure →
  • 10 Micro-SaaS Products You Can Build with AI This Weekend →