DEV Community

Cover image for Stripe Subscriptions in a SaaS (Step-by-Step With Code)
MD Hemal Akhand
MD Hemal Akhand

Posted on

Stripe Subscriptions in a SaaS (Step-by-Step With Code)

Have you ever added a “Subscribe” button to your SaaS — and then realized payments and user accounts are two completely different problems?

The user pays on Stripe.

Your app still thinks they’re on the free plan.

The webhook fires twice.

A guest buys from your landing page but has no login.

Sound familiar?

This happens on almost every subscription SaaS. The fix is not “one magic Stripe button.” It is a clear flow: checkout → webhook → database → dashboard.

This article walks through that flow step by step, with code you can adapt for your own Next.js project.

No confusion. No guessing. Just clear steps.

Security note: Every API key, webhook secret, URL, and route in this article is made up for learning. Use your own values from the Stripe Dashboard. Never publish your real credentials, webhook endpoints, database table names from production, or internal file paths in a public post — that gives attackers a head start.


What you’re building (the big picture)

In simple words:

  1. User clicks Subscribe → Stripe Payment Link opens
  2. User pays → Stripe sends a webhook to your server
  3. Your server saves the subscription in Postgres
  4. Dashboard shows plan, renewal date, cancel button

Here’s the full flow (paths below are examples only — name yours however you like):

User clicks "Subscribe"
        ↓
Stripe Payment Link (hosted checkout)
        ↓
Payment succeeds
        ↓
Stripe redirects to your success page (?session_id=...)
        ↓
Webhook: checkout.session.completed (runs in parallel)
        ↓
Create or find user in your database
        ↓
Save subscription row
        ↓
User lands on billing dashboard
Enter fullscreen mode Exit fullscreen mode

Important: Stripe is the source of truth for billing. Your database is a mirror kept in sync by webhooks.


Part 1: Set up Stripe (Dashboard)

Before writing code, create your products in Stripe.

Step 1 — Create products and prices

In Stripe Dashboard → Products:

Product Monthly price Yearly price
Starter $19/month $190/year
Pro $49/month $490/year
Business $99/month $990/year

Each price gets a Price ID like price_1ExampleStarterMonthly01. Save these — you’ll map them in code later.

Step 2 — Create Payment Links

For most SaaS apps, Payment Links are simpler than building a custom checkout form.

Why?

  • Stripe hosts the checkout page (PCI compliance handled for you)
  • Works for landing page and logged-in dashboard
  • No frontend card form to maintain

Create one Payment Link per plan + interval (6 links for 3 plans × monthly/yearly).

Configure each link:

  • Mode: Subscription
  • Success URL: https://YOUR-DOMAIN.com/billing/success?session_id={CHECKOUT_SESSION_ID}
  • Local dev: http://localhost:3000/billing/success?session_id={CHECKOUT_SESSION_ID}

Stripe will give you URLs like:

https://buy.stripe.com/test_XXXXXXXXXXXX
Enter fullscreen mode Exit fullscreen mode

Copy those into a private config file in your repo (not in a blog post).

Step 3 — Get your API keys

From Developers → API keys:

# Test mode (local development)
STRIPE_TEST_SECRET_KEY=sk_test_XXXXXXXXXXXXXXXXXXXXXXXX
STRIPE_TEST_WEBHOOK_SECRET=whsec_XXXXXXXXXXXXXXXXXXXXXXXX

# Live mode (production)
STRIPE_SECRET_KEY=sk_live_XXXXXXXXXXXXXXXXXXXXXXXX
STRIPE_WEBHOOK_SECRET=whsec_XXXXXXXXXXXXXXXXXXXXXXXX

# Toggle test vs live in the app
NEXT_PUBLIC_STRIPE_TEST_MODE=true
Enter fullscreen mode Exit fullscreen mode

Never commit real keys to Git. Use .env.local and your host’s secret environment variables only.


Part 2: Database tables

Your app needs to remember who subscribed to what. A typical setup uses three tables.

users — link account to Stripe customer

ALTER TABLE users ADD COLUMN stripe_customer_id TEXT UNIQUE;
Enter fullscreen mode Exit fullscreen mode

subscriptions — subscription history

