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.
Stripe Webhooks in NestJS Are Fine Until Production Hits: Fixing Race Conditions and Duplicate Events
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.
- When the frontend lands on the completion URL with a
session_id, it calls a dedicated endpoint — something likePOST /payments/verify-session. - The backend fetches that session directly from Stripe:
stripe.checkout.sessions.retrieve(sessionId). - If
payment_statuscomes back'paid', the backend fulfills immediately and records the event/session ID in Postgres — right into the sameprocessed_eventstable 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.
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.
Subscribe to RenovateAPI
Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.
Discussion (2)
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.
The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.
Suggested Related Articles
Your Blog Isn't Getting Cited by AI Search — Here's the Structure That Fixes It
A practical breakdown of Answer Engine Optimization (AEO): how to format headings, code blocks, and tables so ChatGPT Search, Perplexity, and Google AI Overviews actually cite your content.
One Prompt Template, Infinite Consistent Carousels: A System for AI-Generated Instagram Visuals
How a single locked master prompt with four variable fields keeps an entire brand's AI-generated carousel visuals consistent — and why the AI should never touch your text.
revalidateTag() Works Locally and Fails in Production: Fixing Next.js Cache Sync Across Multiple Nodes
Why revalidateTag() and revalidatePath() silently stop working once your Next.js App Router app runs on more than one server instance, and how to fix it.