Back to all articles
TechnicalFeature

Stripe Webhooks in Next.js — The Complete Guide

Stripe webhooks in Next.js: signature verification, handling checkout.session.completed, subscription events, idempotency, and local testing with Stripe CLI.

Stripe Webhooks in Next.js — The Complete Guide

A customer paid $49 for your SaaS. Stripe processed the charge. The money landed in your account. But the customer never got access.

They email support. You check Stripe — payment succeeded. You check your database — no record of the purchase. The customer is frustrated, you're embarrassed, and you just burned trust that took weeks to build.

This happens more often than you'd think. The root cause is almost always the same: the app relied on the client-side redirect after checkout instead of webhooks. The customer closed the tab before the success page loaded, or their browser crashed, or they had a flaky connection. No webhook handler meant no reliable way to fulfill the order.

Key Takeaways

  • Never trust the success page — webhooks are the only reliable way to confirm payments
  • Always verify webhook signatures — anyone can POST to your endpoint
  • Handle idempotency — Stripe can send the same event multiple times
  • Use the Stripe CLI for local development — it saves hours of debugging
  • Process only the events you need — ignore everything else

How Stripe Webhooks Work

When something happens in Stripe — a payment succeeds, a subscription renews, a charge fails — Stripe sends an HTTP POST request to a URL you configure. That POST contains a JSON payload describing the event.

Your job is to:

  1. Receive the request
  2. Verify it actually came from Stripe (signature verification)
  3. Parse the event type
  4. Do something useful (grant access, update the database, send an email)
  5. Return a 200 status code so Stripe knows you received it

If you don't return 200, Stripe retries. In production, it retries for up to 3 days with exponential backoff. In test mode, it retries 3 times over a few hours.

Setting Up the Webhook Route Handler

In Next.js App Router, webhooks live in a route handler. Create the file at app/api/webhooks/stripe/route.ts.

The critical detail: you must read the request body as raw text, not parsed JSON. Stripe's signature verification requires the raw body exactly as it was sent. If Next.js parses it first, the signature check fails.

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

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

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: Request) {
  const body = await req.text();
  const headersList = await headers();
  const signature = headersList.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) {
    console.error("Webhook signature verification failed:", err);
    return NextResponse.json(
      { error: "Invalid signature" },
      { status: 400 }
    );
  }

  // Handle the event
  try {
    switch (event.type) {
      case "checkout.session.completed":
        await handleCheckoutCompleted(event.data.object);
        break;
      case "customer.subscription.updated":
        await handleSubscriptionUpdated(event.data.object);
        break;
      case "customer.subscription.deleted":
        await handleSubscriptionDeleted(event.data.object);
        break;
      case "invoice.payment_failed":
        await handlePaymentFailed(event.data.object);
        break;
      default:
        console.log(`Unhandled event type: ${event.type}`);
    }
  } catch (err) {
    console.error(`Error processing ${event.type}:`, err);
    return NextResponse.json(
      { error: "Webhook handler failed" },
      { status: 500 }
    );
  }

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

A few things worth noting here. The req.text() call gets the raw body as a string — this is essential. Using req.json() would parse the body and break signature verification. The stripe.webhooks.constructEvent call does three things: verifies the signature, parses the JSON, and returns a typed event object. If the signature doesn't match, it throws.

Webhook Signature Verification

This part is non-negotiable. Without signature verification, anyone who discovers your webhook URL can send fake events. Imagine someone posting a fake checkout.session.completed event to grant themselves a free subscription.

Stripe signs every webhook with a secret unique to your endpoint. When you create a webhook endpoint in the Stripe dashboard (or via CLI), you get a whsec_... string. That's your STRIPE_WEBHOOK_SECRET.

The signature is sent in the stripe-signature header and looks like this:

t=1614556828,v1=abc123...,v0=def456...

It contains a timestamp and one or more signatures. stripe.webhooks.constructEvent checks the v1 signature against a hash of the timestamp + raw body + your secret. If they match, the event is legit.

This is similar to how we protect other endpoints. If you're interested in the broader security picture, check out how OmniKit handles security attacks like CSRF and XSS out of the box.

