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:
- User clicks Subscribe → Stripe Payment Link opens
- User pays → Stripe sends a webhook to your server
- Your server saves the subscription in Postgres
- 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
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
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
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;
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()
);
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()
);
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
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;
}
| 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;
}
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();
}
| 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>
);
}
On the landing page, link directly to the Payment Link (no user params):
<a href={checkoutUrl("starter", "monthly") ?? "#"}>Buy now</a>
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;
}
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,
},
});
}
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 });
}
}
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);
}
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 },
});
}
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;
}
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");
}
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;
}
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 });
}
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
Terminal 2 — Stripe CLI:
stripe listen --forward-to localhost:3000/api/billing/stripe-webhook
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
- Subscribe on localhost
- Pay with test card
- CLI shows
checkout.session.completed - Billing page shows active plan
- Cancel → status “canceling at period end”
- Reactivate → active again
Part 13: Production checklist
- [ ]
NEXT_PUBLIC_STRIPE_TEST_MODE=falsein 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.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.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
Final thought
Stripe integration looks complex until you see the loop:
- Checkout — Payment Links handle cards
-
Link —
client_reference_idties payment to your user - Sync — Webhooks update your database
- 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.