Plainform

Mobile Sidebar

Mobile Sidebar

How Stripe Subscriptions Work in a SaaS App

7 Jun 2026
11 minute read
R
Ronald SolticzkiBackend Engineer
How Stripe Subscriptions Work in a SaaS App

Stripe subscriptions are how most SaaS products handle recurring revenue. A customer signs up, Stripe charges them on a schedule, and your app grants or revokes access based on whether they are in good standing.

That sounds simple. But the implementation has more moving parts than it first appears. There is the checkout session, the subscription object, the webhook lifecycle, the local database sync, customer portal access, upgrade and downgrade logic, and trial periods — each with its own edge cases.

This post covers how Stripe subscriptions work end to end in a Next.js SaaS app, what each piece is responsible for, and the patterns that hold up in production.

How Stripe Subscriptions Work

A Stripe subscription is an object that tells Stripe to charge a customer on a recurring schedule. It is tied to a customer, a price, and a billing interval. Stripe handles the billing cycle, retries failed payments, and sends webhook events at each significant moment in the subscription lifecycle.

Your app never charges the customer directly. Instead:

  1. You create a Stripe Checkout session in subscription mode
  2. The customer enters their payment details on Stripe's hosted checkout page
  3. Stripe creates the subscription and charges the customer
  4. Stripe sends a checkout.session.completed webhook event
  5. Stripe sends customer.subscription.created to signal the subscription is active
  6. Your webhook handler syncs the subscription state to your database
  7. Your app grants access based on what is in your database

The database is the source of truth for your app. Stripe is the source of truth for billing. Webhooks are the bridge between them.

Creating a Checkout Session in Subscription Mode

The entry point is a POST route that creates a Stripe Checkout session. The key difference from a one-time payment is mode: 'subscription':

