checkout.session.completed: Update Prisma Subscriptions in Next.js

Handling checkout.session.completed in Next.js to Update Subscriptions in Prisma
Payment succeeded in Stripe Checkout, but your app still shows the free plan. The user refreshes the dashboard nothing changes until you manually run a script or wait for another event. In most SaaS apps built with Next.js and Prisma, the gap is not Stripe; it is the checkout.session.completed handler never tying the new subscription to the logged-in user in your database.
This guide focuses on that one event: how to update user subscription state in Prisma when Checkout completes, why metadata.userId is the reliable link, and how this handler fits alongside customer.subscription.updated in a production webhook route.
What checkout.session.completed actually delivers
Stripe fires checkout.session.completed when a Checkout Session reaches a completed state. For subscription mode, that usually means:
The customer completed payment (or started a trial, depending on your price).
Stripe created or attached a Subscription object (session
subscriptionis set).The session may include metadata you set when creating the session.
The event payload is a Checkout.Session, not a full Subscription. Your handler typically:
Reads
session.metadata.userId(or resolves the user another way see pitfalls below).Retrieves the subscription with
stripe.subscriptions.retrieve(session.subscription).Upserts a
Subscriptionrow in Prisma keyed bystripeSubscriptionId.
That is the core of a checkout.session.completed handler to update user subscription in Prisma everything else is edge cases and hardening.
When this event is enough (and when it is not)
Scenario checkout.session.completed Also listen for First purchase after Checkout Primary sync point customer.subscription.updated for later changes Plan upgrade via Checkout Yes, if you use Checkout for upgrades subscription.updated Cancel at period end in Portal No customer.subscription.updated Failed renewal → past_due No customer.subscription.updated, invoice.paid Subscription deleted No customer.subscription.deleted
Treat Checkout completion as the fast path for new paid access; keep subscription lifecycle events for ongoing truth.
Prerequisites
Working webhook route with signature verification (
request.text()+constructEvent).Prisma models for
UserandSubscription(see the full sync guide).Checkout sessions created in subscription mode with
metadata.userIdset server-side.
Step 1: Pass userId when creating Checkout
Never trust the client to send userId in metadata without authentication. Create the session in a Route Handler or Server Action after you know the session user:
// app/api/stripe/checkout/route.ts (example)
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { priceId } = await request.json();
const user = await db.user.findUnique({
where: { id: session.user.id },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email ?? undefined,
metadata: { userId: user.id },
});
customerId = customer.id;
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
const checkoutSession = await stripe.checkout.sessions.create({
mode: "subscription",
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing?success=1`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
metadata: {
userId: user.id,
},
subscription_data: {
metadata: {
userId: user.id,
},
},
});
return NextResponse.json({ url: checkoutSession.url });
}
Why both metadata and subscription_data.metadata?
Session metadata is on the Checkout Session easy to read in
checkout.session.completed.Subscription metadata copies to the Subscription object useful when later events only include the subscription, not the original session.
Step 2: Map Stripe status to your Prisma enum
Reuse the same mapper from your general sync handler:
import type { SubscriptionStatus } from "@prisma/client";
import type Stripe from "stripe";
function mapSubscriptionStatus(
status: Stripe.Subscription.Status,
): SubscriptionStatus {
const statusMap: Record<Stripe.Subscription.Status, SubscriptionStatus> = {
active: "ACTIVE",
canceled: "CANCELED",
incomplete: "INCOMPLETE",
incomplete_expired: "INCOMPLETE_EXPIRED",
past_due: "PAST_DUE",
trialing: "TRIALING",
unpaid: "UNPAID",
paused: "PAUSED",
};
return statusMap[status] ?? "INCOMPLETE";
}
Step 3: Upsert from a Stripe Subscription object
Keep one function used by Checkout and by customer.subscription.updated:
async function upsertSubscriptionFromStripe(
userId: string,
subscription: Stripe.Subscription,
) {
const priceId = subscription.items.data[0]?.price.id;
if (!priceId) {
console.warn("Subscription missing price id", subscription.id);
return;
}
await db.subscription.upsert({
where: { stripeSubscriptionId: subscription.id },
create: {
userId,
stripeSubscriptionId: subscription.id,
stripePriceId: priceId,
status: mapSubscriptionStatus(subscription.status),
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
update: {
userId,
stripePriceId: priceId,
status: mapSubscriptionStatus(subscription.status),
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
}
Using upsert on stripeSubscriptionId makes the handler safe if Stripe retries the same checkout.session.completed delivery as long as you also guard side effects with event.id (see idempotent webhooks with event.id when that post is live; pattern below).
Step 4: The checkout.session.completed case in your webhook
Inside app/api/stripe/webhook/route.ts, after verification:
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode !== "subscription") {
break;
}
const userId = session.metadata?.userId;
const subscriptionId =
typeof session.subscription === "string"
? session.subscription
: session.subscription?.id;
if (!userId) {
console.error(
"checkout.session.completed missing metadata.userId",
session.id,
);
break;
}
if (!subscriptionId) {
console.error(
"checkout.session.completed missing subscription",
session.id,
);
break;
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
await upsertSubscriptionFromStripe(userId, subscription);
break;
}
Register checkout.session.completed in the Stripe Dashboard webhook endpoint (Developers → Webhooks → select events).
Step 5: Resolve the user when metadata is missing
If you forgot metadata.userId on older sessions, fall back to Stripe customer id on your User row:
async function resolveUserIdFromCheckoutSession(
session: Stripe.Checkout.Session,
): Promise<string | null> {
if (session.metadata?.userId) {
return session.metadata.userId;
}
const customerId =
typeof session.customer === "string"
? session.customer
: session.customer?.id;
if (!customerId) return null;
const user = await db.user.findFirst({
where: { stripeCustomerId: customerId },
});
return user?.id ?? null;
}
This only works if you persisted stripeCustomerId before Checkout (as in Step 1). Creating the customer only inside the webhook without saving the id on User leaves you with no join key.
Step 6: Idempotency and side effects
Stripe may deliver the same event more than once. Before running handlers:
const existing = await db.stripeEvent.findUnique({
where: { eventId: event.id },
});
if (existing) {
return NextResponse.json({ received: true });
}
await db.stripeEvent.create({
data: { eventId: event.id, type: event.type },
});
For checkout.session.completed, duplicate upserts are usually harmless. Side effects welcome emails, analytics, granting one-time credits must be keyed by event.id or session.id, not fired blindly on every delivery.
Step 7: Test the handler end to end
Fix verification first (Stripe webhook signature in Next.js).
Run
stripe listen --forward-to localhost:3000/api/stripe/webhookand set the CLIwhsecin.env.local.Complete test Checkout with card
4242 4242 4242 4242.In Stripe Dashboard → Webhooks → recent deliveries, confirm 200 and inspect logs for
checkout.session.completed.Open Prisma Studio:
Subscriptionrow should showACTIVE(orTRIALING) with correctuserId.
Optional: stripe trigger checkout.session.completed sends a fixture event—useful for plumbing, but real Checkout tests metadata and subscription ids more faithfully.
Common failures
Payment works; Prisma row never appears
Webhook not subscribed to
checkout.session.completed.Handler returns 500 before upsert (check logs; Stripe retries).
session.modeispaymentbut you only handle subscription mode.Missing
metadata.userIdand nostripeCustomerIdon user.
Wrong user gets the subscription
Client-supplied
userIdin metadata without server auth.Shared Stripe customer across accounts (reuse customer id correctly per app user).
User sees Pro briefly, then loses access
You only handle Checkout; cancel or
past_dueupdates never run.Fix: Also handle
customer.subscription.updatedand gate onstatus, not “row exists.”
Duplicate welcome emails or credits
No idempotency on
event.idfor non-upsert side effects.
How this connects to the rest of your billing stack
Signature verification — raw body handling in the App Router.
Full event matrix — Sync Stripe Subscriptions to Prisma in Next.js.
Customer Portal — users change plans without a new Checkout session; sync via
subscription.updated(Add the Stripe Customer Portal to Next.js App Router).Plan gating — read Prisma
Subscription.statusin server components after sync (upcoming: SaaS plan gating post).
Ship Checkout, metadata, and upserts without reinventing them
Wiring Checkout creation, customer ids, webhook verification, and Prisma upserts is repetitive and easy to get wrong on the first real payment.
Skryptix AI SaaS Starter Kit includes:
Server-side Checkout with
userIdmetadata and customer creationWebhook handler with
checkout.session.completedand subscription lifecycle eventsPrisma models and upsert helpers shared across events
Billing UI and docs for test mode
Metadata, checkout, and subscription upserts are pre-wired in AI SaaS Starter Kit — Get it here.
FAQ
What should I do in checkout.session.completed for a SaaS app?
Read metadata.userId, retrieve the subscription id from the session, call stripe.subscriptions.retrieve, and upsert a Subscription record in Prisma with status, price id, and billing period fields. Return HTTP 200 from the webhook after processing.
Do I still need customer.subscription.updated if I handle Checkout?
Yes. Checkout completion covers the first successful purchase; plan changes, renewals, failures, and cancellations arrive on subscription and invoice events later.
Where should I set userId for Stripe Checkout metadata?
In your authenticated server-side Checkout session creation (app/api/stripe/checkout/route.ts or equivalent) never from the browser alone.
Can I use client_reference_id instead of metadata?
Yes. Stripe supports client_reference_id on Checkout Sessions; read session.client_reference_id in the webhook the same way you read metadata.userId. Pick one convention and use it consistently.
Why retrieve the subscription instead of trusting the session object?
The session carries ids and metadata; the Subscription object has authoritative status, items, and period dates. One retrieve call keeps your Prisma row aligned with Stripe’s subscription API.
Written by the team behind Skryptix. We build the AI SaaS Starter Kit so founders can focus on product—not auth and billing infrastructure.
