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

October 13, 2026
checkout.session.completed: Update Prisma Subscriptions in Next.js
AI SaaS Starter Kit AI SaaS Starter Kit Production-ready Next.js 16 SaaS boilerplate: Auth.js login & OAuth, Stripe subscriptions, dashboard, analytics, team invites, admin CMS for pricing & FAQ, and 8 setup guides. TypeScript, Prisma, PostgreSQL, shadcn/ui. MIT license — download, customize, deploy. $29
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 subscription is 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:

  1. Reads session.metadata.userId (or resolves the user another way see pitfalls below).

  2. Retrieves the subscription with stripe.subscriptions.retrieve(session.subscription).

  3. Upserts a Subscription row in Prisma keyed by stripeSubscriptionId.

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 User and Subscription (see the full sync guide).

  • Checkout sessions created in subscription mode with metadata.userId set 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

  1. Fix verification first (Stripe webhook signature in Next.js).

  2. Run stripe listen --forward-to localhost:3000/api/stripe/webhook and set the CLI whsec in .env.local.

  3. Complete test Checkout with card 4242 4242 4242 4242.

  4. In Stripe Dashboard → Webhooks → recent deliveries, confirm 200 and inspect logs for checkout.session.completed.

  5. Open Prisma Studio: Subscription row should show ACTIVE (or TRIALING) with correct userId.

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.mode is payment but you only handle subscription mode.

  • Missing metadata.userId and no stripeCustomerId on user.

Wrong user gets the subscription

  • Client-supplied userId in 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_due updates never run.

  • Fix: Also handle customer.subscription.updated and gate on status, not “row exists.”

Duplicate welcome emails or credits

  • No idempotency on event.id for non-upsert side effects.


How this connects to the rest of your billing stack


Ship Checkout, metadata, and upserts without reinventing them

AI SaaS Starter Kit AI SaaS Starter Kit Production-ready Next.js 16 SaaS boilerplate: Auth.js login & OAuth, Stripe subscriptions, dashboard, analytics, team invites, admin CMS for pricing & FAQ, and 8 setup guides. TypeScript, Prisma, PostgreSQL, shadcn/ui. MIT license — download, customize, deploy. $29

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 userId metadata and customer creation

  • Webhook handler with checkout.session.completed and subscription lifecycle events

  • Prisma 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.