Web Development
Next.jsStripeWebhooksApp RouterPaymentsReliabilitySaaS

Next.js + Stripe Webhooks in the App Router: A Reliability-First Implementation Guide

AO
Adrijan Omićević
·14 min read

# What You'll Build#

This guide shows a production-ready implementation of Next.js Stripe webhooks using the App Router. The focus is reliability first: strict signature verification, raw body handling, idempotency, safe retries, and strategies for event ordering.

You’ll also get copy-pasteable code, Stripe CLI local testing, and a troubleshooting checklist for common failure modes.

If you’re building subscriptions and SaaS billing, pair this with our deeper billing guide: Next.js Stripe subscriptions for SaaS billing.

# Prerequisites#

RequirementVersionNotes
Next.js14 or 15App Router assumed
Node.js18 or 20LTS recommended
Stripe accountAnyYou need a webhook signing secret
stripe npm package14+Use the official SDK
DatabasePostgres recommendedFor idempotency and state updates
Stripe CLILatestFor local webhook testing

You should also be comfortable with basic API routes and database writes.

ℹ️ Note: Webhooks are a security boundary. Treat them like a public endpoint and follow hardening best practices from our web application security checklist.

# Architecture: Reliability-First Webhook Design#

A webhook handler has two jobs: prove the request is from Stripe and apply side effects safely.

A robust architecture typically looks like this:

  1. 1
    Verify Stripe signature using the raw request body.
  2. 2
    Enforce idempotency using event.id as a unique key.
  3. 3
    Persist the event and minimal data required for auditing and retries.
  4. 4
    Apply changes in a transaction, or enqueue work to background processing.
  5. 5
    Return 200 quickly so Stripe stops retrying.

Why this matters: Stripe will retry failed deliveries for up to several days, and retries are not rare in real systems. You want retries to be safe and boring.

Stripe retry and ordering realities#

Stripe does not guarantee ordering across event types, and retries can cause older events to arrive after newer ones. If your code assumes order, you will eventually corrupt billing state.

Design principles that prevent data corruption:

  • Use database constraints for idempotency.
  • Prefer updating state from Stripe object fields that represent current state, not just “delta” assumptions.
  • When order matters, compare event.created and ignore stale updates.

🎯 Key Takeaway: Treat every webhook delivery as “maybe duplicated” and “maybe out of order”.

# Step 1: Install Stripe and set environment variables#

Install Stripe:

Bash
npm i stripe

Add environment variables:

VariableExampleWhere
STRIPE_SECRET_KEYsk_live_...Server runtime only
STRIPE_WEBHOOK_SECRETwhsec_...Webhook endpoint secret
STRIPE_API_VERSION2024-06-20Optional but recommended

In Next.js, keep these in .env.local for development and in your hosting provider secrets for production.

💡 Tip: Pin a Stripe API version explicitly. It reduces unexpected payload changes when Stripe upgrades defaults.

# Step 2: Create the webhook route in the App Router#

Create app/api/stripe/webhook/route.ts.

Key reliability decisions:

  • Force Node.js runtime.
  • Read the raw body as bytes.
  • Verify signature with stripe.webhooks.constructEvent.
TypeScript
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
 
export const runtime = "nodejs";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: (process.env.STRIPE_API_VERSION as any) || "2024-06-20",
});
 
export async function POST(req: Request) {
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
  if (!webhookSecret) return new Response("Missing webhook secret", { status: 500 });
 
  const signature = req.headers.get("stripe-signature");
  if (!signature) return new Response("Missing stripe-signature", { status: 400 });
 
  const rawBody = Buffer.from(await req.arrayBuffer());
 
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    return new Response(`Webhook signature verification failed: ${message}`, { status: 400 });
  }
 
  // Acknowledge only after durable processing decisions are made.
  // We'll implement idempotency + processing next.
  return new Response("OK", { status: 200 });
}

Why raw bytes matter#

