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

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:
User clicks Subscribe → Stripe Checkout session.
User pays → Stripe sends webhooks to your app.
Your handler verifies the signature, processes the event, and upserts a
Subscriptionrow in Postgres via Prisma.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(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.
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:
Before handling, check if
event.idexists inStripeEvent.If yes, return
200immediately.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
Webhook URL:
https://yourdomain.com/api/stripe/webhookEvents: Same four as above (add others only if you handle them).
Env:
STRIPE_WEBHOOK_SECRETfrom the Dashboard endpoint—not the CLI secret.Middleware: Do not run auth or body parsers on the webhook route.
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()beforeconstructEvent.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, notdeleted.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.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 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
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.
