Mastering Stripe Webhook Idempotency and Duplication in Next.js for Production Payments
Stripe
Webhooks
Idempotency
Next.js
Payments
Stripe Connect

Mastering Stripe Webhook Idempotency and Duplication in Next.js for Production Payments

Developers building Stripe Connect marketplaces often encounter the critical challenge of duplicate webhook events, leading to incorrect payment processing or data inconsistenci...

October 13, 202611 min read

You're a technical founder or CTO building a Stripe Connect marketplace with Next.js, and you've hit a wall: duplicate Stripe webhook events. Perhaps a charge.succeeded event fired twice, leading to double-crediting a seller's account, or a transfer.succeeded event processed multiple times, causing reconciliation nightmares. This isn't a theoretical problem; it's a production payments reality that can erode trust, complicate accounting, and cost your business real money. You've likely tried basic webhook handling, but the edge cases of network retries, distributed systems, and concurrent requests are proving difficult to tame.

This article will guide you through implementing production-grade idempotency and de-duplication strategies for Stripe webhooks in your Next.js application, focusing on the specific challenges of Stripe Connect.

TL;DR

  • Verify Signatures: Always validate Stripe webhook signatures to ensure event authenticity and prevent tampering.
  • Store Webhook IDs: Persist the unique id of each Stripe webhook event in your database before processing its payload.
  • Transactional Processing: Wrap event processing in a database transaction to ensure atomicity and prevent partial updates.
  • Handle Concurrency: Implement database-level locking or unique constraints to prevent race conditions during parallel processing of the same event.
  • Acknowledge Immediately: Respond to Stripe with a 200 OK as quickly as possible, deferring heavy processing to background jobs.

The Inevitable Problem: Why Webhooks Duplicate

Stripe's webhook system is designed for reliability. When your endpoint doesn't respond with a 200 OK within a reasonable timeframe (typically 3-5 seconds), or if there's a network issue, Stripe assumes the event wasn't received and will retry. This retry mechanism, while crucial for data integrity, is the primary source of duplicate events. Your server might have processed the event successfully but failed to send the 200 OK response in time, leading Stripe to resend the same event.

Furthermore, in distributed systems, it's possible for multiple instances of your webhook handler to receive and attempt to process the same event concurrently due to load balancing or temporary network partitioning. Without explicit safeguards, each instance might independently process the event, leading to duplication.

Stripe's Role and Your Responsibility

Stripe itself provides a unique id for every webhook event. This id is crucial for idempotency. While Stripe tries to deliver events once, it explicitly states that your endpoint must be idempotent. This means that processing the same event multiple times should have the same effect as processing it once. This is your responsibility as the implementer.

Stripe also sends an idempotency-key header for some API requests you make to Stripe, but this is different from handling incoming webhook event duplication. For webhooks, the id of the event object is your primary tool.

Implementing Database-Level Idempotency

The most robust way to handle webhook duplication is to record the receipt of each unique webhook event in your database before you do any business logic. This creates a single source of truth for processed events.

Consider a WebhookEvent table in your database:

CREATE TABLE "WebhookEvent" (
    "id" TEXT NOT NULL PRIMARY KEY, -- Stripe's webhook event ID
    "type" TEXT NOT NULL,
    "data" JSONB NOT NULL,
    "receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "processedAt" TIMESTAMP(3),
    "status" TEXT NOT NULL DEFAULT 'PENDING',
    "error" TEXT
);

When a webhook arrives, your first step, after signature verification, is to attempt to insert this event into your WebhookEvent table. If an event with the same id already exists, you know it's a duplicate and can safely exit, or check its status and potentially re-process if it failed previously.

Next.js Webhook Handler with Idempotency

Here's a practical example using Next.js App Router, TypeScript, and Prisma for database interaction. This assumes you have a prisma client configured and a WebhookEvent model as described above.

// app/api/stripe/webhooks/route.ts

import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { buffer } from 'micro'; // For raw body parsing
import { prisma } from '@/lib/prisma'; // Your Prisma client instance

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