Handling checkout.session.completed

This is the most important event for one-time payments and initial subscription creation. When a customer finishes Checkout, Stripe fires this event.

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

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

  // Update the user's record in your database
  await db
    .update(users)
    .set({
      stripeCustomerId: customerId,
      stripeSubscriptionId: subscriptionId,
      plan: "pro",
      subscriptionStatus: "active",
    })
    .where(eq(users.id, userId));

  // Send welcome email
  await sendEmail({
    to: session.customer_details?.email!,
    template: "welcome-pro",
  });
}

The key here is session.metadata?.userId. When you create the Checkout Session, always pass the user's ID in metadata:

const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  line_items: [{ price: priceId, quantity: 1 }],
  success_url: `${baseUrl}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${baseUrl}/pricing`,
  metadata: {
    userId: user.id, // This is how you link Stripe to your user
  },
});

Without this, you have no way to connect the payment to the user who made it.

Handling Subscription Lifecycle Events

Subscriptions are more complex than one-time payments. A customer's subscription goes through multiple state changes: active, past due, canceled, paused. You need to handle these transitions.

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

  // Find the user by their Stripe customer ID
  const user = await db.query.users.findFirst({
    where: eq(users.stripeCustomerId, customerId),
  });

  if (!user) {
    console.error(`No user found for customer ${customerId}`);
    return;
  }

  const status = subscription.status;
  const priceId = subscription.items.data[0]?.price.id;

  // Map the Stripe price ID to your plan name
  const plan = getPlanFromPriceId(priceId);

  await db
    .update(users)
    .set({
      plan,
      subscriptionStatus: status,
      currentPeriodEnd: new Date(
        subscription.current_period_end * 1000
      ),
    })
    .where(eq(users.id, user.id));
}

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

  await db
    .update(users)
    .set({
      plan: "free",
      subscriptionStatus: "canceled",
      stripeSubscriptionId: null,
    })
    .where(eq(users.stripeCustomerId, customerId));
}

The customer.subscription.updated event fires frequently — plan changes, payment method updates, trial endings. Your handler should be resilient enough to handle all these without breaking.

Handling Failed Payments

When a subscription payment fails (expired card, insufficient funds), Stripe fires invoice.payment_failed. This is your chance to notify the user before they lose access.

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

  const user = await db.query.users.findFirst({
    where: eq(users.stripeCustomerId, customerId),
  });

  if (!user) return;

  // Update status to past_due
  await db
    .update(users)
    .set({ subscriptionStatus: "past_due" })
    .where(eq(users.id, user.id));

  // Notify the user
  await sendEmail({
    to: user.email,
    template: "payment-failed",
    data: {
      updatePaymentUrl: `${baseUrl}/dashboard/billing`,
      amount: (invoice.amount_due / 100).toFixed(2),
    },
  });
}

Stripe has built-in retry logic for failed payments (Smart Retries), but you should still notify the user so they can update their payment method.

Idempotency: Why It Matters

Stripe can send the same webhook event more than once. Network timeouts, retries, edge cases — it happens. If your handler isn't idempotent, you might grant access twice, send duplicate emails, or create duplicate records.

Two approaches to handle this:

Approach 1: Track Processed Event IDs

Store every processed event ID. Before processing, check if you've already handled it.

async function isEventProcessed(eventId: string): Promise<boolean> {
  const existing = await db.query.webhookEvents.findFirst({
    where: eq(webhookEvents.stripeEventId, eventId),
  });
  return !!existing;
}

async function markEventProcessed(eventId: string): Promise<void> {
  await db.insert(webhookEvents).values({
    stripeEventId: eventId,
    processedAt: new Date(),
  });
}

// In your webhook handler, before the switch statement:
if (await isEventProcessed(event.id)) {
  console.log(`Event ${event.id} already processed, skipping`);
  return NextResponse.json({ received: true });
}

// After successful processing:
await markEventProcessed(event.id);

Approach 2: Database Constraints

Use unique constraints to prevent duplicate records. If you try to insert a duplicate, the database rejects it.

