6 min read
stripe
webhooks
nextjs
typescript
payments

Handling Stripe Webhooks Reliably

A practical guide to handling Stripe webhooks reliably in a Next.js and TypeScript ecommerce app with signature verification, idempotency, retries, and production-safe event handling.

Stripe EventWebhook EndpointVerify SignatureIdempotency CheckProcess Event200 OKReject 400Skipvalidinvalidalready processednew

Handling Stripe Webhooks Reliably

I learned quickly that Stripe webhooks are not background noise around payments they are the source of truth for everything that happens after checkout.

In a production ecommerce platform, the difference between a clean order flow and a quietly broken one often comes down to whether webhook handling is strict, idempotent, and boring.

Stripe's documentation is very clear about the fundamentals:

Why Webhooks Matter

Checkout does not end when the customer clicks Pay.

The browser can close.

The network can fail.

The redirect can stall.

A delayed payment method can settle long after the user leaves the page.

That is why I treat webhooks as the durable signal that updates order state, sends fulfillment jobs, and reconciles payment records inside my database.

I never trust the client to tell me a payment succeeded.

The client can show a success page, but only the webhook should move the system of record from:

pending -> paid
pending -> failed

Stripe explicitly positions webhooks as the mechanism for:

Signature Verification

Skipping signature verification creates a real security hole.

Stripe recommends verifying every event using:

Without verification, anyone could send a fake POST request and trick your backend into marking orders as paid.

The raw-body requirement catches many developers.

Stripe verifies the exact payload it originally sent.

If the request body is parsed and reserialized before verification, whitespace and ordering can change, causing signature validation to fail.

Next.js Route Handler

// app/api/stripe/webhook/route.ts
 
import Stripe from "stripe";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-07-30.basil",
});
 
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
 
export async function POST(req: Request) {
  const signature = headers().get("stripe-signature");
 
  if (!signature) {
    return NextResponse.json({ error: "Missing signature" }, { status: 400 });
  }
 
  const body = await req.text();
 
  let event: Stripe.Event;
 
  try {
    event = stripe.webhooks.constructEvent(body, signature, endpointSecret);
  } catch {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }
 
  await handleStripeEvent(event);
 
  return NextResponse.json({
    received: true,
  });
}

This is the shape I want in production:

One easy mistake is mixing the webhook secret from the Stripe Dashboard with the one generated by the Stripe CLI during local development.

They are different values.

Idempotency and Duplicate Deliveries

Stripe webhooks can arrive more than once.

That is not an edge case.

It is part of the delivery model.

Stripe automatically retries failed deliveries for up to three days.

Because of that, I never ask:

Did I receive this event?

Instead I ask:

Have I already processed this event ID?

The event ID becomes the natural deduplication key.

Idempotent Event Log

async function handleStripeEvent(event: Stripe.Event) {
  const alreadyProcessed = await db.webhookEvent.findUnique({
    where: {
      stripeEventId: event.id,
    },
  });
 
  if (alreadyProcessed) {
    return;
  }
 
  await db.webhookEvent.create({
    data: {
      stripeEventId: event.id,
      type: event.type,
      processedAt: new Date(),
    },
  });
 
  await processEvent(event);
}

I also make side effects idempotent.

Without that safeguard, retries can create:

If an event was already processed, the handler should safely no-op and return success.

Which Events Actually Matter?

Most ecommerce systems do not need every Stripe event.

I usually start with:

These events cover most post-checkout workflows without introducing unnecessary complexity.

For a typical one-time purchase flow:

checkout.session.completed

Marks checkout as completed and starts order creation.

payment_intent.succeeded

Confirms the payment succeeded.

payment_intent.payment_failed

Marks payment as failed.

charge.refunded

Updates refund state.

charge.dispute.created

Flags the order for manual review.

If subscriptions enter the picture, the event list changes significantly.

Only subscribe to events your business model actually needs.

Retry Behavior

Stripe retries failed webhook deliveries automatically.

That means the webhook endpoint must satisfy two requirements:

  1. Respond quickly.
  2. Move expensive work elsewhere.

The most common mistake is doing everything synchronously:

One slow dependency triggers retries.

Retries replay already-completed work.

The fix is simple:

  1. Record the event.
  2. Queue downstream work.
  3. Return a success response.

Production-Safe Handler

async function processEvent(event: Stripe.Event) {
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
 
      await orders.markCheckoutComplete(
        session.id,
        session.payment_intent as string,
      );
 
      await queue.publish("order.paid", {
        orderId: session.metadata?.orderId,
        stripeEventId: event.id,
      });
 
      break;
    }
 
    case "payment_intent.payment_failed": {
      const intent = event.data.object as Stripe.PaymentIntent;
 
      await payments.markFailed(
        intent.id,
        intent.last_payment_error?.message ?? null,
      );
 
      break;
    }
 
    case "charge.refunded": {
      const charge = event.data.object as Stripe.Charge;
 
      await refunds.recordRefund(charge.id);
 
      break;
    }
 
    default:
      return;
  }
}

I keep webhook handlers intentionally narrow.

Their job is to translate Stripe events into domain events or state transitions.

Nothing more.

Common Failure Modes

Trusting the Redirect

The browser redirect is a UX signal.

It is not a payment guarantee.

Users can close tabs before the redirect completes.

The webhook remains the source of truth.

Using the Wrong Body Parser

If the framework parses the request body before verification, Stripe signature checks fail.

Always verify against the raw payload.

Doing Too Much Synchronously

Slow webhook responses trigger retries.

Retries increase duplicate processing risk.

Acknowledge first.

Process asynchronously.

Not Storing Processed Event IDs

Without deduplication:

These bugs are expensive because they appear randomly and are difficult to reproduce.

What I Store

I typically persist three layers of state:

  1. Stripe event ID and type
  2. Internal payment or order record
  3. Processed marker

That gives me:

If I ever need to replay missed events, the event log makes the process safe and predictable.

Final Takeaways

Stripe webhooks are only reliable when treated like infrastructure rather than glue code.

My rules are simple:

If those fundamentals are in place, payment state remains correct even when browsers, networks, and Stripe retries become messy.

References

  1. Resolve Webhook Signature Verification Errors
  2. Process Undelivered Webhook Events
  3. Types of Events
  4. Set Up and Deploy a Webhook
  5. Handle Payment Events
Handling Stripe Webhooks Reliably | Enrique Ferreiro