Dev Pattern Labs logo
Dev Pattern Labs Blog
Automation 10 min readOct 16, 2026

Stripe Webhooks in Next.js 15: Payment Event Handling

Integrate Stripe webhooks with Next.js 15 App Router. Learn stripe.webhooks.constructEvent, raw body handling, payment event processing, fulfillment logic, and testing with the Stripe CLI.

Dev Pattern Labs Author

Dev Pattern Labs

Peer-reviewed technical tutorial · Production tested

Payment processing dashboard showing Stripe integration
Executive Summary & Key Insight

Integrate Stripe webhooks with Next.js 15 App Router. Learn stripe.webhooks.constructEvent, raw body handling, payment event processing, fulfillment logic, and testing with the Stripe CLI. This practical guide covers step-by-step setup, real-world code snippets, security hardening, and common production edge cases.

Advertisement

Stripe webhooks are the backbone of any reliable payment integration. Rather than polling the Stripe API to discover whether a payment succeeded, a subscription renewed, or a refund was issued, webhooks let Stripe push real-time event notifications directly to your server the moment something happens. This event-driven model is faster, cheaper to operate, and far more resilient than polling loops.

Next.js 15 App Router introduces a subtlety that trips up most developers: it streams request bodies instead of buffering them, which breaks Stripe's cryptographic signature verification out of the box. Getting webhooks right in this environment means understanding exactly how stripe.webhooks.constructEvent works, why raw bytes matter, and how to structure your route handler so it is both secure and maintainable.

In this guide you will build a production-ready Stripe webhook endpoint from the ground up. You will handle the three most commercially important event types — checkout.session.completed, payment_intent.succeeded, and the subscription lifecycle events — wire up a fulfillment layer that talks to your database, and use the Stripe CLI to drive the entire flow locally without touching a real card.


Prerequisites

Before diving in, make sure you have the following ready:

  • Next.js 15 project using the App Router (app/ directory)
  • Node.js 18+ (required for the native ReadableStream APIs used below)
  • A Stripe account — free tier is fine for testing
  • Stripe CLI installed locally (brew install stripe/stripe-cli/stripe on macOS, or the Windows installer from the Stripe docs)
  • Basic familiarity with TypeScript and async/await patterns
  • A database client (examples use Prisma, but the pattern is database-agnostic)

1. Setting Up Stripe Webhooks in the Dashboard

Log into the Stripe Dashboard and navigate to Developers → Webhooks. Click Add endpoint and fill in:

  • Endpoint URL: https://yourdomain.com/api/webhooks/stripe
  • Events to listen for: Select the events your application needs (see the table later in this article)

Once saved, Stripe generates a Webhook Signing Secret that looks like whsec_.... Copy it immediately — you will only see it once. Add it to your environment variables:

# .env.local
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...

Never commit these values to version control. Use .env.local for local development and your hosting platform's secret manager (Vercel Environment Variables, AWS Secrets Manager, etc.) for production.


2. Installing the Stripe SDK and Configuration

Install the official Stripe Node.js library and its TypeScript types:

npm install stripe
# Types are bundled with the package — no @types/stripe needed

Create a singleton Stripe client so you do not instantiate a new connection on every request:

// lib/stripe.ts
import Stripe from "stripe";

if (!process.env.STRIPE_SECRET_KEY) {
  throw new Error("STRIPE_SECRET_KEY environment variable is not set");
}

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: "2024-06-20",
  typescript: true,
});

Exporting a singleton here ensures that Next.js module caching keeps a single authenticated Stripe instance across hot reloads in development and across serverless function invocations that share the same container in production.


3. The Raw Body Requirement in Next.js App Router

Stripe's constructEvent method verifies a HMAC-SHA256 signature computed over the raw request body bytes. If your framework parses the body first — converting it to a JavaScript object — the byte sequence changes and verification always fails with a StripeSignatureVerificationError.

In Next.js 15 App Router you must read the raw ArrayBuffer from the request and convert it to a Buffer before doing anything else with the body:

// app/api/webhooks/stripe/route.ts  (excerpt — full version below)
export const runtime = "nodejs"; // Ensures Node.js Buffer is available

export async function POST(request: Request) {
  const rawBody = await request.arrayBuffer();
  const body = Buffer.from(rawBody);
  // ✅ body is now raw bytes — safe to pass to constructEvent
}

Do not call request.json() or request.text() before passing the body to Stripe. Also add the export const runtime = "nodejs" directive to the route file — the Edge runtime does not have Node.js Buffer and will fail at runtime.


4. Verifying Webhooks with stripe.webhooks.constructEvent

