Mastering Stripe Connect Webhook Idempotency in Next.js: Preventing Duplicate Events
Stripe Connect
Webhooks
Next.js
Idempotency
Payments
App Router

Mastering Stripe Connect Webhook Idempotency in Next.js: Preventing Duplicate Events

Developers building Stripe Connect marketplaces in Next.js often grapple with the challenge of processing webhook events reliably, especially preventing duplicate processing and...

September 10, 20269 min read

Developers and CTOs architecting Stripe Connect marketplaces in Next.js frequently encounter a critical challenge: ensuring the reliable and idempotent processing of webhook events. The problem isn't just about receiving events; it's about handling them exactly once, even when Stripe retries delivery or your system faces transient issues. Failing to do so can lead to duplicate charges, incorrect payouts, or inconsistent data, eroding user trust and incurring significant operational overhead. If you've ever debugged a payment_intent.succeeded event that fired twice, or an account.updated event that caused a race condition, this article is for you.

TL;DR

  • Verify Signatures: Always validate Stripe webhook signatures to ensure event authenticity and prevent tampering.
  • Store Raw Events: Persist the raw Stripe event object in your database immediately upon receipt, before any business logic.
  • Implement Idempotency: Use the event.id as a unique key in your database to prevent reprocessing the same event.
  • Manage Event Status: Track the processing status (received, processing, processed, failed) to handle retries and errors gracefully.
  • Atomic Operations: Ensure your business logic updates (e.g., updating order status, crediting accounts) are transactional and idempotent.

The Inevitable Challenge of Duplicate Webhooks

Stripe webhooks are the backbone of real-time event-driven architectures for payments. They notify your application about critical changes, from a successful payment (payment_intent.succeeded) to an updated connected account (account.updated). However, the internet is not perfectly reliable. Network latency, temporary server outages, or even Stripe's own retry mechanisms mean that your webhook endpoint might receive the same event multiple times.

For instance, if your server acknowledges a webhook but Stripe doesn't receive the 200 OK response, Stripe will retry sending the event. If your system processes the event on the first attempt but fails to send the 200 OK until the second attempt, you've just processed the same event twice. Without proper idempotency, this can lead to:

  • Double-charging customers: A charge.succeeded event processed twice could mistakenly trigger two charges.
  • Incorrect payouts: A payout.succeeded event processed twice might credit a vendor's balance twice.
  • Inconsistent state: Updating an order status or a user's subscription level multiple times can lead to data corruption or race conditions.

This isn't a theoretical problem; it's a production reality that every engineer building on Stripe Connect must solve.

The Idempotency Imperative: How Stripe Helps (and How You Must Too)

Idempotency, in the context of webhooks, means that processing the same event multiple times has the same effect as processing it once. Stripe itself provides idempotency keys for API requests you make to Stripe. For example, when creating a charge, you can provide an idempotency_key to ensure that even if your request times out and you retry it, only one charge is created. This is crucial for outgoing requests.

However, for incoming webhooks, Stripe does not provide an idempotency_key in the same way. Instead, every Stripe event has a unique id. This event.id is your primary tool for ensuring idempotency on your end. Your system must record and check this event.id to determine if an event has already been processed.

Building an Idempotent Webhook Handler in Next.js App Router

Let's walk through building a robust webhook handler in a Next.js App Router environment, focusing on the steps needed to achieve idempotency and security.

1. The Route Handler Setup

In Next.js App Router, you'll create a route.ts file within app/api/webhooks/stripe/. Remember that webhook endpoints need to accept POST requests and, crucially, access the raw request body for signature verification.

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { db } from '@/lib/db'; // Assume a database client, e.g., Prisma or Supabase client
import { headers } from 'next/headers';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16', // Or your current API version
});

// For raw body access in Next.js App Router
export const config = {
  api: {
    bodyParser: false,
  },
};

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

  if (!signature) {
    return new NextResponse('No stripe-signature header', { status: 400 });
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET! // Your webhook secret
    );
  } catch (err: any) {
    console.error(`Webhook signature verification failed: ${err.message}`);
    return new NextResponse(`Webhook Error: ${err.message}`, { status: 400 });
  }

  // Idempotency check and processing logic follows...
  // (See next section for full implementation)
}

2. Signature Verification: Your First Line of Defense

Before processing any event, you must verify its authenticity using Stripe's provided signature. This prevents malicious actors from sending fake webhook events to your endpoint. The stripe.webhooks.constructEvent method handles this for you, throwing an error if the signature is invalid.

3. Database-Driven Idempotency: The event.id Strategy

The core of webhook idempotency lies in your database. You need a mechanism to record every event.id you receive and check against it before executing any business logic. A dedicated StripeEvent table is ideal for this. Here's a simplified Prisma schema example:

