← Back to ArticlesBackend Development
Backend Development5 min read

Stripe Webhooks in NestJS Are Fine Until Production Hits: Fixing Race Conditions and Duplicate Events

RENOVATEAPI ARCHITECTURAL SPEC
CANONICAL GUIDE
Published 13 August 20265 min read

How to stop Stripe webhooks from double-charging users, showing stale subscription status, or racing the frontend redirect in a NestJS and PostgreSQL app.

Abhishek Pandey

Abhishek Pandey

Full-Stack & AI Product Engineer

Stripe integrations look done long before they actually are. You wire up checkout.session.completed, provision access, ship it, and everything works in testing. Then it goes to production, real traffic shows up, and three things start happening that never came up in your local Stripe CLI tests: the same event fires twice, a cancellation arrives before the update it's supposed to override, and a user lands back on your dashboard before your webhook has even been received.

None of these are edge cases. They're what Stripe webhooks actually look like at any real volume, and if your NestJS backend isn't built for them, you'll eventually double-fulfill an order or show a paying customer a "no access" screen. Here's how to close each gap.

Duplicate deliveries: give every event a unique-constraint gatekeeper

Stripe's at-least-once delivery guarantee means retries are a normal part of the protocol, not a failure mode. A slow response, a timeout, a flaky connection — any of these makes Stripe resend event.id, sometimes minutes later. If your handler just runs its business logic on arrival, you'll process that event twice: two credited balances, two fulfillment emails, maybe two shipped orders.

The fix is boring in the best way. Create a processed_events table with a unique or primary key constraint on eventId, and try to insert the event before you do anything else:

@Injectable()
export class StripeWebhookService {
  constructor(private readonly prisma: PrismaService) {}

  async processWebhookEvent(event: Stripe.Event): Promise<void> {
    try {
      // Attempt atomic registration of the Stripe event ID
      await this.prisma.processedEvent.create({
        data: {
          eventId: event.id,
          type: event.type,
          processedAt: new Date(),
        },
      });
    } catch (error) {
      if (error.code === 'P2002') {
        // Already processed — exit safely with HTTP 200
        return;
      }
      throw error;
    }

    // Only new events reach the actual business logic
    await this.handleEventPayload(event);
  }
}

The insert either succeeds once, or it throws a unique-constraint violation (P2002 in Prisma) and you bail out early with a 200. No locks, no distributed cache, no race window — the database is doing the deduplication for you, which is exactly where that job belongs.

The redirect race: don't make the user wait on the webhook

This is the one that actually gets reported as a bug, because a real customer sees it. Checkout redirects the browser to your-app.com/dashboard?session_id=cs_123 at roughly the same moment Stripe fires the checkout.session.completed webhook at /webhooks/stripe. Those two things happen concurrently, not in sequence, and there's no guarantee which one lands first.

If your dashboard's access check depends solely on the webhook having already run, you get a paying customer staring at "Unpaid" for however many seconds it takes the webhook to arrive and process. That's a bad first impression to give someone who just handed you their card.

The fix is a hybrid: don't wait on the webhook for the user who's currently in front of you.

  1. When the frontend lands on the completion URL with a session_id, it calls a dedicated endpoint — something like POST /payments/verify-session.
  2. The backend fetches that session directly from Stripe: stripe.checkout.sessions.retrieve(sessionId).
  3. If payment_status comes back 'paid', the backend fulfills immediately and records the event/session ID in Postgres — right into the same processed_events table from above.

When the webhook shows up seconds later, it hits the same idempotency check and gets skipped cleanly. You're not disabling the webhook; you're just refusing to make the logged-in user's experience depend on its timing.

Out-of-order events: let timestamps, not arrival order, decide what wins

A subscription webhook race that's easy to miss until it actually costs someone their access: a customer upgrades, then almost immediately cancels. customer.subscription.deleted can arrive at your server before the earlier customer.subscription.updated event does. Process them in arrival order and the update silently overwrites the cancellation — now a canceled customer has active access.

Every Stripe event carries a created unix timestamp, and that's the field to trust, not the order your server happened to receive things in. Store the last-applied timestamp per user and only write if the incoming event is newer:

async updateUserSubscription(customerId: string, status: string, eventTimestamp: number) {
  await this.prisma.$executeRaw`
    UPDATE "User"
    SET
      "subscriptionStatus" = ${status},
      "lastStripeEventTimestamp" = ${eventTimestamp}
    WHERE "stripeCustomerId" = ${customerId}
      AND ("lastStripeEventTimestamp" IS NULL OR "lastStripeEventTimestamp" < ${eventTimestamp});
  `;
}

The WHERE clause is doing the real work here. A stale event with an older created timestamp simply fails to match any rows and updates nothing — no locking, no queue reordering, just a conditional write that refuses to move backward in time.

Putting the three together

Failure mode What goes wrong Fix
Duplicate delivery Double fulfillment, duplicate charges or emails Unique constraint on event.id in a processed_events table
Redirect race User sees "unpaid" right after paying Direct Stripe session fetch on redirect, gated through the same idempotency table
Out-of-order events A newer event's state gets overwritten by a stale one Compare event.created in the SQL WHERE clause before writing

None of these three patterns are complicated on their own — that's kind of the point. They're each a small, deliberate constraint at the database layer rather than an in-memory queue or a distributed lock you'll also have to operate. If you're running Stripe in production on NestJS and Postgres and haven't hit one of these yet, it's less that your integration is solid and more that you haven't had the traffic to expose it.

Tags:#NestJS#Stripe#PostgreSQL#Webhooks#Prisma

Related Articles

View All Articles ↗