// IMPORTANT: Tell Next.js to not parse the body automatically
export const config = {
  api: {
    bodyParser: false,
  },
};

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

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

  let event: Stripe.Event;

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

  // --- Idempotency Check and Processing --- 
  try {
    // Attempt to create a new WebhookEvent record.
    // If it already exists (due to unique constraint on 'id'), it will throw.
    // This is our primary idempotency guard.
    const newWebhookEvent = await prisma.webhookEvent.create({
      data: {
        id: event.id,
        type: event.type,
        data: event.data as any, // Store the full event data
        receivedAt: new Date(),
        status: 'PENDING',
      },
    });

    // If we reach here, it's a new event or a previously failed one we want to retry.
    // Now, process the actual business logic based on event.type
    await prisma.$transaction(async (tx) => {
      switch (event.type) {
        case 'charge.succeeded': {
          const charge = event.data.object as Stripe.Charge;
          console.log(`Charge succeeded: ${charge.id}`);
          // Example: Credit the connected account's balance
          // For Stripe Connect, you'd typically handle transfer.succeeded or payout.succeeded
          // or update application-specific balances based on charge.amount

          // Ensure your business logic here is also idempotent if it involves external systems
          // For instance, check if a balance update for this specific charge ID has already occurred.
          break;
        }
        case 'account.updated': {
          const account = event.data.object as Stripe.Account;
          console.log(`Account updated: ${account.id} - ${account.capabilities?.transfers?.status}`);
          // Example: Update your user's connected account status in your database
          // Ensure this update is idempotent (e.g., only update if the status is newer/different)
          break;
        }
        case 'transfer.succeeded': {
          const transfer = event.data.object as Stripe.Transfer;
          console.log(`Transfer succeeded: ${transfer.id} to destination ${transfer.destination}`);
          // Example: Mark the transfer as successful in your internal ledger
          // This is critical for marketplace payouts.
          // Ensure you don't double-process the same transfer ID.
          break;
        }
        // Add more event types as needed for your application
        default:
          console.warn(`Unhandled event type: ${event.type}`);
      }

      // After successful business logic, update the WebhookEvent status
      await tx.webhookEvent.update({
        where: { id: event.id },
        data: { status: 'PROCESSED', processedAt: new Date() },
      });
    }, {
      maxWait: 5000, // default: 2000
      timeout: 10000, // default: 5000
    });

    return NextResponse.json({ received: true, event: event.type }, { status: 200 });

  } catch (dbError: any) {
    if (dbError.code === 'P2002' && dbError.meta?.target?.includes('id')) {
      // P2002 is Prisma's unique constraint violation code
      console.log(`Duplicate webhook event received (ID: ${event.id}, Type: ${event.type}). Skipping processing.`);
      // Acknowledge the duplicate to Stripe, so it stops retrying
      return NextResponse.json({ received: true, message: 'Duplicate event, already processed or pending' }, { status: 200 });
    } else {
      console.error(`Database error during webhook processing for event ${event.id}:`, dbError);
      // Update the webhook event status to FAILED if it was created
      await prisma.webhookEvent.update({
        where: { id: event.id },
        data: { status: 'FAILED', error: dbError.message || 'Unknown database error' },
      }).catch(e => console.error("Failed to update webhook event status to FAILED:", e));
      return NextResponse.json({ error: 'Internal Server Error during processing' }, { status: 500 });
    }
  }
}


Explanation of Key Parts:

  1. Raw Body Parsing: Next.js by default parses the request body. Stripe's webhook signature verification requires the raw body. We use buffer from micro and disable Next.js's body parser with export const config = { api: { bodyParser: false } }; (Note: bodyParser is for Pages Router, for App Router, you simply read the req.body as a stream and buffer it).
  2. Signature Verification: This is non-negotiable. Always verify the stripe-signature header using stripe.webhooks.constructEvent. This prevents malicious actors from sending fake webhook events.
  3. Database Idempotency Check: The prisma.webhookEvent.create call is the core of our idempotency. By defining id as a PRIMARY KEY (or a UNIQUE constraint), the database will automatically prevent duplicate inserts. If a P2002 error (unique constraint violation) is caught, we know this event has been received before. We then return a 200 OK to Stripe, signaling that we've handled it, even if it's a duplicate.
  4. Transactional Processing: The prisma.$transaction block ensures that all subsequent business logic (e.g., updating user balances, marking transfers) either fully completes or fully rolls back. This prevents partial state updates if an error occurs mid-processing. This is critical for payments.
  5. Status Tracking: Updating the status field in WebhookEvent (PENDING, PROCESSED, FAILED) provides an audit trail and allows for manual inspection or re-processing of failed events.

