Fairy
Resources

How to Secure Stripe Webhooks in Next.js (The 3 Mistakes AI Makes)

June 21, 2026 · 8-minute read · Fairy

The short answer

To securely handle Stripe webhooks in Next.js, you must verify the webhook signature using stripe.webhooks.constructEvent() with the raw request body, implement idempotency checks by storing processed event IDs, and only fulfill orders after confirming payment status. Never trust the parsed JSON body directly—always verify the signature first.

The Direct Answer: Three Security Requirements for Stripe Webhooks

Securing Stripe webhooks in Next.js requires three non-negotiable steps: signature verification using stripe.webhooks.constructEvent() with the raw request body, idempotency checks to prevent duplicate processing, and proper event handling that only fulfills after payment confirmation.

Here's what secure Stripe webhook handling looks like in Next.js App Router:

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: NextRequest) {
  // 1. Get RAW body - critical for signature verification
  const rawBody = await request.text();
  const signature = request.headers.get('stripe-signature');

  if (!signature) {
    return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
  }

  let event: Stripe.Event;

  // 2. Verify signature before ANY processing
  try {
    event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
  } catch (err) {
    console.error('Webhook signature verification failed:', err);
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  // 3. Idempotency check - prevent duplicate processing
  const alreadyProcessed = await checkEventProcessed(event.id);
  if (alreadyProcessed) {
    return NextResponse.json({ received: true });
  }

  // 4. Handle the verified event
  try {
    await handleStripeEvent(event);
    await markEventProcessed(event.id);
  } catch (err) {
    console.error('Error processing webhook:', err);
    return NextResponse.json({ error: 'Processing failed' }, { status: 500 });
  }

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

This pattern addresses every critical vulnerability we've observed in AI-generated Stripe webhook code. Let's examine why each step matters and what goes wrong when they're missing.

Why AI-Generated Webhook Code Is Dangerous

When you ask an AI assistant to generate Stripe webhook handling code, it typically produces something functional-looking but fundamentally insecure. The code runs. It processes events. It even looks reasonable at first glance.

The problem is that AI code generators optimize for the happy path. They produce code that works when everything goes right—when requests genuinely come from Stripe, when networks are reliable, when no one is trying to exploit your endpoint.

In our reviews through Fairy Scout, we consistently find three critical patterns in AI-generated Stripe webhook code that create severe security vulnerabilities.

Mistake #1: Missing Signature Verification

This is the most dangerous and most common mistake. Here's what AI typically generates:

// ❌ DANGEROUS: AI-generated anti-pattern
export async function POST(request: NextRequest) {
  const body = await request.json();
  
  if (body.type === 'checkout.session.completed') {
    const session = body.data.object;
    await fulfillOrder(session.metadata.orderId);
  }
  
  return NextResponse.json({ received: true });
}

This code has a critical flaw: anyone can POST fake events to your endpoint. An attacker could send:

{
  "type": "checkout.session.completed",
  "data": {
    "object": {
      "metadata": { "orderId": "order_123" }
    }
  }
}

Your system would fulfill the order without any payment ever occurring. This isn't theoretical—it's trivial to exploit.

The Fix: Always Verify Signatures

Stripe signs every webhook payload with your endpoint's signing secret. Verification ensures the request genuinely came from Stripe and hasn't been tampered with:

// ✅ SECURE: Signature verification
const rawBody = await request.text();
const signature = request.headers.get('stripe-signature');

try {
  const event = stripe.webhooks.constructEvent(
    rawBody,
    signature!,
    process.env.STRIPE_WEBHOOK_SECRET!
  );
  // Now event.data.object is verified and safe to use
} catch (err) {
  // Invalid signature - reject the request
  return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}

The constructEvent function does three things: it parses the payload, verifies the signature matches, and checks the timestamp to prevent replay attacks.

Why Raw Body Matters

Notice we use request.text(), not request.json(). This is critical. Stripe's signature is computed over the exact bytes sent in the request. If you parse the JSON first and let it re-serialize, subtle differences in whitespace or key ordering will cause verification to fail.

In Next.js App Router, request.text() gives you the raw string. In the Pages Router with API routes, you need to configure the body parser:

// pages/api/webhooks/stripe.ts (Pages Router)
export const config = {
  api: {
    bodyParser: false, // Disable default JSON parsing
  },
};

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const rawBody = await buffer(req);
  // ... verification with rawBody.toString()
}

Mistake #2: No Idempotency Handling

Stripe's webhook delivery is designed for reliability, not exactly-once delivery. When your endpoint returns a 5xx error, times out, or is temporarily unreachable, Stripe retries the webhook—sometimes multiple times over several days.

AI-generated code almost never accounts for this:

// ❌ DANGEROUS: No idempotency check
async function handleStripeEvent(event: Stripe.Event) {
  if (event.type === 'checkout.session.completed') {
    await grantUserAccess(event.data.object.customer);
    await sendConfirmationEmail(event.data.object.customer_email);
  }
}

If Stripe retries this event, your user gets multiple confirmation emails. Worse, if you're tracking usage or credits, they might receive duplicate grants.

The Fix: Track Processed Events

Store processed event IDs and skip duplicates:

// ✅ SECURE: Idempotency handling
async function checkEventProcessed(eventId: string): Promise<boolean> {
  const existing = await db.stripeEvents.findUnique({
    where: { eventId }
  });
  return !!existing;
}

async function markEventProcessed(eventId: string): Promise<void> {
  await db.stripeEvents.create({
    data: {
      eventId,
      processedAt: new Date()
    }
  });
}

// In your handler:
if (await checkEventProcessed(event.id)) {
  // Already processed - return success to stop retries
  return NextResponse.json({ received: true });
}

await handleStripeEvent(event);
await markEventProcessed(event.id);

The check must happen before any side effects, and you should mark the event as processed after successful handling. If processing fails, Stripe will retry, and you'll get another chance.

Database Schema for Event Tracking

A simple table works:

CREATE TABLE stripe_webhook_events (
  event_id VARCHAR(255) PRIMARY KEY,
  event_type VARCHAR(100),
  processed_at TIMESTAMP DEFAULT NOW(),
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_stripe_events_processed ON stripe_webhook_events(processed_at);

Keep events for 30-90 days for debugging, then prune. Stripe's retry window is about 72 hours, so anything older than that is safe to remove from an idempotency perspective.

Mistake #3: Fulfilling Before Payment Confirmation

AI code often triggers fulfillment on the wrong event or without checking payment status:

// ❌ DANGEROUS: Fulfilling on checkout creation, not completion
if (event.type === 'checkout.session.created') {
  await fulfillOrder(session.metadata.orderId);
}

Or checking the wrong field:

// ❌ DANGEROUS: Not verifying payment status
if (event.type === 'checkout.session.completed') {
  // Session completed doesn't always mean paid!
  await fulfillOrder(session.metadata.orderId);
}

A checkout session can complete without payment if you're using delayed payment methods or if the customer's payment fails after initial authorization.

The Fix: Check Payment Status

Always verify the payment status before fulfillment:

// ✅ SECURE: Verify payment before fulfillment
if (event.type === 'checkout.session.completed') {
  const session = event.data.object as Stripe.Checkout.Session;
  
  if (session.payment_status === 'paid') {
    await fulfillOrder(session.metadata?.orderId);
  } else if (session.payment_status === 'unpaid') {
    // For delayed payment methods, wait for payment_intent.succeeded
    console.log('Checkout completed but payment pending');
  }
}

// Also handle successful payment intents for robustness
if (event.type === 'payment_intent.succeeded') {
  const paymentIntent = event.data.object as Stripe.PaymentIntent;
  await handleSuccessfulPayment(paymentIntent);
}

For subscription businesses, listen to invoice.paid rather than customer.subscription.created—a subscription can be created before the first payment succeeds.

Complete Secure Implementation

Here's a production-ready implementation combining all three protections:

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { db } from '@/lib/db';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16'
});

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: NextRequest) {
  const rawBody = await request.text();
  const signature = request.headers.get('stripe-signature');

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

  // Step 1: Verify signature
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(rawBody, 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 signature verification failed' },
      { status: 400 }
    );
  }

  // Step 2: Idempotency check
  const existingEvent = await db.stripeWebhookEvent.findUnique({
    where: { eventId: event.id }
  });

  if (existingEvent) {
    console.log(`Event ${event.id} already processed, skipping`);
    return NextResponse.json({ received: true });
  }

  // Step 3: Process event with payment verification
  try {
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.Checkout.Session;
        if (session.payment_status === 'paid') {
          await handleCheckoutComplete(session);
        }
        break;
      }
      case 'invoice.paid': {
        const invoice = event.data.object as Stripe.Invoice;
        await handleInvoicePaid(invoice);
        break;
      }
      case 'customer.subscription.deleted': {
        const subscription = event.data.object as Stripe.Subscription;
        await handleSubscriptionCanceled(subscription);
        break;
      }
      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    // Mark as processed after successful handling
    await db.stripeWebhookEvent.create({
      data: {
        eventId: event.id,
        eventType: event.type,
        processedAt: new Date()
      }
    });

  } catch (err) {
    console.error(`Error processing event ${event.id}:`, err);
    // Return 500 so Stripe will retry
    return NextResponse.json(
      { error: 'Webhook processing failed' },
      { status: 500 }
    );
  }

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