// app/api/stripe/checkout/route.ts
import { stripe } from '@/lib/stripe/stripe';
import { auth } from '@clerk/nextjs/server';
import { env } from '@/env';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
  const { userId } = await auth();

  if (!userId) {
    return NextResponse.redirect(new URL('/sign-in', req.url));
  }

  const formData = await req.formData();
  const priceId = formData.get('priceId')?.toString();

  if (!priceId) {
    return NextResponse.json({ message: 'Missing price ID.' }, { status: 400 });
  }

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${env.SITE_URL}/order?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${env.SITE_URL}/pricing`,
    automatic_tax: { enabled: true },
    subscription_data: {
      metadata: {
        userId, // store your internal user ID for webhook use
      },
    },
  });

  return NextResponse.redirect(session.url!, { status: 303 });
}

Why store userId in subscription metadata

Webhooks from Stripe do not include your internal user ID by default. They include the Stripe customer ID and subscription ID. To sync subscription state back to the right local user, you need a way to look up the user from Stripe data.

Storing userId in subscription_data.metadata means it travels with the subscription object and is available in every subscription webhook event. The alternative is storing the Stripe customer ID on your local user record and querying that — both patterns work, and most production apps use both.

Attaching a Stripe customer

In production, you typically want to create or reuse a Stripe customer record rather than letting Stripe create a new one on each checkout. This keeps all of a user's payment history, invoices, and subscriptions linked to one customer:

// Create or retrieve the Stripe customer for this user
let customerId = existingUser?.stripeCustomerId;

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

  // Save the customer ID to your database
  await prisma.user.update({
    where: { id: userId },
    data: { stripeCustomerId: customerId },
  });
}

const session = await stripe.checkout.sessions.create({
  customer: customerId,
  mode: 'subscription',
  // ...
});

The Webhook Handler

After checkout completes, Stripe sends events to your webhook endpoint. This is where your app does the real work — updating the database, sending emails, granting access.

Verifying the signature

Every webhook handler must verify the Stripe signature before processing anything. Without this, anyone can send a fake event to your endpoint:

// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe/stripe';
import { env } from '@/env';
import Stripe from 'stripe';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
  const rawBody = await req.text(); // must be raw text, not parsed JSON
  const sig = req.headers.get('stripe-signature')!;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      rawBody,
      sig,
      env.STRIPE_WEBHOOK_SECRET
    );
  } catch (error) {
    console.error('Webhook signature verification failed:', error);
    return NextResponse.json({ message: 'Invalid signature.' }, { status: 400 });
  }

  // process event...
}

The raw body is critical. If you parse the body as JSON first — even just to log it — the signature check fails because the raw bytes no longer match what Stripe signed.

The subscription lifecycle events

Stripe subscriptions generate several webhook events across their lifetime. The ones you must handle in a SaaS app:

EventWhen it firesWhat to do
checkout.session.completedAfter checkoutAcknowledge, send welcome email
customer.subscription.createdSubscription is activeSync to DB, grant access
customer.subscription.updatedPlan change, renewal, status changeRe-sync to DB, update access
customer.subscription.deletedSubscription cancelled and endedRevoke access
invoice.payment_failedBilling attempt failedNotify user, optionally restrict access
invoice.payment_succeededSuccessful renewal chargeOptional: record revenue event

Syncing subscription state

The most important handler is the one that syncs subscription state to your database. It should run on created, updated, and deleted:

async function syncSubscription(subscription: Stripe.Subscription) {
  // Retrieve the customer to get your internal user ID
  const customer = await stripe.customers.retrieve(
    subscription.customer as string
  ) as Stripe.Customer;

  const userId = customer.metadata?.userId;

  if (!userId) {
    console.error('No userId in customer metadata', subscription.customer);
    return;
  }

  const price = subscription.items.data[0]?.price;
  const product = await stripe.products.retrieve(price?.product as string);

  await prisma.user.upsert({
    where: { id: userId },
    update: {
      stripeSubscriptionId: subscription.id,
      stripePriceId: price?.id,
      stripeCustomerId: subscription.customer as string,
      subscriptionStatus: subscription.status,
      subscriptionCurrentPeriodEnd: new Date(
        subscription.current_period_end * 1000
      ),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
      planKey: product.metadata?.planKey ?? null,
    },
    create: {
      id: userId,
      stripeSubscriptionId: subscription.id,
      stripePriceId: price?.id,
      stripeCustomerId: subscription.customer as string,
      subscriptionStatus: subscription.status,
      subscriptionCurrentPeriodEnd: new Date(
        subscription.current_period_end * 1000
      ),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
      planKey: product.metadata?.planKey ?? null,
    },
  });
}

Dispatching events

Keep the webhook route clean by dispatching each event type to its own handler:

switch (event.type) {
  case 'checkout.session.completed':
    await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
    break;

  case 'customer.subscription.created':
  case 'customer.subscription.updated':
    await syncSubscription(event.data.object as Stripe.Subscription);
    break;

  case 'customer.subscription.deleted':
    await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
    break;

  case 'invoice.payment_failed':
    await handlePaymentFailed(event.data.object as Stripe.Invoice);
    break;

  default:
    break;
}

Controlling Access Based on Subscription State

Once subscription state is in your database, you check it wherever access decisions are made — middleware, server components, API routes.

Checking subscription status

import { auth } from '@clerk/nextjs/server';
import { prisma } from '@/lib/prisma/prisma';

export async function getSubscriptionStatus() {
  const { userId } = await auth();
  if (!userId) return null;

  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: {
      subscriptionStatus: true,
      subscriptionCurrentPeriodEnd: true,
      cancelAtPeriodEnd: true,
      planKey: true,
    },
  });

  return user;
}

The subscriptionStatus field mirrors Stripe's subscription status values: active, trialing, past_due, canceled, unpaid, incomplete, incomplete_expired, paused.

For most SaaS access checks, you want active or trialing:

const isActive = ['active', 'trialing'].includes(user.subscriptionStatus);

Using planKey from product metadata

To support multiple pricing tiers (free, pro, enterprise), store a planKey in the Stripe product's metadata field. The webhook syncs it to your database, and your access checks use it to determine what features are available:

const canAccessProFeature = user.planKey === 'pro' || user.planKey === 'enterprise';

This pattern means adding a new pricing tier is a Stripe dashboard change, not a code deploy.

Upgrades and Downgrades

When a customer changes their plan, you update the Stripe subscription rather than cancelling and creating a new one. Stripe handles the proration automatically.

Changing the plan

async function changePlan(subscriptionId: string, newPriceId: string) {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);

  await stripe.subscriptions.update(subscriptionId, {
    items: [
      {
        id: subscription.items.data[0].id,
        price: newPriceId,
      },
    ],
    proration_behavior: 'create_prorations', // or 'none' to disable proration
  });
}

After this call, Stripe fires customer.subscription.updated, which your webhook handler picks up and syncs to your database. The user's access updates automatically.

Letting the customer manage their subscription

Rather than building your own plan change and cancellation UI, use Stripe's Customer Portal. It handles upgrades, downgrades, cancellations, and payment method updates — fully hosted and maintained by Stripe:

// app/api/stripe/portal/route.ts
export async function POST(req: NextRequest) {
  const { userId } = await auth();
  if (!userId) return NextResponse.redirect('/sign-in');

  const user = await prisma.user.findUnique({ where: { id: userId } });

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${env.SITE_URL}/account`,
  });

  return NextResponse.redirect(portalSession.url, { status: 303 });
}

