← Back to blog
·7 min read·By Utkarsh Singh

Stripe Webhooks in Next.js: The Complete Guide to Getting It Right

Everything you need to know about handling Stripe webhooks in Next.js — subscription lifecycle, entitlement sync, local testing, and the mistakes that cost you money.

nextjsstripewebhookspaymentssaas

Stripe Webhooks in Next.js: The Complete Guide to Getting It Right

Stripe webhooks are where most SaaS payment integrations quietly fall apart. The checkout works. The user pays. But three days later you realize that cancelled subscriptions aren't being revoked, failed payments aren't being handled, and your entitlement sync is running on vibes.

This guide covers Stripe webhooks in Next.js properly — not just the happy path, but the full subscription lifecycle, how to sync entitlements reliably, and how to test everything locally before you ship.


Why Webhooks Are Hard to Get Right

The common mistake is treating webhooks as an afterthought. You wire up a checkout, test the happy path, and ship. Then:

Webhooks are Stripe's way of telling your server what happened. If you're not handling them correctly, you're flying blind.


The Events That Actually Matter

You don't need to handle every Stripe event — just the ones that affect your users' access:

| Event | What it means | What you should do | |---|---|---| | checkout.session.completed | User completed checkout | Create subscription in DB, grant access | | customer.subscription.updated | Plan changed, trial ended | Update entitlements in DB | | customer.subscription.deleted | Subscription cancelled | Revoke access | | invoice.payment_succeeded | Recurring payment worked | Extend subscription period | | invoice.payment_failed | Payment failed | Send email, flag account |


Step 1: Create the Webhook Route

In Next.js App Router, create app/api/webhooks/stripe/route.ts:

import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { prisma } from "@/lib/prisma";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-12-18",
});

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

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

  let event: Stripe.Event;

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

  try {
    switch (event.type) {
      case "checkout.session.completed":
        await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
        break;

      case "customer.subscription.updated":
        await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
        break;

      case "customer.subscription.deleted":
        await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
        break;

      case "invoice.payment_succeeded":
        await handlePaymentSucceeded(event.data.object as Stripe.Invoice);
        break;

      case "invoice.payment_failed":
        await handlePaymentFailed(event.data.object as Stripe.Invoice);
        break;

      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    return NextResponse.json({ received: true });
  } catch (err) {
    console.error("Webhook handler error:", err);
    return NextResponse.json({ error: "Webhook handler failed" }, { status: 500 });
  }
}

Critical: Use req.text() not req.json(). Stripe verifies the raw request body. If you parse it as JSON first, the signature verification will fail every time.


Step 2: Handle Each Event

async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  const customerId = session.customer as string;
  const subscriptionId = session.subscription as string;
  const userId = session.metadata?.userId;

  if (!userId) {
    console.error("No userId in checkout session metadata");
    return;
  }

  const subscription = await stripe.subscriptions.retrieve(subscriptionId);
  const priceId = subscription.items.data[0].price.id;

  await prisma.user.update({
    where: { id: userId },
    data: {
      stripeCustomerId: customerId,
      stripeSubscriptionId: subscriptionId,
      stripePriceId: priceId,
      stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
      plan: getPlanFromPriceId(priceId),
    },
  });
}

async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
  const customerId = subscription.customer as string;
  const priceId = subscription.items.data[0].price.id;

  await prisma.user.updateMany({
    where: { stripeCustomerId: customerId },
    data: {
      stripePriceId: priceId,
      stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
      plan: getPlanFromPriceId(priceId),
    },
  });
}

async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
  const customerId = subscription.customer as string;

  await prisma.user.updateMany({
    where: { stripeCustomerId: customerId },
    data: {
      stripeSubscriptionId: null,
      stripePriceId: null,
      stripeCurrentPeriodEnd: null,
      plan: "free",
    },
  });
}

async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
  const customerId = invoice.customer as string;
  const subscriptionId = invoice.subscription as string;

  if (!subscriptionId) return;

  const subscription = await stripe.subscriptions.retrieve(subscriptionId);

  await prisma.user.updateMany({
    where: { stripeCustomerId: customerId },
    data: {
      stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
    },
  });
}