Production Edge Cases to Consider

Concurrency and Race Conditions

While the WebhookEvent table's unique constraint handles basic duplication, what if two instances of your webhook handler simultaneously try to insert the same event? One will succeed, the other will fail with a unique constraint error. This is generally fine for the WebhookEvent record itself.

The real race condition risk is within your business logic. For instance, if two charge.succeeded events (from different ids, but related to the same underlying transaction or user) arrive very close together, and your logic involves fetching a balance, incrementing it, and saving it back, you could have a lost update. This is where careful use of database transactions and, if necessary, pessimistic locking (e.g., SELECT ... FOR UPDATE in PostgreSQL) or optimistic locking comes into play for your application-specific data.

Handling Out-of-Order Events

Stripe does not guarantee the order of webhook events. For example, invoice.payment_succeeded might arrive before charge.succeeded if they are related to the same payment. Your business logic needs to be resilient to this. Often, this means your processing for a later event might need to check if a prior, expected event has already been handled, or be prepared to handle it when it eventually arrives.

Error Handling and Dead-Letter Queues

What happens if your business logic inside the transaction fails? The transaction will roll back, and the WebhookEvent status will be set to FAILED. You should have a mechanism to alert your team to these failed events. For critical payments, a dead-letter queue (e.g., using a message broker like RabbitMQ, SQS, or even a dedicated table) allows you to store failed events for manual review and re-processing. Stripe will also retry the webhook, but your internal system should be robust enough to handle its own failures.

Monitoring and Alerting

Implement robust monitoring for your webhook endpoint. Track the number of incoming webhooks, processing times, and especially the count of FAILED or PENDING events in your WebhookEvent table that haven't been processed within an expected timeframe. Set up alerts for anomalies to quickly identify and address issues.

Specific Stripe Connect Events

For Stripe Connect marketplaces, pay close attention to events like:

  • account.updated: For changes in connected account status, capabilities, or requirements.
  • charge.succeeded: When a customer successfully pays for a product/service, often leading to a transfer to a connected account.
  • transfer.succeeded / transfer.failed: Crucial for tracking payouts to your sellers.
  • payout.succeeded / payout.failed: For direct payouts from connected accounts.

Each of these requires careful idempotent processing to maintain accurate ledgers and prevent double-payouts or incorrect balance updates.

Key Takeaways

  • Idempotency is paramount: Design your webhook handler to produce the same result regardless of how many times an event is processed.
  • Database-first approach: Use a unique constraint on the Stripe event id in your database as the primary guard against duplication.
  • Transactions for atomicity: Ensure business logic is wrapped in database transactions.
  • Handle all edge cases: Consider network retries, concurrency, out-of-order events, and robust error handling.
  • Monitor relentlessly: Keep an eye on your webhook processing pipeline for failures or delays.

By implementing these strategies, you can build a highly resilient and accurate payments infrastructure for your Stripe Connect marketplace, allowing you to focus on building features rather than chasing down payment discrepancies.

Ship this without the trial-and-error

Building production-grade payments infrastructure for Stripe Connect marketplaces is complex, fraught with subtle edge cases that can cost you time and money. Our team specializes in exactly these challenges, from robust webhook handling to complex usage-based billing and multi-tenant architectures.

Don't let webhook duplication or race conditions stall your progress. Partner with PolarSoftBD to engineer a payments system that's secure, scalable, and bulletproof from day one.

Explore our Stripe Integration Services

Book a free strategy call with our team

Last updated October 13, 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 →