The customer clicks a "Manage subscription" button, gets redirected to the Stripe portal, makes changes, and is returned to your app. Stripe fires the appropriate webhook events for whatever they changed, and your sync logic handles the rest.

Trial Periods

Free trials are configured when creating the checkout session or subscription:

const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  line_items: [{ price: priceId, quantity: 1 }],
  subscription_data: {
    trial_period_days: 14,
    metadata: { userId },
  },
  // ...
});

During the trial, the subscription status is trialing. Stripe does not charge the customer until the trial ends. When the trial converts to a paid subscription, Stripe fires customer.subscription.updated with status: 'active' — your webhook handler catches it and nothing else needs to change.

Trials without a payment method

You can also offer trials without requiring a credit card upfront:

subscription_data: {
  trial_period_days: 14,
  trial_settings: {
    end_behavior: {
      missing_payment_method: 'cancel', // or 'pause'
    },
  },
},
payment_method_collection: 'if_required',

If the customer does not add a payment method before the trial ends, Stripe either cancels or pauses the subscription based on end_behavior. Your webhook receives the appropriate status event either way.

Handling Webhook Idempotency

Stripe can retry webhook events if your endpoint does not respond with a 2xx status within a few seconds. This means your handlers must be idempotent — running the same event twice should produce the same result, not duplicate side effects.

For a subscription sync, upsert is naturally idempotent. For side effects like sending emails, use a database key to guard against duplicates:

async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  const key = `checkout:${session.id}`;
  const existing = await prisma.processedEvent.findUnique({ where: { key } });

  if (existing) {
    return; // already processed
  }

  await prisma.processedEvent.create({ data: { key } });

  // now safe to send email, record event, etc.
  await sendWelcomeEmail(session.customer_details?.email);
}

Without this guard, a Stripe retry or an accidental resend causes duplicate welcome emails, duplicate credits, or duplicate any other side effect you fire on checkout.

Testing Stripe Subscriptions Locally

Stripe provides a CLI for forwarding webhook events to your local server:

npm run stripe:listen
# which runs: stripe listen --forward-to localhost:3000/api/webhooks/stripe

This starts a listener that forwards all incoming Stripe events to your local endpoint. The CLI also prints a webhook signing secret — use this as STRIPE_WEBHOOK_SECRET in your .env.local for local development, separate from the production webhook secret.

To trigger specific events without going through the full checkout flow:

stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed

This lets you test every state transition in your webhook handler without creating real subscriptions.

Common Mistakes with Stripe Subscriptions

Trusting the browser instead of webhooks. Never grant access based on a redirect parameter or a client-side flag after checkout. The only trusted signal is a webhook event with a verified signature.

Not handling customer.subscription.updated. This event fires on every renewal, plan change, and status change. If you only handle created and deleted, your database goes out of sync on the first billing cycle.

Forgetting past_due status. When a renewal payment fails, Stripe sets the subscription to past_due and retries. You should decide upfront whether past_due users retain access (grace period) or lose it immediately. Set your access check accordingly and notify the user to update their payment method.

Not syncing cancelAtPeriodEnd. When a customer cancels, Stripe does not immediately set status to canceled. It sets cancel_at_period_end: true and fires customer.subscription.updated. The subscription stays active until the period ends, then Stripe fires customer.subscription.deleted. If you only watch for deleted, you miss the cancellation signal until the end of the period.

Sharing the same webhook secret between environments. Each webhook endpoint in the Stripe dashboard has its own secret. Your local, staging, and production endpoints each get their own STRIPE_WEBHOOK_SECRET.

Summary

Stripe subscriptions in a SaaS app follow a clear pattern:

  • Create a Checkout session in subscription mode with your internal user ID stored in metadata
  • Verify every webhook signature before processing
  • Sync subscription state (status, planKey, currentPeriodEnd, cancelAtPeriodEnd) to your database on created, updated, and deleted events
  • Base access decisions on database state, not Stripe API calls
  • Use Stripe's Customer Portal for plan changes and cancellations
  • Use idempotency keys to guard against duplicate side effects on webhook retries

The webhook handler is the most important piece. Get that right and the rest of the subscription lifecycle largely takes care of itself.

Comments on this page

Leave comment

Stay up to date with our latest product updates. Unsubscribe anytime!

2026 © All rights reserved