With the raw buffer in hand, grab the Stripe-Signature header and call constructEvent. Wrap it in a try/catch — an invalid signature means the request is not from Stripe and should be rejected with a 400:

// app/api/webhooks/stripe/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import type Stripe from "stripe";

export const runtime = "nodejs";

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

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

  if (!signature) {
    return NextResponse.json({ error: "Missing stripe-signature header" }, { status: 400 });
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    console.error(`⚠️  Webhook signature verification failed: ${message}`);
    return NextResponse.json({ error: `Webhook Error: ${message}` }, { status: 400 });
  }

  // Route the verified event to the correct handler
  try {
    await handleStripeEvent(event);
  } catch (err) {
    console.error("Webhook handler error:", err);
    return NextResponse.json({ error: "Internal server error" }, { status: 500 });
  }

  return NextResponse.json({ received: true }, { status: 200 });
}

Always return 200 promptly — Stripe retries any endpoint that does not respond within 30 seconds. Keep fulfillment logic fast or dispatch it to a background queue.


5. Handling checkout.session.completed

This event fires when a customer completes the Stripe-hosted checkout page. It is the most common trigger for granting access to a product:

// lib/webhook-handlers.ts
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db"; // Your Prisma or DB client
import type Stripe from "stripe";

export async function handleCheckoutSessionCompleted(
  session: Stripe.Checkout.Session
): Promise<void> {
  // Expand line_items if not already included
  const fullSession = await stripe.checkout.sessions.retrieve(session.id, {
    expand: ["line_items", "customer"],
  });

  const customerId = fullSession.customer as string;
  const customerEmail =
    typeof fullSession.customer === "object"
      ? (fullSession.customer as Stripe.Customer).email
      : fullSession.customer_details?.email;

  if (!customerEmail) {
    throw new Error(`No customer email on session ${session.id}`);
  }

  // Upsert user and record the purchase
  await db.user.upsert({
    where: { email: customerEmail },
    create: {
      email: customerEmail,
      stripeCustomerId: customerId,
      purchases: {
        create: {
          stripeSessionId: session.id,
          amount: session.amount_total ?? 0,
          currency: session.currency ?? "usd",
          status: "COMPLETED",
        },
      },
    },
    update: {
      stripeCustomerId: customerId,
      purchases: {
        create: {
          stripeSessionId: session.id,
          amount: session.amount_total ?? 0,
          currency: session.currency ?? "usd",
          status: "COMPLETED",
        },
      },
    },
  });

  console.log(`✅ Checkout completed for ${customerEmail}`);
}

6. Handling payment_intent.succeeded

payment_intent.succeeded fires for every successful charge, including those outside of Checkout. Use it when you build a custom payment flow with Elements:

export async function handlePaymentIntentSucceeded(
  paymentIntent: Stripe.PaymentIntent
): Promise<void> {
  const { id, amount, currency, metadata } = paymentIntent;
  const userId = metadata?.userId;

  if (!userId) {
    // Metadata not set — nothing to fulfill; log and move on
    console.warn(`PaymentIntent ${id} has no userId in metadata`);
    return;
  }

  await db.payment.upsert({
    where: { stripePaymentIntentId: id },
    create: {
      stripePaymentIntentId: id,
      userId,
      amount,
      currency,
      status: "SUCCEEDED",
    },
    update: { status: "SUCCEEDED" },
  });

  // Trigger downstream fulfillment (email, license key, etc.)
  await fulfillOrder(userId, id);
}

The metadata object is your bridge between the payment intent and your application's domain. Always attach userId (or orderId) when you create the intent on your server.


7. Processing Subscription Events

Subscription management requires tracking three events at minimum:

export async function handleSubscriptionEvent(
  event: Stripe.Event
): Promise<void> {
  const subscription = event.data.object as Stripe.Subscription;
  const customerId = subscription.customer as string;
  const status = subscription.status; // active | past_due | canceled | etc.
  const currentPeriodEnd = new Date(subscription.current_period_end * 1000);

  await db.subscription.upsert({
    where: { stripeSubscriptionId: subscription.id },
    create: {
      stripeSubscriptionId: subscription.id,
      stripeCustomerId: customerId,
      status,
      currentPeriodEnd,
      priceId: subscription.items.data[0]?.price.id ?? "",
    },
    update: {
      status,
      currentPeriodEnd,
      priceId: subscription.items.data[0]?.price.id ?? "",
    },
  });

  console.log(
    `📦 Subscription ${subscription.id} → ${status} (customer: ${customerId})`
  );
}

Listen to customer.subscription.created, customer.subscription.updated, and customer.subscription.deleted and route all three to this single handler — the logic is identical because the upsert captures the current status regardless of which transition occurred.