Stripe signs the exact payload it sends. If you parse JSON first and re-serialize, whitespace and ordering can change and signature verification will fail.

Using req.arrayBuffer() keeps it exact.

⚠️ Warning: Do not call req.json() before verification. Once you read the body stream, you cannot read it again, and verification will fail.

# Step 3: Add idempotency with an events table#

Stripe retries on non-200 responses, timeouts, network failures, and transient errors. If your handler applies side effects twice, you can grant duplicate entitlements, double-send emails, or incorrectly extend subscriptions.

The simplest robust pattern is:

  • Insert the Stripe event ID in a table with a unique constraint.
  • If insert fails due to conflict, return 200 immediately.

A minimal schema:

ColumnTypeWhy it exists
idtext primary keyStore event.id and enforce uniqueness
typetextDebugging and filtering
createdintegerStripe event created timestamp
livemodebooleanSeparate test and live auditing
payloadjsonbOptional, for audit and reprocessing
processed_attimestamptzTrack completion
processing_errortextTrack failures

Example SQL for Postgres:

SQL
CREATE TABLE stripe_events (
  id TEXT PRIMARY KEY,
  type TEXT NOT NULL,
  created INTEGER NOT NULL,
  livemode BOOLEAN NOT NULL,
  payload JSONB,
  processed_at TIMESTAMPTZ,
  processing_error TEXT
);

In code, implement a “try insert” step. Below is pseudo-DB code you can adapt to Prisma, Drizzle, or raw SQL.

TypeScript
async function insertStripeEventOnce(event: Stripe.Event) {
  // Return true if inserted, false if already exists
  // Implement using your DB with a unique constraint on id
  return true;
}

A concrete example using Postgres-style conflict handling:

TypeScript
async function insertStripeEventOnce(event: Stripe.Event) {
  const res = await db.query(
    `INSERT INTO stripe_events (id, type, created, livemode, payload)
     VALUES ($1, $2, $3, $4, $5)
     ON CONFLICT (id) DO NOTHING`,
    [event.id, event.type, event.created, event.livemode, event]
  );
 
  // Some clients return rowCount; adapt for your DB driver
  return res.rowCount === 1;
}

# Step 4: Process only the events you need, safely#

Stripe emits many event types. Don’t process everything. Decide which events are authoritative for your business state and ignore the rest.

Common “must handle” events for SaaS billing:

Use caseRecommended eventsNotes
Subscription statecustomer.subscription.created, customer.subscription.updated, customer.subscription.deletedTreat these as the source of truth
Checkout completioncheckout.session.completedOften used to link user to customer
Invoice lifecycleinvoice.paid, invoice.payment_failedUseful for access gating and dunning
Disputescharge.dispute.createdCompliance and risk workflows

Implement a processing switch with defensive parsing#

Stripe payload shapes vary by event type. Use type narrowing carefully and treat unknown shapes as non-fatal.

TypeScript
function isStripeObject<T extends Stripe.Event.Data.Object>(obj: any): obj is T {
  return obj && typeof obj === "object" && typeof obj.object === "string";
}

A processing skeleton:

TypeScript
async function handleStripeEvent(event: Stripe.Event) {
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      await onCheckoutSessionCompleted(event, session);
      break;
    }
 
    case "customer.subscription.updated":
    case "customer.subscription.created":
    case "customer.subscription.deleted": {
      const sub = event.data.object as Stripe.Subscription;
      await onSubscriptionChanged(event, sub);
      break;
    }
 
    case "invoice.paid": {
      const invoice = event.data.object as Stripe.Invoice;
      await onInvoicePaid(event, invoice);
      break;
    }
 
    default:
      // Ignore unneeded events, but keep them recorded for audit if you store payloads
      break;
  }
}

# Step 5: Make processing durable and fast#

Stripe expects a timely response. If you do slow work inside the webhook, you increase timeouts and retries.

A reliability-first pattern is:

  • Insert event record.
  • Do a small transaction that updates core state.
  • Return 200.
  • Offload expensive work to a job queue or background worker.