// prisma/schema.prisma
model StripeEvent {
  id          String   @id @unique // Stripe Event ID (e.g., evt_xxxxxxxxxxxxxx)
  type        String
  data        Json     // Raw event data
  apiVersion  String?
  request     Json?
  pendingWebhooks Int?
  livemode    Boolean
  created     DateTime // Stripe timestamps are in seconds, convert to milliseconds for JS Date
  status      String   // e.g., 'received', 'processing', 'processed', 'failed'
  errorMessage String? // Store details if processing failed
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

With this schema, your webhook handler can perform an upsert or findUnique operation based on event.id.

// ... inside your POST function, after signature verification

  try {
    // Attempt to find an existing event record
    const existingEvent = await db.stripeEvent.findUnique({
      where: { id: event.id },
    });

    if (existingEvent && existingEvent.status === 'processed') {
      console.log(`Webhook event ${event.id} already processed. Skipping.`);
      return new NextResponse('Event already processed', { status: 200 });
    }

    // If event exists but wasn't processed, or it's a new event, proceed.
    // Save or update the raw event to the database before processing business logic.
    // This ensures we have a record even if subsequent steps fail.
    await db.stripeEvent.upsert({
      where: { id: event.id },
      update: { type: event.type, data: event.data as any, status: 'received' }, // Update status if re-received
      create: {
        id: event.id,
        type: event.type,
        data: event.data as any, // Store raw data
        apiVersion: event.api_version,
        request: event.request as any,
        pendingWebhooks: event.pending_webhooks,
        livemode: event.livemode,
        created: new Date(event.created * 1000), // Stripe timestamps are in seconds
        status: 'received', // Initial status
      },
    });

    // Proceed with business logic based on event.type
    switch (event.type) {
      case 'account.updated':
        const account = event.data.object as Stripe.Account;
        console.log(`Account updated: ${account.id}`);
        // Update connected account details in your DB.
        // This operation itself should be idempotent if possible (e.g., upserting).
        await db.connectedAccount.upsert({
          where: { stripeAccountId: account.id },
          update: {
            chargesEnabled: account.charges_enabled,
            payoutsEnabled: account.payouts_enabled,
            detailsSubmitted: account.details_submitted,
            // ... other relevant fields
          },
          create: { // Handle creation if account doesn't exist yet
            stripeAccountId: account.id,
            userId: 'some-user-id', // Link to your user
            chargesEnabled: account.charges_enabled,
            payoutsEnabled: account.payouts_enabled,
            detailsSubmitted: account.details_submitted,
          }
        });
        break;
      case 'payment_intent.succeeded':
        const paymentIntent = event.data.object as Stripe.PaymentIntent;
        console.log(`Payment Intent succeeded: ${paymentIntent.id}`);
        // Fulfill order, provision service, send email.
        // Crucially, this operation must also be idempotent. For example, when updating an order status,
        // only transition from 'pending' to 'completed', not from 'completed' to 'completed'.
        await db.order.updateMany({
          where: { paymentIntentId: paymentIntent.id, status: 'pending' }, // Only update if pending
          data: { status: 'completed', amountPaid: paymentIntent.amount },
        });
        // Consider a separate table for payment receipts if you need to track multiple payments for an order
        break;
      // ... handle other event types relevant to your marketplace
      default:
        console.warn(`Unhandled event type: ${event.type}`);
    }

    // Mark event as processed after successful handling of all business logic
    await db.stripeEvent.update({
      where: { id: event.id },
      data: { status: 'processed' },
    });

    return new NextResponse('Event processed', { status: 200 });
  } catch (error: any) {
    console.error(`Error processing webhook event ${event?.id}: ${error.message}`);
    // If an error occurs, update the event status to 'failed' for later inspection.
    if (event?.id) {
      await db.stripeEvent.update({
        where: { id: event.id },
        data: { status: 'failed', errorMessage: error.message },
      }).catch(e => console.error(`Failed to update event status to failed: ${e.message}`));
    }
    return new NextResponse(`Webhook Handler Error: ${error.message}`, { status: 500 });
  }
}

4. Atomic Operations and Transaction Management

Beyond simply checking event.id, the business logic triggered by webhooks must also be idempotent. If processing payment_intent.succeeded involves updating an order status and crediting a user's account, these operations should ideally be wrapped in a database transaction. This ensures that either all updates succeed, or none do, preventing partial state changes if an error occurs mid-process.

Furthermore, the updates themselves should be designed to be idempotent. For example, when updating an order status, ensure you're only transitioning from a 'pending' state to a 'completed' state, not attempting to 'complete' an already completed order. This often involves adding WHERE clauses to your UPDATE statements (e.g., WHERE status = 'pending').

Production Edge Cases and Considerations

Implementing a basic idempotent handler is a good start, but production systems demand more.

Webhook Retries and Exponential Backoff

Stripe retries webhook deliveries with an exponential backoff strategy for up to three days. This is precisely why your idempotency logic is critical. If your system is down for an hour, it will receive a flood of retried events when it comes back online. Your event.id check will prevent duplicate processing for these.

Race Conditions in Distributed Systems

In a horizontally scaled Next.js application, multiple instances might receive the same webhook event concurrently. Without careful design, this could lead to a race condition where two instances try to process the same event simultaneously. The db.stripeEvent.upsert (or create with a unique constraint) acts as a distributed lock. The first instance to successfully create/update the event record

Last updated September 10, 2026

Get thoughtful updates

Join our monthly digest for founders and builders.

Work with PolarSoftBD

Need help shipping your next product? Let's talk.

Start a project →