Skip to content
Back to Blog
  • Stripe Connect
  • Webhooks
  • Idempotency
  • Payments Engineering
  • Production Systems
  • Next.js

Handling Duplicate Stripe Connect Webhooks: A Production Guide for Robust Integrations

Building a reliable Stripe Connect integration means confronting duplicate webhook events head-on.

October 25, 20267 min read
Handling Duplicate Stripe Connect Webhooks: A Production Guide for Robust Integrations

If you're building a Stripe Connect marketplace or any complex Stripe integration, you've likely encountered the headache of duplicate webhook events. This isn't a theoretical edge case; it's a production reality that can lead to corrupted data, incorrect billing, and a significant drain on engineering resources trying to debug phantom issues. For technical founders and CTOs, ensuring financial data integrity is paramount.

Duplicate webhooks arise from the distributed nature of modern systems. Stripe's robust retry mechanisms, network latencies, and concurrent processing within your own infrastructure can all conspire to deliver the same event payload multiple times. Ignoring this problem is not an option for any production-grade payments system.

TL;DR

  • Implement event-level idempotency using the unique event.id and atomic database operations.
  • Always verify Stripe webhook signatures to prevent spoofing and ensure data integrity.
  • Design your event processing to be inherently idempotent, handling out-of-order delivery gracefully.
  • Leverage database unique constraints (UPSERT or INSERT ... ON CONFLICT) for concurrency safety.
  • Establish robust monitoring and alerting for webhook processing failures and retries.

The Inevitable Reality of Duplicate Webhooks

Stripe webhooks are designed for reliability, which includes retrying failed deliveries with an exponential backoff. While crucial for ensuring event delivery, this mechanism is a primary source of duplicates. If your webhook endpoint responds with a non-2xx status code, or times out, Stripe will retry. If the original attempt did process the event but the response wasn't received by Stripe, the retry becomes a duplicate.

Furthermore, in a horizontally scaled application, multiple instances of your webhook handler might pick up the same event simultaneously, especially during periods of high load or if your message queue isn't strictly ordered and single-consumer per message. This race condition compounds the problem, demanding a robust, idempotent processing layer.

The Core Solution: Event Idempotency

The fundamental strategy to combat duplicate webhooks is to make your event processing idempotent. This means that processing the same event multiple times has the same effect as processing it once. For Stripe webhooks, the event.id property is your golden ticket.

Every Stripe event has a globally unique id. This id is consistent across all retries and deliveries of the same logical event. Your system must use this id to determine if an event has already been processed.

Implementing Idempotency with Database Constraints

The most reliable way to implement event idempotency is at the database level. When you receive a webhook, the first step should be to record the event.id in a dedicated processed_webhooks table or directly on the relevant entity table (e.g., payments, subscriptions).

Consider a processed_webhooks table with a webhook_id column that stores the event.id and has a unique constraint. When a webhook arrives, attempt to insert this webhook_id.

// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { buffer } from 'node:stream/consumers';
import { db } from '@/lib/db'; // Your database client (e.g., Prisma)

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

