RA
RenovateAPIEngineering Hub
Backend Development

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

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

Backend Development5 min read

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

RENOVATEAPI ARCHITECTURAL SPEC
CANONICAL GUIDE

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.

RenovateAPI Engineering Suite

Accelerate your Backend Development Modernization Roadmap

Need custom architecture auditing, automated OpenAPI contract generation, or zero-downtime microservice migration guidance for your engineering team?

Frequently Asked Questions

Why does Stripe send the same webhook event more than once?

Stripe guarantees at-least-once delivery. If your endpoint is slow to respond, times out, or the network hiccups, Stripe retries the same event. Your handler has to assume every event might arrive twice.

Do I still need webhooks if I verify the session directly after checkout?

Yes. Direct session verification fixes the redirect race for the user currently in your app, but the webhook is still your source of truth for events that happen without a browser present, like subscription renewals or disputes.

Weekly Engineering Dispatch

Subscribe to RenovateAPI

Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.

Discussion (2)

A
Alex Rivera
2 hours ago

Extremely helpful breakdown of the Strangler Fig pattern! We're currently refactoring a legacy Java monolith at work and the OpenAPI gateway routing tips saved us weeks of experimentation.

S
Sophia Chen
1 day ago

The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.

Suggested Related Articles