All posts
Backend Architecture

Stripe Webhooks: Making Them Truly Idempotent

Arif Iqbal·June 15, 2026·5 min read

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:

src/payments/webhook.controller.ts
@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 };
  }
}
src/payments/webhook.service.ts
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}`);
  }
}
Never parse the body before verification

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:

  1. Verify the signature
  2. 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.

src/payments/webhook.service.ts
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()
);
src/payments/stripe-event.processor.ts
@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:

EventWhat to do
checkout.session.completedActivate subscription, grant access
customer.subscription.updatedSync plan changes, update access level
customer.subscription.deletedRevoke access, send cancellation email
invoice.payment_succeededExtend subscription period, send receipt
invoice.payment_failedSend 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/stripe

Trigger specific events for testing:

stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed

The 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.


stripenode.jsnestjspaymentswebhooks

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