AI SaaS Starter KitAI SaaS Starter KitProduction-ready SaaS boilerplate with Next.js 16, Auth.js, Stripe subscriptions, Prisma, dashboard, teams, admin CMS, and AI-ready API hooks.$29

If you sell subscriptions with Stripe, your app needs one source of truth for who is on Pro, Business, or free. Checkout happens on Stripe’s side but your dashboard, API routes, and plan gates read fromyourdatabase. When those two drift apart, users see “Upgrade” while Stripe shows an active subscription, or they get Pro features after a failed payment.

This guide shows how tosync Stripe subscription state to Prismausing a Next.js App Router webhook handler: raw-body signature verification, the events that matter for SaaS, and an upsert pattern you can trust in production.


What you are building

End-to-end flow:

  1. User clicksSubscribe→ Stripe Checkout session.

  2. User pays → Stripe sends webhooks to your app.

  3. Your handler verifies the signature, processes the event, andupsertsaSubscriptionrow in Postgres via Prisma.

  4. Server components, API routes, and middleware read subscription status from Prisma—not from the client.

That is the standard way tosync a Stripe subscription to your database in Next.jswhen you use Prisma as the ORM.

Prerequisites

  • Next.js 14+ with theApp Router(this tutorial applies to Next.js 16 as well).

  • A Stripe account intest modefor development.

  • Prisma with PostgreSQL (or any DB Prisma supports).

  • Tables roughly like:User(withstripeCustomerId),Subscription(with uniquestripeSubscriptionId), and optionallyStripeEventfor idempotency.


Step 1: Model subscriptions in Prisma

Store what your app needs to gate features not every Stripe field.

ExampleSubscriptionmodel:

enum SubscriptionStatus {
  ACTIVE
  CANCELED
  INCOMPLETE
  INCOMPLETE_EXPIRED
  PAST_DUE
  TRIALING
  UNPAID
  PAUSED
}

model Subscription {
  id                   String             @id @default(cuid())
  userId               String
  stripeSubscriptionId String             @unique
  stripePriceId        String
  status               SubscriptionStatus
  currentPeriodStart   DateTime?
  currentPeriodEnd     DateTime?
  cancelAtPeriodEnd    Boolean            @default(false)
  createdAt            DateTime           @default(now())
  updatedAt            DateTime           @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

Optional but recommended for production:

model StripeEvent {
  id        String   @id @default(cuid())
  eventId   String   @unique
  type      String
  createdAt DateTime @default(now())
}

Recordingevent.idlets you ignore duplicate deliveries when Stripe retries webhooks.


Step 2: Create the webhook route (App Router)

Add a route handler, for example:

app/api/stripe/webhook/route.ts

Read the raw body—neverrequest.json()first

Stripe signs therawrequest body. If Next.js parses JSON and you re-serialize it, signature verification fails with opaque errors.

import { NextResponse } from "next/server";
import { headers } from "next/headers";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
  const body = await request.text();
  const signature = (await headers()).get("stripe-signature");

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

  let event: Stripe.Event;

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

  // Handle event types (next section)
  return NextResponse.json({ received: true });
}

Production tip:Exclude this path from any middleware that reads or transforms the request body.


Step 3: Map Stripe status to your enum

Stripe subscription statuses are lowercase strings (active,past_due, …). Your app enum is easier to query if you normalize once:

import type { SubscriptionStatus } from "@prisma/client";

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";
}

Always deriveaccessfromstatus(and optionallycurrentPeriodEnd), not from “user clicked subscribe once.”


Step 4: Upsert subscription rows

UsestripeSubscriptionIdas the natural unique key. On each relevant webhook, upsert period dates, price id, status, andcancelAtPeriodEnd:

async function upsertSubscription(
  userId: string,
  subscription: Stripe.Subscription,
) {
  const priceId = subscription.items.data[0]?.price.id;
  if (!priceId) 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: {
      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,
    },
  });
}

This is the core ofStripe subscription sync with Prisma: idempotent writes keyed by Stripe’s subscription id.


Step 5: Handle the events that matter for SaaS

Register these in the Stripe Dashboard (and listen locally with the CLI):

Event Why it matterscheckout.session.completedFirst subscription after Checkout; link session → usercustomer.subscription.updatedPlan changes, renewals, past_due, cancel at period endcustomer.subscription.deletedSubscription ended; downgrade accessinvoice.paidConfirms paid period; good backup sync point

Example handler outline:

switch (event.type) {
  case "checkout.session.completed": {
    const session = event.data.object as Stripe.Checkout.Session;
    const userId = session.metadata?.userId;

    if (userId && session.subscription) {
      const subscription = await stripe.subscriptions.retrieve(
        session.subscription as string,
      );
      await upsertSubscription(userId, subscription);
    }
    break;
  }
  case "customer.subscription.updated":
  case "customer.subscription.deleted": {
    const subscription = event.data.object as Stripe.Subscription;
    const customerId = subscription.customer as string;
    const user = await db.user.findFirst({
      where: { stripeCustomerId: customerId },
    });
    if (user) await upsertSubscription(user.id, subscription);
    break;
  }
  case "invoice.paid": {
    const invoice = event.data.object as Stripe.Invoice;
    const subscriptionId =
      typeof invoice.subscription === "string"
        ? invoice.subscription
        : invoice.subscription?.id;

    if (subscriptionId) {
      const subscription = await stripe.subscriptions.retrieve(subscriptionId);
      const customerId = subscription.customer as string;
      const user = await db.user.findFirst({
        where: { stripeCustomerId: customerId },
      });
      if (user) await upsertSubscription(user.id, subscription);
    }
    break;
  }
}

