Stripe integration guide · 2026

ShipAI.today payment reference

Stripe webhooks in Next.js: complete setup guide.

Stripe webhooks let your backend react to payment events — subscriptions activated, payments failed, plans changed. This guide covers creating the webhook route handler in Next.js 15 App Router, verifying signatures, handling the key SaaS events, and testing locally with the Stripe CLI.

Next.js 15 App Router route handlerSignature verification with stripe.webhooks.constructEvent()Local testing via Stripe CLI

How it works

What Stripe webhooks do and why you need them

Stripe cannot update your database directly — webhooks are how Stripe calls your server when something happens.

The webhook flow

1
User action: User completes Stripe Checkout or updates their subscription in your app.
2
Stripe fires event: Stripe creates an Event object (e.g., checkout.session.completed) and POSTs the JSON payload to your webhook URL.
3
Your endpoint receives it: Your Next.js route handler at /api/webhook receives the POST request with the Stripe-Signature header.
4
Verify signature: Call stripe.webhooks.constructEvent() with the raw body, the Stripe-Signature header, and your webhook secret. This proves the event is from Stripe.
5
Handle the event: Switch on event.type. Update your database, provision access, send emails, etc.
6
Return 200: Return a 200 OK immediately. If you return a non-2xx status, Stripe will retry the webhook with exponential backoff for up to 3 days.

Why signature verification is mandatory

Your webhook endpoint is a public HTTPS URL. Without verifying the signature, any attacker could POST a fake checkout.session.completed event to grant themselves free access. Stripe signs every request with your webhook secret using HMAC-SHA256. Always verify — never skip it.

Implementation

Stripe webhook route handler for Next.js 15

The complete route handler with signature verification and essential event handling.

Step 1 — Install the Stripe SDK

bun add stripe
# or: npm install stripe

Step 2 — Environment variables (.env.local)

STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...   # from Stripe CLI (local) or Dashboard (production)

Step 3 — lib/stripe.ts (shared Stripe client)

import Stripe from "stripe";

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

Step 4 — app/api/webhook/route.ts (full implementation)

import { stripe } from "@/lib/stripe";
import { headers } from "next/headers";
import type { NextRequest } from "next/server";

export async function POST(req: NextRequest) {
  const body = await req.text(); // raw body required for signature verification
  const headersList = await headers();
  const signature = headersList.get("Stripe-Signature");

  if (!signature) {
    return new Response("Missing Stripe-Signature header", { status: 400 });
  }

  let event: Stripe.Event;

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

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

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

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

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

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

      default:
        // Unhandled event type — log and ignore
        console.log(`Unhandled Stripe event: ${event.type}`);
    }
  } catch (err) {
    console.error("Error handling webhook event:", err);
    // Return 500 so Stripe retries the event
    return new Response("Internal error", { status: 500 });
  }

  // Always return 200 to acknowledge receipt
  return new Response(JSON.stringify({ received: true }), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
}

async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  const { customer, subscription, metadata } = session;
  const userId = metadata?.userId; // you pass this when creating the Checkout Session

  if (!userId || !subscription) return;

  // Retrieve full subscription object
  const sub = await stripe.subscriptions.retrieve(subscription as string);

  // Update your DB: link user → Stripe customer, set plan, mark as active
  await db.user.update({
    where: { id: userId },
    data: {
      stripeCustomerId: customer as string,
      stripeSubscriptionId: sub.id,
      stripePriceId: sub.items.data[0].price.id,
      stripeCurrentPeriodEnd: new Date(sub.current_period_end * 1000),
      plan: "pro", // or derive from price ID
    },
  });
}

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

  await db.user.update({
    where: { stripeCustomerId: customerId },
    data: {
      stripePriceId: subscription.items.data[0].price.id,
      stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
      plan: subscription.status === "active" ? "pro" : "free",
    },
  });
}

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

  await db.user.update({
    where: { stripeCustomerId: customerId },
    data: {
      stripeSubscriptionId: null,
      plan: "free",
    },
  });
}