If you run on Vercel, background jobs and queues matter a lot. See: Next.js background jobs, queues, and cron on Vercel.

Full route example with idempotency and safe completion tracking#

This example returns 200 for duplicates and records processing errors for visibility.

TypeScript
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
 
export const runtime = "nodejs";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: (process.env.STRIPE_API_VERSION as any) || "2024-06-20",
});
 
export async function POST(req: Request) {
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
  const signature = req.headers.get("stripe-signature");
 
  if (!webhookSecret) return new Response("Missing STRIPE_WEBHOOK_SECRET", { status: 500 });
  if (!signature) return new Response("Missing stripe-signature", { status: 400 });
 
  const rawBody = Buffer.from(await req.arrayBuffer());
 
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    return new Response(`Invalid signature: ${message}`, { status: 400 });
  }
 
  const inserted = await insertStripeEventOnce(event);
  if (!inserted) {
    // Duplicate delivery. Safe to acknowledge.
    return new Response("Duplicate", { status: 200 });
  }
 
  try {
    await handleStripeEvent(event);
    await markStripeEventProcessed(event.id);
    return new Response("OK", { status: 200 });
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    await markStripeEventFailed(event.id, message);
 
    // Returning 500 asks Stripe to retry. Only do this for transient errors.
    return new Response("Webhook processing failed", { status: 500 });
  }
}
 
// Implement these with your DB layer
async function insertStripeEventOnce(event: Stripe.Event) {
  return true;
}
async function markStripeEventProcessed(eventId: string) {}
async function markStripeEventFailed(eventId: string, message: string) {}
async function handleStripeEvent(event: Stripe.Event) {}

⚠️ Warning: Returning 500 for permanent errors can create retry storms. Only return 500 for errors you expect could succeed on retry, like temporary database downtime.

# Step 6: Handle event ordering explicitly#

Two ordering problems show up in production:

  1. 1
    Out-of-order subscription updates: An older customer.subscription.updated arrives after a newer one.
  2. 2
    Race conditions between events: For example, invoice.paid arrives before your system has linked the Stripe customer to your internal user.

Strategy A: store a “last applied Stripe event timestamp” per entity#

When updating subscription state, store the last processed event.created for that subscription ID. Only apply changes if the incoming event is newer.

FieldWherePurpose
stripe_subscription_idsubscriptions tableEntity key
last_stripe_event_createdsubscriptions tableGuard against stale events

Pseudo-logic:

TypeScript
async function applySubscriptionUpdateSafely(event: Stripe.Event, sub: Stripe.Subscription) {
  const incoming = event.created;
 
  const current = await db.getSubscriptionByStripeId(sub.id);
  if (current && current.lastStripeEventCreated && incoming <= current.lastStripeEventCreated) {
    return; // stale, ignore
  }
 
  await db.upsertSubscriptionFromStripe(sub, incoming);
}

Strategy B: prefer “source of truth” objects over assumptions#

Instead of “add one month to access”, set access based on Stripe’s current state:

  • Subscription status
  • Current period end
  • Cancel at period end
  • Price ID and quantity

This makes retries and reordering much less dangerous.

# Step 7: Idempotency for downstream side effects#

Even if the webhook event itself is idempotent, side effects might not be.

Common side effects:

Side effectRiskFix
Provisioning featuresDouble provisioningUnique constraints or “already provisioned” flags
Sending emailsDuplicate emailsEmail outbox table keyed by event.id
Posting to SlackSpamDe-dup based on event.id

A practical pattern is an outbox table:

  • Write outbox records in the same DB transaction as state updates.
  • A background worker delivers them once.

This reduces your webhook’s complexity and keeps response times low.

# Step 8: Local testing with Stripe CLI#

Stripe CLI makes webhook testing predictable and fast.

Install and authenticate:

Bash
stripe login

Forward webhooks to your local Next.js server:

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

