Stripe Webhooks: Making Them Truly Idempotent
Stripe webhooks look straightforward: Stripe sends an event, you handle it, done. In practice, Stripe can send the same event multiple times — network retries, your server timing out, your load balancer returning a 504. If your handler isn't idempotent, a customer gets charged twice.
I've built Stripe integrations across three production systems. Here's what idempotent webhook handling actually looks like.
Why Stripe Sends Events Multiple Times
Stripe retries webhook delivery if:
- Your endpoint returns anything other than a 2xx status
- Your endpoint times out (default: 30 seconds)
- Stripe's delivery infrastructure has a transient failure
Their retry schedule is roughly: immediately, then 1 hour, 5 hours, 1 day, 2 days — up to 72 hours of retries. Any of these deliveries could arrive after your server has already processed the event successfully.
The fix isn't "process faster" — it's "make processing the same event twice produce the same result as once."
Step 1: Verify the Signature
Before anything else, verify that the event is genuinely from Stripe:
@Controller('webhooks')
export class WebhookController {
constructor(private readonly webhookService: WebhookService) {}
@Post('stripe')
@HttpCode(200)
async handleStripeWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('stripe-signature') signature: string,
) {
// req.rawBody required — use NestJS rawBody: true in main.ts
const event = await this.webhookService.constructEvent(
req.rawBody,
signature,
);
// Respond immediately — heavy processing goes to a queue
await this.webhookService.enqueue(event);
return { received: true };
}
}async constructEvent(rawBody: Buffer, signature: string): Promise<Stripe.Event> {
try {
return this.stripe.webhooks.constructEvent(
rawBody,
signature,
this.configService.get('STRIPE_WEBHOOK_SECRET'),
);
} catch (err) {
throw new BadRequestException(`Webhook signature invalid: ${err.message}`);
}
}Stripe's signature is computed against the raw request body. If you parse it as JSON first (which most NestJS middleware does), verification fails. Configure NestJS with rawBody: true and use req.rawBody — not req.body — in the webhook endpoint.
Step 2: Acknowledge Immediately, Process Asynchronously
Your webhook endpoint should do two things and nothing else:
- Verify the signature
- Return 200
Processing should happen in a background queue. If you process inline and something takes more than 30 seconds, Stripe retries — even though you're still working.
async enqueue(event: Stripe.Event): Promise<void> {
await this.queue.add('stripe-event', {
eventId: event.id,
type: event.type,
data: event.data,
}, {
jobId: event.id, // BullMQ deduplication — same eventId = same job
removeOnComplete: true,
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
});
}Note the jobId: event.id — BullMQ will reject duplicate jobs with the same ID, giving you a first layer of deduplication at the queue level.
Step 3: Database-Level Deduplication
The queue deduplication isn't enough on its own — if your queue is restarted or the job fails before completion, BullMQ may accept the event again. Add a database-level idempotency check:
CREATE TABLE processed_stripe_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
processed_at TIMESTAMPTZ DEFAULT now()
);@Processor('stripe-events')
export class StripeEventProcessor {
@Process('stripe-event')
async handle(job: Job<{ eventId: string; type: string; data: Stripe.EventData }>) {
const { eventId, type, data } = job.data;
// Idempotency guard — skip if already processed
const alreadyProcessed = await this.db.query(
`INSERT INTO processed_stripe_events (event_id, event_type)
VALUES ($1, $2)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id`,
[eventId, type],
);
if (alreadyProcessed.rowCount === 0) {
this.logger.log(`Skipping duplicate Stripe event: ${eventId}`);
return;
}
// Safe to process — guaranteed to run exactly once
await this.handleEvent(type, data);
}
}The ON CONFLICT DO NOTHING pattern is atomic — no race conditions, no double processing.
Step 4: Handle Each Event Type
private async handleEvent(type: string, data: Stripe.EventData): Promise<void> {
switch (type) {
case 'checkout.session.completed':
return this.handleCheckoutCompleted(data.object as Stripe.Checkout.Session);
case 'customer.subscription.updated':
return this.handleSubscriptionUpdated(data.object as Stripe.Subscription);
case 'customer.subscription.deleted':
return this.handleSubscriptionCancelled(data.object as Stripe.Subscription);
case 'invoice.payment_failed':
return this.handlePaymentFailed(data.object as Stripe.Invoice);
default:
this.logger.debug(`Unhandled Stripe event type: ${type}`);
}
}
private async handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
const orderId = session.metadata?.orderId;
if (!orderId) throw new Error(`Missing orderId in session metadata: ${session.id}`);
await this.orderService.fulfil(orderId, {
stripeSessionId: session.id,
customerId: session.customer as string,
});
}The Subscription Lifecycle
The events you need to handle for a complete subscription lifecycle:
| Event | What to do |
|---|---|
checkout.session.completed | Activate subscription, grant access |
customer.subscription.updated | Sync plan changes, update access level |
customer.subscription.deleted | Revoke access, send cancellation email |
invoice.payment_succeeded | Extend subscription period, send receipt |
invoice.payment_failed | Send dunning email, set grace period |
invoice.payment_failed (3rd time) | Suspend account, notify |
Testing Webhooks Locally
Use the Stripe CLI to forward events to your local server:
stripe listen --forward-to localhost:3000/webhooks/stripeTrigger specific events for testing:
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failedThe CLI prints your webhook signing secret — use this in your .env.local during development.
The two things that eliminate 99% of webhook bugs: verify the signature on the raw body, and use database-level idempotency with ON CONFLICT DO NOTHING. Everything else is application logic.
Arif Iqbal
Senior Backend Engineer with 10+ years building high-traffic platforms. NestJS · Node.js · Laravel · AWS · PostgreSQL. Open to remote & relocation.
Enjoyed this post?
Get my technical deep-dives in your inbox. No spam, unsubscribe anytime.
Discussion