async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
  // Extend subscription period in DB
  const customerId = invoice.customer as string;
  const subscriptionId = invoice.subscription as string | null;
  if (!subscriptionId) return;

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

  await db.user.update({
    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 email to user to update payment method
  // Consider downgrading to free after grace period
  console.log(`Payment failed for customer ${customerId}`);
}

Next.js config

Important: disable body parsing for the webhook route

Next.js must receive the raw request body to verify the Stripe signature. The default JSON body parser breaks signature verification.

Why raw body matters

Stripe computes its signature over the exact raw bytes of the request body. If Next.js parses the JSON body first (which reformats it), the bytes change and constructEvent() throws a signature mismatch error. Using await req.text() in the App Router route handler reads the raw body string directly — the right approach.

App Router — use req.text() (recommended)

In Next.js 13+ App Router route handlers, simply call await req.text() instead of await req.json(). No config needed.

const body = await req.text(); // ✅ raw body for signature verification
// NOT: const body = await req.json(); // ❌ destroys signature

Pages Router — disable body parsing with config export

If you're on Pages Router (pages/api/webhook.ts), export a config object to disable Next.js's body parser:

// pages/api/webhook.ts
export const config = {
  api: {
    bodyParser: false, // required for Stripe signature verification
  },
};

Event reference

Key Stripe webhook events for a subscription SaaS

These are the events you need to handle for a complete billing integration.

EventWhen it firesWhat to doPriority
checkout.session.completedUser completes Stripe CheckoutProvision access, create subscription record in DB, send welcome emailCritical
customer.subscription.updatedPlan changed, seats added/removed, subscription period renewedUpdate plan in DB, adjust feature access gatesCritical
customer.subscription.deletedSubscription cancelled (at period end or immediately)Downgrade user to free tier, revoke pro accessCritical
invoice.payment_succeededSuccessful recurring payment (renewal)Extend current_period_end in DB, optionally send receipt emailHigh
invoice.payment_failedPayment method declined on recurring chargeSend email to update card, start grace period countdownHigh
customer.subscription.trial_will_end3 days before trial endsSend conversion email urging user to add payment methodMedium
customer.updatedCustomer email/name changedSync name/email changes to your user tableLow

Local testing

Testing Stripe webhooks locally with the Stripe CLI

The Stripe CLI creates a tunnel that forwards Stripe events to your local dev server.

Install and authenticate

# macOS
brew install stripe/stripe-cli/stripe

# Windows
scoop bucket add stripe https://github.com/stripe/scoop-stripe-cli.git
scoop install stripe

# Authenticate (opens browser)
stripe login

Start the webhook listener

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

The CLI prints your local webhook signing secret: > Ready! Your webhook signing secret is whsec_abc123...
Copy this value and add it to your .env.local as STRIPE_WEBHOOK_SECRET=whsec_abc123...

Trigger test events

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

# Trigger a subscription updated event
stripe trigger customer.subscription.updated

# Trigger a payment failure
stripe trigger invoice.payment_failed

# List all available event types
stripe trigger --help

Pro tip: replay events from the Stripe Dashboard

In the Stripe Dashboard → Developers → Webhooks → your endpoint → Events, you can click any past event and hit "Resend" to replay it to your endpoint. Useful for debugging specific real payloads without going through the full checkout flow again.

Production

Setting up Stripe webhooks in production

Registering your live webhook endpoint in the Stripe Dashboard.

1

Go to Stripe Dashboard → Developers → Webhooks

Click "Add endpoint". Enter your production URL, e.g., https://yourdomain.com/api/webhook.

2

Select events to listen for

Add: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_succeeded, invoice.payment_failed. You can add more later.

3

Copy the signing secret

After creating the endpoint, click "Signing secret" → "Reveal". Copy the whsec_... value and add it to your production environment as STRIPE_WEBHOOK_SECRET.

4

Deploy and test

Deploy your app. Stripe Dashboard shows delivery attempts, response codes, and event payloads. If you get a 400/500, check the Stripe Dashboard logs to see the exact error.

Troubleshooting

Common Stripe webhook errors and fixes

No signatures found matching the expected signature for payload

Cause: Wrong webhook secret, or body was parsed (not raw)

Fix: Use the correct STRIPE_WEBHOOK_SECRET (from Stripe CLI for local, from Dashboard for prod). Read body with req.text(), not req.json().

Timestamp outside the tolerance zone

Cause: System clock skew, or cached/buffered old request

Fix: The Stripe SDK rejects requests older than 300 seconds by default. Ensure server clock is synced. For testing, pass { clockSkew: Infinity } as the 4th arg to constructEvent() (dev only).

Webhook returns 404

Cause: Route handler not deployed, wrong URL, or wrong HTTP method

Fix: Stripe webhooks use POST. Make sure you export a POST function (not GET). In App Router: export async function POST(req) { ... }

Event fires but database not updated

Cause: Async handler throws but returns 200, so Stripe doesn't retry

Fix: Wrap handlers in try/catch. Return 500 on DB errors so Stripe retries. Return 200 only after successful processing.

Duplicate event handling

Cause: Stripe delivers at-least-once — same event may arrive multiple times

Fix: Use event.id as an idempotency key. Store processed event IDs in DB and check before processing: if (await isEventProcessed(event.id)) return 200.

Ready to ship

Stripe billing pre-wired in ShipAI.today

ShipAI.today ships with the complete Stripe billing integration — webhook handler, subscription management, portal, pro gating, and all five critical events handled. Skip the 2-day setup.