PassuserIdfrom Checkout

When you create the Checkout session, set metadata:

metadata: { userId: user.id },

Without that,checkout.session.completedcannot tie the subscription to a user unless you only ever look up bystripeCustomerIdafter the customer is created.


Step 6: Make webhooks idempotent

Stripe guaranteesat-least-oncedelivery. The samecheckout.session.completedcan arrive twice; without idempotency you might run side effects twice.

Pattern:

  1. Before handling, check ifevent.idexists inStripeEvent.

  2. If yes, return200immediately.

  3. If no, insertevent.id, then run 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 },
});

Return200after successful processing so Stripe stops retrying. Return400only for bad signatures—not for business-logic bugs you need to fix and replay from the Dashboard.


Step 7: Test locally with Stripe CLI

Install theStripe CLI, then:

stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook

Copy the webhook signing secret (whsec_...) into.envasSTRIPE_WEBHOOK_SECRET. Use adifferentsecret in production when you register the live endpoint.

Run through Checkout in test mode with card4242 4242 4242 4242. Watch CLI logs for events and confirm rows appear in Prisma Studio.


Step 8: Production checklist

  1. Webhook URL:https://yourdomain.com/api/stripe/webhook

  2. Events:Same four as above (add others only if you handle them).

  3. Env:STRIPE_WEBHOOK_SECRETfrom the Dashboard endpoint—not the CLI secret.

  4. Middleware:Do not run auth or body parsers on the webhook route.

  5. Reconciliation:Optionally cron-fetch subscriptions for support cases; webhooks remain the primary path.

After sync works, wireplan gatingin server code: readSubscriptionfor the logged-in user before enabling Pro features (analytics, higher limits, teams, etc.).


Common failures (and fixes)

“Webhook signature verification failed”

  • You calledrequest.json()beforeconstructEvent.

  • WrongSTRIPE_WEBHOOK_SECRET(CLI vs production mix-up).

  • Middleware consumed the body stream.

Fix:Onlyawait request.text(), verify, then parse objects fromevent.data.object.

Dashboard still shows free after payment

  • Checkout session missingmetadata.userId.

  • Handler not registered forcheckout.session.completed.

  • Handler errors before upsert (check logs; Stripe retries on 5xx).

User keeps Pro after cancel

  • You only handleupdated, notdeleted.

  • You gate on “has a row” instead ofstatus === ACTIVE(and valid period).

Fix:TreatCANCELEDand expired periods as no access.

Duplicate credits or emails

  • Missing idempotency onevent.idor on side effects keyed by subscription id.


Skip the plumbing: ship with a production-ready kit

Building Checkout, the customer portal, webhook verification, Prisma sync, plan gates, and admin tooling from scratch is weeks of work—and most bugs show up only after real payments and retries.

AI SaaS Starter Kit fromSkryptixincludes:

  • Stripe Checkout and customer portal routes

  • Webhook handler with signature verification and subscription upserts

  • Auth.js, dashboard, billing UI, and plan-gated features

  • Docs for Stripe, auth, and Vercel deploy

AI SaaS Starter KitAI SaaS Starter KitProduction-ready SaaS boilerplate with Next.js 16, Auth.js, Stripe subscriptions, Prisma, dashboard, teams, admin CMS, and AI-ready API hooks.$29

FAQ

Why do I needrequest.text()for Stripe webhooks in Next.js?

Stripe’s signature is computed over the exact raw bytes of the POST body. The App Router does not give you a “raw body” helper—you read the stream once withrequest.text()and pass that string tostripe.webhooks.constructEvent. Parsing JSON first changes the bytes and breaks verification.

Which Stripe webhook events should a SaaS app listen for?

At minimum:checkout.session.completed,customer.subscription.updated,customer.subscription.deleted, andinvoice.paid. That covers new subscriptions, plan changes, cancellations, and successful renewals.

How do I test Stripe webhooks on localhost?

Usestripe listen --forward-to localhost:3000/api/stripe/webhookand put the CLI’swhsec_secret inSTRIPE_WEBHOOK_SECRETwhile developing. Trigger real flows via test-mode Checkout orstripe triggerfor specific event types.

How do I prevent duplicate webhook processing?

Store Stripe’sevent.idin a table with a unique constraint before running handlers. If the event was already processed, return HTTP 200 without re-running business logic.

Should my app call the Stripe API on every page load?

No. Sync via webhooks (and optional periodic reconciliation). Read subscription state from your database on each request so pages stay fast and work when Stripe has a slow moment.