export async function POST(req: NextRequest) {
  const rawBody = await buffer(req.body!);
  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
    );
  } catch (err: any) {
    console.error(`Webhook signature verification failed: ${err.message}`);
    return NextResponse.json({ error: `Webhook Error: ${err.message}` }, { status: 400 });
  }

  // --- Idempotency Check --- 
  try {
    // This is a simplified example using Prisma's upsert for atomicity.
    // In a real scenario, you might have a dedicated `ProcessedWebhookEvents` table
    // or incorporate the check directly into your event processing logic
    // using `INSERT ... ON CONFLICT DO NOTHING` for better performance.
    const processedEvent = await db.processedWebhookEvent.upsert({
      where: { id: event.id },
      update: { lastProcessedAt: new Date() }, // Update timestamp if already exists
      create: {
        id: event.id,
        type: event.type,
        createdAt: new Date(event.created * 1000),
        lastProcessedAt: new Date(),
        data: event.data as any,
      },
    });

    // If the event was just created (i.e., it's new), proceed with processing.
    // If it was updated (i.e., it already existed), then it's a duplicate and we skip.
    if (processedEvent.lastProcessedAt.getTime() !== processedEvent.createdAt.getTime()) {
        console.log(`Skipping duplicate event: ${event.id}`);
        return NextResponse.json({ received: true });
    }

  } catch (dbError: any) {
    // This catch block handles potential unique constraint violations
    // if two instances try to insert the same event.id simultaneously.
    // In a real system, you'd check the specific error code.
    if (dbError.code === 'P2002' || dbError.message.includes('Unique constraint failed')) { // Prisma unique constraint error code
      console.log(`Race condition detected, event ${event.id} already being processed or processed.`);
      return NextResponse.json({ received: true }); // Acknowledge receipt, but skip processing
    }
    console.error(`Database error during idempotency check: ${dbError.message}`);
    return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
  }

  // --- Event Processing Logic --- 
  try {
    switch (event.type) {
      case 'customer.subscription.created':
        // Handle new subscription, provision access
        console.log(`Subscription created for customer: ${event.data.object.customer}`);
        break;
      case 'customer.subscription.updated':
        // Handle subscription changes (e.g., plan change, status change)
        console.log(`Subscription updated for customer: ${event.data.object.customer}`);
        break;
      case 'invoice.payment_succeeded':
        // Record successful payment, update user credits/entitlements
        console.log(`Invoice payment succeeded: ${event.data.object.id}`);
        break;
      case 'checkout.session.completed':
        // Provision access after a successful checkout session
        console.log(`Checkout session completed: ${event.data.object.id}`);
        break;
      // ... handle other relevant Stripe Connect events (e.g., account.updated, charge.succeeded)
      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    return NextResponse.json({ received: true });
  } catch (processError: any) {
    console.error(`Error processing event ${event.id}: ${processError.message}`);
    // Crucially, if processing fails *after* idempotency check, you might need
    // to mark the event as failed in your `processed_webhooks` table or retry internally.
    return NextResponse.json({ error: 'Event processing failed' }, { status: 500 });
  }
}

// Required for Next.js App Router to parse raw body
export const config = {
  api: {
    bodyParser: false,
  },
};

In this example, db.processedWebhookEvent.upsert attempts to insert the event.id. If it already exists, the update clause is triggered, and we can infer it's a duplicate. If it's a new record, the create clause runs, and we proceed with processing. This upsert pattern is atomic and handles race conditions where two processes might try to insert the same event.id simultaneously.

For higher throughput, a direct INSERT ... ON CONFLICT DO NOTHING SQL statement can be more performant than UPSERT if your database supports it (e.g., PostgreSQL). This attempts to insert and simply does nothing if a unique conflict occurs, returning a success without inserting, which you can then check.

Production Edge Cases and Considerations

Webhook Signature Verification

Before any idempotency checks or processing, always verify the webhook's signature. This ensures the request genuinely originated from Stripe and hasn't been tampered with. The stripe.webhooks.constructEvent method handles this for you. Failing to do so opens your system to security vulnerabilities and potentially malicious data.

Race Conditions and Concurrent Processing

Even with event.id uniqueness, race conditions can occur. Imagine two instances of your webhook handler receiving the same event almost simultaneously. Both attempt the idempotency check. Without atomic operations like UPSERT or ON CONFLICT DO NOTHING, one might pass the check, start processing, and then the other passes the check before the first has committed its event.id to the database.

Atomic database operations are key here. The upsert method or INSERT ... ON CONFLICT DO NOTHING ensures that only one process successfully

Last updated October 25, 2026

Get thoughtful updates

Join our monthly digest for founders and builders.

Work withPolarSoftBD

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