Testing Your Webhook Security

Before deploying, verify your implementation catches these attacks:

Test 1: Forged Requests

curl -X POST http://localhost:3000/api/webhooks/stripe \
  -H "Content-Type: application/json" \
  -d '{"type":"checkout.session.completed","data":{"object":{}}}'

This should return 400, not 200.

Test 2: Invalid Signatures

curl -X POST http://localhost:3000/api/webhooks/stripe \
  -H "Content-Type: application/json" \
  -H "stripe-signature: invalid_signature" \
  -d '{"type":"checkout.session.completed"}'

This should also return 400.

Test 3: Stripe CLI for Real Events

Use the Stripe CLI for integration testing:

stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed

This sends real signed events to your local endpoint.

Getting Expert Review on Payment Code

Stripe webhook security is exactly the kind of code where AI assistance creates false confidence. The generated code looks correct, handles the happy path, and passes basic tests—but leaves critical vulnerabilities that only surface when an attacker probes your endpoint or when Stripe retries an event during an outage.

When you're handling payments, the cost of getting it wrong is direct financial loss. If you're shipping payment integration code, consider having a staff engineer verify your implementation. A human reviewer catches the patterns AI misses—not because AI is incapable, but because security requires adversarial thinking that current models don't reliably exhibit.

For ongoing monitoring of your AI-generated code, Fairy Scout automatically flags these patterns in pull requests before they reach production. It's free and catches the signature verification, idempotency, and premature fulfillment issues described in this guide.

Frequently asked questions

Why do I need to use the raw body for Stripe webhook verification?

Stripe's signature is computed over the exact bytes sent in the request. If you parse the JSON first, the re-serialized body may differ slightly (whitespace, key order), causing signature verification to fail. Always use request.text() or equivalent to get the raw string.

What happens if I don't implement idempotency in my Stripe webhook handler?

Stripe retries webhooks when your endpoint returns 5xx errors or times out. Without idempotency checks, the same event processes multiple times, leading to duplicate order fulfillment, double credits, or repeated emails to customers.

How do I get the raw body in Next.js App Router API routes?

In Next.js App Router, use await request.text() instead of await request.json(). This returns the raw string body needed for Stripe signature verification before you parse it as JSON.

Where do I find my Stripe webhook signing secret?

In your Stripe Dashboard, go to Developers > Webhooks, select your endpoint, and click 'Reveal' under Signing secret. It starts with 'whsec_'. Store this in an environment variable, never in code.


Have AI-generated work you’d want verified? Connect with a Fairy → or run a free check with Scout.

More resources