async function handlePaymentFailed(invoice: Stripe.Invoice) {
  const customerId = invoice.customer as string;

  // Send a payment failed email here
  // Don't immediately revoke access — Stripe retries failed payments
  console.log(`Payment failed for customer: ${customerId}`);
}

function getPlanFromPriceId(priceId: string): string {
  const planMap: Record<string, string> = {
    [process.env.STRIPE_PRO_PRICE_ID!]: "pro",
    [process.env.STRIPE_STARTER_PRICE_ID!]: "starter",
  };
  return planMap[priceId] ?? "free";
}

Step 3: Add the Right Database Fields

Update your Prisma schema:

model User {
  // ... existing fields

  stripeCustomerId       String?   @unique
  stripeSubscriptionId   String?   @unique
  stripePriceId          String?
  stripeCurrentPeriodEnd DateTime?
  plan                   String    @default("free")
}

Run the migration:

npx prisma migrate dev --name add-stripe-fields

Step 4: Pass userId Through Checkout

This is the most commonly skipped step. When you create a Stripe checkout session, pass the user's ID in the metadata so your webhook can look them up:

const session = await stripe.checkout.sessions.create({
  customer_email: user.email,
  line_items: [{ price: priceId, quantity: 1 }],
  mode: "subscription",
  success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
  cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
  metadata: {
    userId: user.id, // 👈 this is what the webhook uses to find the right user
  },
});

Without this, your webhook receives a payment notification with no way to know which user paid.


Step 5: Test Webhooks Locally

Install the Stripe CLI:

brew install stripe/stripe-cli/stripe
stripe login

Forward webhooks to your local server:

stripe listen --forward-to localhost:3000/api/webhooks/stripe

The CLI will output a webhook signing secret — use this as STRIPE_WEBHOOK_SECRET in your .env.local during development. It's different from your production webhook secret.

Trigger test events:

stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed

Test every event in your switch statement before you ship.


Step 6: Protect Routes Based on Subscription

A utility function to check entitlements server-side:

import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";

export async function getUserPlan() {
  const session = await auth();
  if (!session?.user?.id) return null;

  const user = await prisma.user.findUnique({
    where: { id: session.user.id },
    select: {
      plan: true,
      stripeCurrentPeriodEnd: true,
    },
  });

  if (!user) return null;

  const isActive =
    user.stripeCurrentPeriodEnd &&
    user.stripeCurrentPeriodEnd > new Date();

  return {
    plan: isActive ? user.plan : "free",
    isActive,
  };
}

Use this in your Server Components and API routes to gate features by plan.


Common Mistakes That Cost You Money

Not verifying the webhook signature. Anyone can send a POST request to your webhook endpoint. Always verify with stripe.webhooks.constructEvent. The example above does this — don't skip it.

Parsing the body as JSON before verification. Covered above, but worth repeating. Raw body only.

Only handling checkout.session.completed. Subscriptions change. People cancel. Payments fail. Handle all the lifecycle events or your database will drift from Stripe's state.

Not idempotent handlers. Stripe may send the same event more than once. Make sure your database updates use upsert or updateMany rather than create, or you'll hit duplicate key errors.

Revoking access immediately on payment failure. Stripe retries failed payments. Give users a grace period before downgrading their account.


The Honest Assessment

A properly wired Stripe webhook integration takes about 6–9 hours the first time you do it — including the checkout flow, all the lifecycle events, entitlement syncing, and local testing. It's not glamorous work, but it's the difference between a SaaS that handles money correctly and one that leaks revenue or frustrates users.

If you'd rather spend that time building your actual product, ZeroDrag ships with the full Stripe integration pre-wired — checkout, webhooks, subscription lifecycle, entitlement sync, and protected routes. It's all done before you open the repo.


Written by Utkarsh Singh. Last updated March 2026.

Skip the setup. Ship your product.

ZeroDrag gives you auth, payments, database, email, and AI — pre-wired. Starter at $119, Pro at $169. One-time payment.

Get ZeroDrag →