8. Implementing Fulfillment Logic

Wire all the handlers together in a central dispatcher:

// lib/webhook-handlers.ts (continued)
import type Stripe from "stripe";
import {
  handleCheckoutSessionCompleted,
  handlePaymentIntentSucceeded,
  handleSubscriptionEvent,
} from "./handlers";

export async function handleStripeEvent(event: Stripe.Event): Promise<void> {
  switch (event.type) {
    case "checkout.session.completed":
      await handleCheckoutSessionCompleted(
        event.data.object as Stripe.Checkout.Session
      );
      break;

    case "payment_intent.succeeded":
      await handlePaymentIntentSucceeded(
        event.data.object as Stripe.PaymentIntent
      );
      break;

    case "customer.subscription.created":
    case "customer.subscription.updated":
    case "customer.subscription.deleted":
      await handleSubscriptionEvent(event);
      break;

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

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

async function handleInvoicePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
  const customerId = invoice.customer as string;
  // Notify customer, suspend access after grace period, etc.
  await db.subscription.updateMany({
    where: { stripeCustomerId: customerId },
    data: { status: "past_due" },
  });
  console.warn(`⚠️ Invoice payment failed for customer ${customerId}`);
}

async function fulfillOrder(userId: string, paymentIntentId: string): Promise<void> {
  // Example: send confirmation email, generate license, unlock course access
  await db.user.update({
    where: { id: userId },
    data: { accessGranted: true },
  });
  console.log(`🎉 Fulfilled order for user ${userId} (PI: ${paymentIntentId})`);
}

9. Testing Locally with the Stripe CLI

The Stripe CLI can forward live webhook events to your local Next.js dev server without exposing a public URL:

# Step 1: Authenticate the CLI (one-time)
stripe login

# Step 2: Start your Next.js dev server
npm run dev

# Step 3: In a second terminal, start forwarding
stripe listen --forward-to localhost:3000/api/webhooks/stripe

The CLI prints a local webhook signing secret (whsec_test_...) at startup. Replace your STRIPE_WEBHOOK_SECRET in .env.local with this test secret while developing — Stripe signs the forwarded events with it.

Trigger specific events to test your handlers:

# Trigger a successful checkout
stripe trigger checkout.session.completed

# Trigger a successful payment intent
stripe trigger payment_intent.succeeded

# Trigger a subscription creation
stripe trigger customer.subscription.created

# Trigger a failed invoice payment
stripe trigger invoice.payment_failed

Each command fires a real event through your local handler with a realistic mock payload. You can inspect every request and response in the CLI output, and replay failed deliveries with stripe events resend <event_id>.


Important Stripe Webhook Events Reference

Event TypeTriggerCommon Use Case
checkout.session.completedHosted Checkout completedGrant product access, send receipt
checkout.session.expiredCheckout page abandonedClean up pending records
payment_intent.succeededPayment charged successfullyCustom Elements fulfillment
payment_intent.payment_failedCharge declined or erroredNotify customer, retry logic
customer.subscription.createdNew subscription startedActivate subscription tier
customer.subscription.updatedPlan changed, trial endedSync plan changes to DB
customer.subscription.deletedSubscription canceledRevoke access, offboard
invoice.payment_succeededRecurring invoice paidExtend subscription period
invoice.payment_failedRecurring invoice failedGrace period, dunning email
customer.updatedCustomer profile changedSync billing details
charge.refundedRefund issuedRevoke access, notify user
charge.dispute.createdChargeback openedFlag account, gather evidence

Conclusion

Stripe webhooks in Next.js 15 App Router boil down to four non-negotiable rules:

  1. Read the raw ArrayBuffer — never let the framework parse the body before Stripe inspects it.
  2. Set export const runtime = "nodejs" — Edge workers lack the Buffer API that constructEvent relies on.
  3. Always return 200 quickly — delegate slow work to a background queue if needed; Stripe retries after timeouts.
  4. Verify every single request — reject anything that fails constructEvent with a 400; do not process unverified payloads.

With the singleton Stripe client, the raw-body reader, a type-safe event dispatcher, and the Stripe CLI wired into your local dev loop, you now have a production-grade foundation for handling every payment event your application will ever need. From one-time purchases to subscription billing to dispute management, the pattern scales without modification — just add a new case to the switch and wire up the handler.

Tags:#Stripe#Webhooks#Next.js#Payments#Automation
Dev Pattern Labs Engineering Team

Written by Dev Pattern LabsCore Engineering

Dev Pattern Labs is an engineering lab and digital consultancy specializing in custom Next.js web applications, WooCommerce REST API integrations, and workflow automation for growing businesses.

Advertisement

Recommended Companion Articles