The CLI prints a webhook signing secret for that session. Put it in .env.local as STRIPE_WEBHOOK_SECRET.

Trigger common events:

Bash
stripe trigger checkout.session.completed

More useful triggers for subscription flows:

Bash
stripe trigger customer.subscription.created
stripe trigger invoice.paid
stripe trigger invoice.payment_failed

💡 Tip: Keep one terminal running stripe listen and another running npm run dev. When signature verification fails, the CLI output plus your server logs usually reveal the mismatch immediately.

# Step 9: Production configuration checklist#

Webhooks often fail in production due to deployment differences, not code.

Hosting and runtime#

ConcernRecommendationWhy
RuntimeNode.jsMost predictable raw body handling
TimeoutsKeep handler under a few secondsReduces retries
LoggingLog event ID and typeFast incident response
DB connectivityUse pooled connectionsAvoid connection storms on retries

Security basics#

  • Only accept POST.
  • Verify Stripe signature for every request.
  • Do not expose secret keys to the client.
  • Consider rate limiting, but be careful not to block Stripe IP ranges by mistake.

For a broader security pass, use: web application security checklist.

# Troubleshooting: Common pitfalls and fixes#

Signature verification fails intermittently#

Typical causes:

SymptomLikely causeFix
Always failing locallyWrong STRIPE_WEBHOOK_SECRETCopy from Stripe CLI or Dashboard endpoint
Failing only in prodDifferent endpoint secret per environmentUse separate secrets for test and live
Failing after code refactorBody parsed before verificationUse req.arrayBuffer() and verify first

Also confirm you are forwarding to the correct path. In the App Router, the file path maps directly to the route URL.

Webhook works locally but not on Vercel#

Common reasons:

  • The route runs on Edge runtime and raw body handling differs.
  • Missing environment variables in production.
  • Database connection limits hit during retry storms.

Fixes:

  • Ensure export const runtime = "nodejs";
  • Verify STRIPE_WEBHOOK_SECRET is set in the deployment environment.
  • Add idempotency and fast responses so retries don’t multiply load.

Duplicate grants or duplicated emails#

If you see duplicated side effects, one of these is missing:

  • Event ID uniqueness enforcement
  • Side-effect idempotency keys

Fix:

  • Enforce uniqueness on stripe_events.id.
  • Add outbox tables keyed by event.id for notifications.

Wrong subscription state in your DB#

If your subscription table flips between states, it’s usually event ordering plus “delta” logic.

Fix:

  • Track and compare event.created to ignore stale updates.
  • Update state from Stripe subscription fields as the source of truth.

Stripe keeps retrying for hours#

Stripe retries when your endpoint returns non-200 or times out.

Fix:

  • Return 200 for duplicates.
  • Only return 500 for transient errors.
  • If the error is permanent, record it and return 200 to stop retries, then alert your team.

ℹ️ Note: A “permanent error” is something like “user not found for customer ID” that won’t fix itself on retry. Record it for manual remediation instead of making Stripe hammer your endpoint.

# Key Takeaways#

  • Verify Stripe signatures using the raw request body bytes via req.arrayBuffer() and stripe.webhooks.constructEvent.
  • Enforce idempotency with a database table keyed by event.id, then return 200 for duplicates.
  • Design for retries and out-of-order delivery by comparing event.created and using Stripe objects as the source of truth.
  • Keep the webhook handler fast and durable, and offload slow side effects to background jobs or an outbox pattern.
  • Use Stripe CLI to test locally with stripe listen and stripe trigger, and validate environment-specific webhook secrets.

# Conclusion#

A reliable Next.js Stripe webhooks implementation is less about “handling an event” and more about guaranteeing correctness under retries, duplication, and reordering. If you implement raw-body signature verification, database-backed idempotency, and ordering guards, your billing system becomes dramatically harder to break.

If you want Samioda to implement or audit your Next.js Stripe webhooks, subscription flows, and background processing, reach out via our website and we’ll help you ship a production-grade billing pipeline.

FAQ

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.