Sync Stripe Subscriptions to Prisma in Next.js (Webhooks)

September 14, 2026
Sync Stripe Subscriptions to Prisma in Next.js (Webhooks)

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



How to Sync Stripe Subscriptions to Your Database in Next.js (Prisma + Webhooks)

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 from your database. 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 to sync Stripe subscription state to Prisma using 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 clicks Subscribe → Stripe Checkout session.

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

  3. Your handler verifies the signature, processes the event, and upserts a Subscription row 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 to sync a Stripe subscription to your database in Next.js when you use Prisma as the ORM.

Prerequisites

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

  • A Stripe account in test mode for development.

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

  • Tables roughly like: User (with stripeCustomerId), Subscription (with unique stripeSubscriptionId), and optionally StripeEvent for idempotency.


Step 1: Model subscriptions in Prisma

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

Example Subscription model:

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())
}

Recording event.id lets 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 never request.json() first

Stripe signs the raw request 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 derive access from status (and optionally currentPeriodEnd), not from “user clicked subscribe once.”


Step 4: Upsert subscription rows

Use stripeSubscriptionId as the natural unique key. On each relevant webhook, upsert period dates, price id, status, and cancelAtPeriodEnd:

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 of Stripe 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 matters checkout.session.completed First subscription after Checkout; link session → user customer.subscription.updated Plan changes, renewals, past_due, cancel at period end customer.subscription.deleted Subscription ended; downgrade access invoice.paid Confirms 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;
  }
}

Pass userId from Checkout

When you create the Checkout session, set metadata:

metadata: { userId: user.id },

Without that, checkout.session.completed cannot tie the subscription to a user unless you only ever look up by stripeCustomerId after the customer is created.


Step 6: Make webhooks idempotent

Stripe guarantees at-least-once delivery. The same checkout.session.completed can arrive twice; without idempotency you might run side effects twice.

Pattern:

  1. Before handling, check if event.id exists in StripeEvent.

  2. If yes, return 200 immediately.

  3. If no, insert event.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 },
});

Return 200 after successful processing so Stripe stops retrying. Return 400 only 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 the Stripe CLI, then:

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

Copy the webhook signing secret (whsec_...) into .env as STRIPE_WEBHOOK_SECRET. Use a different secret in production when you register the live endpoint.

Run through Checkout in test mode with card 4242 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_SECRET from 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, wire plan gating in server code: read Subscription for the logged-in user before enabling Pro features (analytics, higher limits, teams, etc.).


Common failures (and fixes)

“Webhook signature verification failed”

  • You called request.json() before constructEvent.

  • Wrong STRIPE_WEBHOOK_SECRET (CLI vs production mix-up).

  • Middleware consumed the body stream.

Fix: Only await request.text(), verify, then parse objects from event.data.object.

Dashboard still shows free after payment

  • Checkout session missing metadata.userId.

  • Handler not registered for checkout.session.completed.

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

User keeps Pro after cancel

  • You only handle updated, not deleted.

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

Fix: Treat CANCELED and expired periods as no access.

Duplicate credits or emails

  • Missing idempotency on event.id or 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 from Skryptix.com includes:

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

FAQ

Why do I need request.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 with request.text() and pass that string to stripe.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, and invoice.paid. That covers new subscriptions, plan changes, cancellations, and successful renewals.

How do I test Stripe webhooks on localhost?

Use stripe listen --forward-to localhost:3000/api/stripe/webhook and put the CLI’s whsec_ secret in STRIPE_WEBHOOK_SECRET while developing. Trigger real flows via test-mode Checkout or stripe trigger for specific event types.

How do I prevent duplicate webhook processing?

Store Stripe’s event.id in 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.


Written by the team behind skryptix.com. We build Ai SaaS Starter Kit so founders can focus on product not auth and billing infrastructure.