// Your schema
export const subscriptions = pgTable("subscriptions", {
  id: text("id").primaryKey(),
  stripeSubscriptionId: text("stripe_subscription_id").unique(),
  // ...
});

// In your handler - use upsert
await db
  .insert(subscriptions)
  .values({
    stripeSubscriptionId: subscription.id,
    userId: user.id,
    status: subscription.status,
  })
  .onConflictDoUpdate({
    target: subscriptions.stripeSubscriptionId,
    set: { status: subscription.status },
  });

I prefer combining both approaches. Event ID tracking catches exact duplicates fast. Database constraints are the safety net for everything else.

Testing Locally with the Stripe CLI

Testing webhooks in development used to be painful. You'd deploy, configure the endpoint, make a payment, check logs, fix a bug, redeploy. The Stripe CLI changed everything.

Install it and log in:

# macOS
brew install stripe/stripe-cli/stripe

# Login
stripe login

Forward webhook events to your local dev server:

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

The CLI prints a webhook signing secret (whsec_...). Use this as your STRIPE_WEBHOOK_SECRET in .env.local. Making your environment variables type-safe helps catch misconfigurations before they cause silent failures.

Now trigger events:

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

# Trigger a payment intent flow
stripe trigger payment_intent.succeeded

You can also make real test payments through your Checkout flow, and the CLI will forward the resulting webhooks to your local server. This is the fastest feedback loop for webhook development.

Common Mistakes

1. Parsing the body as JSON before verification

// WRONG - this breaks signature verification
const body = await req.json();

// RIGHT - read as raw text
const body = await req.text();

2. Not returning 200 quickly enough

Stripe expects a response within 20 seconds. If your handler does heavy processing (sending emails, calling external APIs), do the critical database update first, then queue the rest.

// Do the critical work synchronously
await db.update(users).set({ plan: "pro" }).where(eq(users.id, userId));

// Queue non-critical work (emails, analytics, etc.)
// Use a background job system for this
await queueJob("send-welcome-email", { userId });

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

For heavy processing, consider using a background job system to handle work asynchronously.

3. Not handling all subscription states

A subscription can be active, past_due, canceled, unpaid, trialing, paused, or incomplete. If your code only handles active and canceled, users in past_due state might keep full access indefinitely.

4. Missing the metadata link

Always pass userId (or some user identifier) in the Checkout Session metadata. Without it, there's no way to connect the Stripe payment to your user when the webhook arrives.

Production Checklist

Before going live, verify everything:

  • Webhook endpoint registered in Stripe Dashboard (not just CLI)
  • STRIPE_WEBHOOK_SECRET set to the production webhook secret (different from CLI secret)
  • Signature verification is working (test with an invalid signature)
  • Idempotency handling in place (send the same event twice, verify no duplicates)
  • All critical subscription states handled (active, past_due, canceled, unpaid)
  • Error logging is comprehensive (you need to debug issues at 2 AM)
  • Endpoint responds within 20 seconds
  • Rate limiting is configured on your API routes to prevent abuse

How OmniKit Handles All of This

Every piece of code in this guide — the route handler, signature verification, idempotency, subscription lifecycle management — is already built into OmniKit.

When you spin up an OmniKit project, the Stripe webhook handler is preconfigured. The authentication module integrates directly with Stripe customer IDs, so user sessions and billing state stay in sync. Subscription status changes automatically reflect in the user's access level.

The webhook handler includes idempotency protection, proper error handling, and typed event processing. The Stripe CLI integration is documented in the setup guide, and environment variables are validated at build time so you never deploy with a missing STRIPE_WEBHOOK_SECRET.

Building all this from scratch takes days — and that's if you get it right the first time. Most teams don't. They discover edge cases in production, with real customers affected. Understanding the true cost of building SaaS infrastructure from scratch makes the value of a battle-tested boilerplate obvious.

OmniKit ships it all, tested and ready. You focus on your product, not on re-implementing payment plumbing for the hundredth time.


Got questions about Stripe integration or webhook handling? Reach out at raman@omnikit.dev or join the OmniKit Discord community — we're happy to help.