CREATE TABLE subscriptions (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    stripe_subscription_id TEXT NOT NULL UNIQUE,
    stripe_customer_id TEXT NOT NULL,
    stripe_price_id TEXT,
    plan_id TEXT NOT NULL,
    billing_interval TEXT NOT NULL,
    status TEXT NOT NULL,
    current_period_start TIMESTAMPTZ NOT NULL,
    current_period_end TIMESTAMPTZ NOT NULL,
    cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
    canceled_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Enter fullscreen mode Exit fullscreen mode

processed_webhook_events — prevent duplicate processing

Stripe can send the same webhook twice. Store processed event IDs:

CREATE TABLE processed_webhook_events (
    event_id TEXT PRIMARY KEY,
    event_type TEXT NOT NULL,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Enter fullscreen mode Exit fullscreen mode

Why idempotency matters: Without it, one payment could create two subscription rows and unlock paid features twice.


Part 3: Stripe client (server-side)

Install the SDK:

npm install stripe
Enter fullscreen mode Exit fullscreen mode

Example server helper (keep this file private in your repo):

import Stripe from "stripe";

function getSecretKey(testMode: boolean): string {
  const key = testMode
    ? process.env.STRIPE_TEST_SECRET_KEY
    : process.env.STRIPE_SECRET_KEY;

  if (!key) throw new Error("Stripe secret key is not configured");
  return key;
}

export function isTestMode(): boolean {
  const flag = process.env.NEXT_PUBLIC_STRIPE_TEST_MODE;
  if (flag === "true") return true;
  if (flag === "false") return false;
  return process.env.NODE_ENV === "development";
}

let testClient: Stripe | null = null;
let liveClient: Stripe | null = null;

export function getStripe(testMode?: boolean): Stripe {
  const useTest = testMode ?? isTestMode();

  if (useTest) {
    if (!testClient) testClient = new Stripe(getSecretKey(true));
    return testClient;
  }

  if (!liveClient) liveClient = new Stripe(getSecretKey(false));
  return liveClient;
}
Enter fullscreen mode Exit fullscreen mode
Code What it means
getStripe(true) Test API key (sk_test_...)
getStripe(false) Live API key (sk_live_...)
isTestMode() Pick test links locally, live in production
Cached client One instance per mode — don’t create new clients on every request

Part 4: Payment Link URLs in code

Store Payment Links in a config module inside your project (do not paste live URLs publicly):

const TEST_CHECKOUT_URLS = {
  starter: {
    monthly: "https://buy.stripe.com/test_XXXXXXXX",
    yearly: "https://buy.stripe.com/test_YYYYYYYY",
  },
  pro: {
    monthly: "https://buy.stripe.com/test_AAAAAAAA",
    yearly: "https://buy.stripe.com/test_BBBBBBBB",
  },
  business: {
    monthly: "https://buy.stripe.com/test_CCCCCCCC",
    yearly: "https://buy.stripe.com/test_DDDDDDDD",
  },
} as const;

const LIVE_CHECKOUT_URLS = {
  // Same shape — your live links from Stripe Dashboard
} as const;

export function checkoutUrl(planId: string, interval: "monthly" | "yearly") {
  const urls = isTestMode() ? TEST_CHECKOUT_URLS : LIVE_CHECKOUT_URLS;
  const plan = urls[planId as keyof typeof TEST_CHECKOUT_URLS];
  return plan?.[interval] ?? null;
}
Enter fullscreen mode Exit fullscreen mode

Link checkout to the logged-in user

When a signed-in user subscribes, pass their user ID so the webhook knows who paid:

export function checkoutUrlForUser(
  baseUrl: string,
  user: { id: string; email: string },
): string {
  const url = new URL(baseUrl);

  if (user.email?.trim()) {
    url.searchParams.set("prefilled_email", user.email.trim());
  }

  // Stripe passes this back on the session — use YOUR internal user id
  url.searchParams.set("client_reference_id", user.id);
  return url.toString();
}
Enter fullscreen mode Exit fullscreen mode
Parameter Purpose
prefilled_email Pre-fills email on Stripe checkout
client_reference_id Your user ID — primary way to link payment → account

For guest checkout from a landing page, you can skip client_reference_id and match by email instead (Part 9).


Part 5: Subscribe button in the dashboard

"use client";

export function SubscribeButton({
  planId,
  interval,
  user,
}: {
  planId: "starter" | "pro" | "business";
  interval: "monthly" | "yearly";
  user: { id: string; email: string };
}) {
  const base = checkoutUrl(planId, interval);
  if (!base) return null;

  const href = checkoutUrlForUser(base, user);

  return (
    <a href={href} className="btn-primary">
      Subscribe
    </a>
  );
}
Enter fullscreen mode Exit fullscreen mode

On the landing page, link directly to the Payment Link (no user params):

<a href={checkoutUrl("starter", "monthly") ?? "#"}>Buy now</a>
Enter fullscreen mode Exit fullscreen mode

Part 6: Map Stripe prices to your plans

When a webhook arrives, Stripe sends a price_... ID. Map it to your internal plan names:

const PRICE_TO_PLAN: Record<string, { planId: string; interval: string }> = {
  price_1ExampleStarterMonthly01: { planId: "starter", interval: "monthly" },
  price_1ExampleStarterYearly001: { planId: "starter", interval: "yearly" },
  // Add every test + live price ID from YOUR Stripe account
};

export function planFromPriceId(priceId: string | null | undefined) {
  return priceId ? PRICE_TO_PLAN[priceId] ?? null : null;
}
Enter fullscreen mode Exit fullscreen mode

Part 7: Save subscriptions to the database

import type Stripe from "stripe";

export async function findUserForSubscription(
  subscription: Stripe.Subscription,
  userIdHint?: string | null,
) {
  if (userIdHint) {
    const user = await db.user.findUnique({ where: { id: userIdHint } });
    if (user) return user;
  }

  const customerId =
    typeof subscription.customer === "string"
      ? subscription.customer
      : subscription.customer?.id;

  if (!customerId) return null;

  const byCustomer = await db.user.findFirst({
    where: { stripeCustomerId: customerId },
  });
  if (byCustomer) return byCustomer;

  const stripe = getStripe();
  const customer = await stripe.customers.retrieve(customerId);
  if (!customer.deleted && customer.email) {
    return db.user.findUnique({ where: { email: customer.email } });
  }

  return null;
}

export async function saveSubscription(
  subscription: Stripe.Subscription,
  userIdHint?: string | null,
) {
  const user = await findUserForSubscription(subscription, userIdHint);
  if (!user) {
    throw new Error("No user found for subscription");
  }

  const item = subscription.items.data[0];
  const price = item?.price;
  const plan = planFromPriceId(price?.id);
  if (!plan) throw new Error("Unknown price");

  const customerId =
    typeof subscription.customer === "string"
      ? subscription.customer
      : (subscription.customer?.id ?? "");

  await db.user.update({
    where: { id: user.id },
    data: { stripeCustomerId: customerId },
  });

  return db.subscription.upsert({
    where: { stripeSubscriptionId: subscription.id },
    create: {
      userId: user.id,
      stripeSubscriptionId: subscription.id,
      stripeCustomerId: customerId,
      stripePriceId: price?.id ?? null,
      planId: plan.planId,
      billingInterval: plan.interval,
      status: subscription.status,
      currentPeriodStart: new Date(item.current_period_start * 1000),
      currentPeriodEnd: new Date(item.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
    },
    update: {
      status: subscription.status,
      currentPeriodStart: new Date(item.current_period_start * 1000),
      currentPeriodEnd: new Date(item.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Key idea: upsert = create if new, update if exists. Same function handles first payment and renewals.


Part 8: Webhooks — the heart of the integration

The webhook route

Use a path only you know, e.g. POST /api/billing/stripe-webhook (example):

import { NextResponse, type NextRequest } from "next/server";

export const runtime = "nodejs";

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

  if (!signature) {
    return NextResponse.json({ error: "Missing signature" }, { status: 400 });
  }

  try {
    const event = verifyWebhook(payload, signature);
    await handleStripeEvent(event);
    return NextResponse.json({ received: true });
  } catch {
    return NextResponse.json({ error: "Invalid webhook" }, { status: 400 });
  }
}
Enter fullscreen mode Exit fullscreen mode

Verify the signature (required)

Never trust webhook JSON without verification:

export function verifyWebhook(payload: string, signature: string) {
  const stripe = getStripe();
  const secret = process.env.STRIPE_WEBHOOK_SECRET!;
  return stripe.webhooks.constructEvent(payload, signature, secret);
}
Enter fullscreen mode Exit fullscreen mode

If the signature doesn’t match, reject the request. This blocks fake “payment succeeded” attacks.

Handle events

async function alreadyProcessed(eventId: string) {
  const row = await db.processedWebhookEvent.findUnique({
    where: { eventId },
  });
  return Boolean(row);
}

export async function handleStripeEvent(event: Stripe.Event) {
  if (await alreadyProcessed(event.id)) return;

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

      const user = await ensureUserFromSession(session);
      const subId =
        typeof session.subscription === "string"
          ? session.subscription
          : session.subscription.id;

      const stripe = getStripe();
      const subscription = await stripe.subscriptions.retrieve(subId);
      await saveSubscription(subscription, user?.id);
      break;
    }

    case "customer.subscription.created":
    case "customer.subscription.updated":
    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      await saveSubscription(subscription);
      break;
    }

    case "invoice.payment_succeeded":
    case "invoice.payment_failed": {
      const invoice = event.data.object as Stripe.Invoice;
      const subId = invoice.subscription as string | null;
      if (!subId) break;

      const stripe = getStripe();
      const subscription = await stripe.subscriptions.retrieve(subId);
      await saveSubscription(subscription);
      break;
    }
  }

  await db.processedWebhookEvent.create({
    data: { eventId: event.id, eventType: event.type },
  });
}
Enter fullscreen mode Exit fullscreen mode

Events to listen for

Event When it fires What to do
checkout.session.completed First successful checkout Create account if needed, save subscription
customer.subscription.updated Plan change, cancel scheduled Update status and period end
customer.subscription.deleted Subscription ended Mark canceled
invoice.payment_succeeded Renewal paid Refresh billing period
invoice.payment_failed Card declined Update status to past_due

Part 9: Guest checkout — auto-create accounts

When someone buys without an account, create one on the server after payment (never trust the browser alone):

import { randomBytes } from "node:crypto";

function randomPassword() {
  return randomBytes(16).toString("base64url");
}

export async function ensureUserFromSession(session: Stripe.Checkout.Session) {
  const email =
    session.customer_details?.email?.trim() ||
    session.customer_email?.trim();

  if (!email) return null;

  const normalized = email.toLowerCase();
  const existing = await db.user.findUnique({ where: { email: normalized } });
  if (existing) return existing;

  const password = randomPassword();

  // Use your auth provider’s admin API (Supabase, Clerk, Auth0, etc.)
  const newUser = await authAdmin.createUser({
    email: normalized,
    password,
    emailVerified: true,
  });

  await sendWelcomeEmail({ email: normalized, password });

  return newUser;
}
Enter fullscreen mode Exit fullscreen mode

Edge case: Webhook and success-page redirect can both run at once. Handle “user already exists” without crashing.


Part 10: After payment — success page

Stripe redirects with session_id. On your success route, retrieve the session from Stripe (server-side) before unlocking anything:

export async function GET(request: NextRequest) {
  const sessionId = request.nextUrl.searchParams.get("session_id");
  if (!sessionId) {
    return redirect("/login?billing=error");
  }

  const stripe = getStripe();
  const session = await stripe.checkout.sessions.retrieve(sessionId);

  if (session.payment_status !== "paid") {
    return redirect("/login?billing=processing");
  }

  const user = await ensureUserFromSession(session);

  const subId =
    typeof session.subscription === "string"
      ? session.subscription
      : session.subscription?.id;

  if (subId) {
    const subscription = await stripe.subscriptions.retrieve(subId);
    await saveSubscription(subscription, user?.id);
  }

  if (user?.isNew) {
    await signInUser(user);
    return redirect("/dashboard/billing?welcome=1");
  }

  return redirect("/login?billing=success");
}
Enter fullscreen mode Exit fullscreen mode

Why both webhook AND success page?

Path Purpose
Webhook Reliable sync even if user closes the tab
Success page Better UX — sign in and show dashboard

They can run in parallel. Idempotency prevents duplicate rows.


Part 11: Cancel and reactivate

Let users cancel inside your app:

export async function cancelAtPeriodEnd(userId: string) {
  const active = await getActiveSubscription(userId);
  if (!active) throw new Error("No active subscription");

  const stripe = getStripe();
  const updated = await stripe.subscriptions.update(
    active.stripeSubscriptionId,
    { cancel_at_period_end: true },
  );

  await saveSubscription(updated, userId);
  return updated;
}

export async function reactivate(userId: string) {
  const active = await getActiveSubscription(userId);
  if (!active?.cancelAtPeriodEnd) return active;

  const stripe = getStripe();
  const updated = await stripe.subscriptions.update(
    active.stripeSubscriptionId,
    { cancel_at_period_end: false },
  );

  await saveSubscription(updated, userId);
  return updated;
}
Enter fullscreen mode Exit fullscreen mode

Protect the API with auth — only the logged-in owner can cancel their own sub:

export async function POST(request: NextRequest) {
  const user = await requireAuth(request);
  await cancelAtPeriodEnd(user.id);
  return NextResponse.json({ ok: true });
}
Enter fullscreen mode Exit fullscreen mode

Cancel at period end keeps access until current_period_end. That’s what users expect.


Part 12: Local development with Stripe CLI

Webhooks can’t reach localhost without forwarding.

Terminal 1 — app:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Terminal 2 — Stripe CLI:

stripe listen --forward-to localhost:3000/api/billing/stripe-webhook
Enter fullscreen mode Exit fullscreen mode

Copy the whsec_... from the CLI into .env.local as STRIPE_TEST_WEBHOOK_SECRET.

Test card: 4242 4242 4242 4242 — any future expiry, any CVC.

Test checklist

  1. Subscribe on localhost
  2. Pay with test card
  3. CLI shows checkout.session.completed
  4. Billing page shows active plan
  5. Cancel → status “canceling at period end”
  6. Reactivate → active again

Part 13: Production checklist

  • [ ] NEXT_PUBLIC_STRIPE_TEST_MODE=false in production
  • [ ] Live keys only in server environment variables
  • [ ] Webhook URL registered in Stripe Dashboard (your real URL — keep it private)
  • [ ] Live Payment Link success URLs point to production domain
  • [ ] All live price IDs mapped in your private config
  • [ ] Webhook idempotency table exists
  • [ ] Test one small live charge, then refund
  • [ ] Cancel / reactivate tested end-to-end

Register webhook in Stripe

Developers → Webhooks → Add endpoint with your production URL and these events:

  • checkout.session.completed
  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.payment_succeeded
  • invoice.payment_failed

Store the signing secret in STRIPE_WEBHOOK_SECRET — never in source code or blog posts.


What NOT to share publicly (important)

When you write tutorials or open-source sample code:

Do share Do NOT share
General patterns and Stripe event names Live sk_live_ or whsec_ secrets
Dummy placeholders (sk_test_XXX) Your real Payment Link URLs
Generic table/column ideas Exact production routes (/api/...) from your live app
Stripe’s official test card 4242… Webhook endpoint URL attackers could spam
How signature verification works Auth admin keys or service role keys

Attackers scan GitHub and Medium for leaked webhook URLs and keys. Your tutorial code should teach the pattern, not document your production stack.


Common mistakes

1. Trusting the frontend for payment status

Never unlock paid features because the user landed on a success page. Verify the session server-side and/or wait for the webhook.

2. No webhook idempotency

Stripe retries failed webhooks. Without a processed-events table, you get duplicate subscriptions.

3. Mixing test and live keys

Test IDs contain _test_. Live IDs contain _live_. Use the matching API key.

4. Forgetting client_reference_id

Logged-in checkout won’t link to the right user without it.

5. Publishing secrets in tutorials

Rotate any key that was ever committed or pasted online.

6. Skipping guest account creation

Landing-page buyers need a login after they pay.

7. Immediate cancel instead of period-end cancel

Use cancel_at_period_end: true unless your product policy says otherwise.


Quick reference

Goal Approach
Accept subscriptions Stripe Payment Links
Link payment to user client_reference_id + email fallback
Keep DB in sync Webhooks + upsert
Prevent duplicates Processed event IDs table
Test locally stripe listen --forward-to ...
Cancel cancel_at_period_end: true
Guest checkout Provision user on checkout.session.completed

The complete flow (save this)

Subscribe click
      ↓
Stripe Payment Link
      ↓
Payment succeeds
      ↓
      ├── Redirect → your success page (verify session_id)
      │
      └── Webhook → your private endpoint
                ↓
          Verify Stripe signature
                ↓
          checkout.session.completed
                ↓
          Find or create user
                ↓
          Upsert subscription row
                ↓
          Mark event processed
      ↓
User sees active plan in dashboard
Enter fullscreen mode Exit fullscreen mode

Final thought

Stripe integration looks complex until you see the loop:

  1. Checkout — Payment Links handle cards
  2. Linkclient_reference_id ties payment to your user
  3. Sync — Webhooks update your database
  4. Trust — Verify everything on the server

Copy the patterns. Use dummy keys while learning. Plug in your real values only in private .env files and your hosting dashboard.

Your next paying customer is one verified webhook away.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.