DEV Community

Aniefon Umanah
Aniefon Umanah

Posted on

Building Reliable Stripe Subscriptions in NestJS: Webhook Idempotency and Optimistic Locking

Subscriptions power modern SaaS apps, but integrating payment providers like Stripe requires handling webhooks reliably. In a recent commit to the CommitLore backend, I implemented a full subscription system using Stripe. The standout technical choice was ensuring idempotent webhook processing to avoid duplicate updates during retries or failures.

This approach uses a dedicated webhook_events table to track Stripe events and optimistic locking on the subscriptions table to prevent race conditions. It's a practical pattern for any NestJS app dealing with external async events. Let's break it down.

Why Idempotency Matters for Stripe Webhooks

Stripe webhooks notify your server of events like subscription upgrades, cancellations, or payment failures. But networks are unreliable—events can arrive multiple times, out of order, or during downtime. Without safeguards, this leads to duplicate charges, inconsistent subscription states, or infinite loops.

The solution: Store each webhook in a database first, process it only once, and update the subscription atomically. This commit adds:

  • A new webhook_events table to log incoming events with status tracking.
  • Optimistic locking via a version column on subscriptions to detect concurrent modifications.

This prevents issues like double-processing a customer.subscription.updated event, which could incorrectly downgrade a plan.

The Database Schema: Tracking Webhook Events

The migration creates a simple but robust table. Here's the key SQL from the TypeORM migration:

// src/database/migrations/1764287633270-AddWebhookEventsAndOptimisticLocking.ts
await queryRunner.query(`
  CREATE TABLE "webhook_events" (
    "id" uuid NOT NULL DEFAULT uuid_generate_v4(),
    "createdAt" TIMESTAMP NOT NULL DEFAULT now(),
    "updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
    "stripeEventId" character varying(255) NOT NULL,
    "eventType" character varying(100) NOT NULL,
    "status" character varying(20) NOT NULL DEFAULT 'processing',
    "errorMessage" text,
    "processedAt" TIMESTAMP,
    CONSTRAINT "UQ_2321876280160661b78693399a5" UNIQUE ("stripeEventId"),
    CONSTRAINT "PK_4cba37e6a0acb5e1fc49c34ebfd" PRIMARY KEY ("id")
  )
`);
await queryRunner.query(`CREATE INDEX "IDX_2321876280160661b78693399a" ON "webhook_events" ("stripeEventId")`);
await queryRunner.query(`ALTER TABLE "subscriptions" ADD "version" integer NOT NULL DEFAULT 1`);
Enter fullscreen mode Exit fullscreen mode
  • Unique stripeEventId: Ensures no duplicates—Stripe guarantees this ID is unique per event.
  • Status enum (processing, processed, failed): Tracks lifecycle for retries or debugging.
  • Index on stripeEventId: Fast lookups during webhook receipt.

The entity looks like this:

// src/modules/subscriptions/domain/entities/webhook-event.entity.ts
@Entity('webhook_events')
export class WebhookEvent extends BaseEntity {
  @Column({ type: 'varchar', length: 255, unique: true })
  @Index()
  stripeEventId: string;

  @Column({ type: 'varchar', length: 100 })
  eventType: string;

  @Column({ type: 'varchar', length: 20, default: WebhookEventStatus.PROCESSING })
  status: WebhookEventStatus;

  @Column({ type: 'text', nullable: true })
  errorMessage?: string;

  @Column({ type: 'timestamp', nullable: true })
  processedAt?: Date;
}
Enter fullscreen mode Exit fullscreen mode

Handling Webhooks in the Use Case

The handle-stripe-webhook.use-case.ts (expanded in this commit) follows a clear flow:

  1. Verify and Parse: Use Stripe's SDK to construct the event from payload and signature.
  2. Idempotency Check: Query webhook_events by stripeEventId. If already processed, return 200 early.
  3. Store Event: Insert as "processing".
  4. Process Event: Switch on eventType to update subscriptions (e.g., upgrade on customer.subscription.updated).
  5. Atomic Update: Use optimistic locking—TypeORM's @VersionColumn() throws if the version mismatches.
  6. Mark Complete: Update to "processed" or "failed" with error details.

Here's a simplified excerpt of the processing logic:

// Pseudo-code from handle-stripe-webhook.use-case.ts
async execute(payload: Buffer, signature: string) {
  const event = stripe.constructWebhookEvent(payload, signature);
  const existing = await this.webhookEventRepository.findByStripeEventId(event.id);

  if (existing?.status === WebhookEventStatus.PROCESSED) {
    return { success: true }; // Idempotent skip
  }

  const webhookEvent = await this.webhookEventRepository.create({
    stripeEventId: event.id,
    eventType: event.type,
  });

  try {
    switch (event.type) {
      case 'customer.subscription.updated':
        await this.updateSubscription(subscriptionId, event.data.object);
        break;
      // ... other cases like 'invoice.payment_failed'
    }
    await this.webhookEventRepository.updateStatus(webhookEvent.id, WebhookEventStatus.PROCESSED, undefined, new Date());
  } catch (error) {
    await this.webhookEventRepository.updateStatus(webhookEvent.id, WebhookEventStatus.FAILED, error.message);
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

The optimistic lock shines in updateSubscription:

// In subscription entity
@VersionColumn()
version: number; // Increments on each save, fails save if concurrent change detected
Enter fullscreen mode Exit fullscreen mode

If two webhooks hit simultaneously (rare but possible during retries), TypeORM rolls back the conflicting update, letting the other succeed.

Cleanup and Monitoring

To avoid table bloat, a CleanupWebhookEventsUseCase deletes processed events older than 30 days:

// src/modules/subscriptions/application/use-cases/cleanup-webhook-events.use-case.ts
async execute(retentionDays: number = 30): Promise<number> {
  const cutoffDate = new Date();
  cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
  return await this.webhookEventRepository.deleteProcessedOlderThan(cutoffDate);
}
Enter fullscreen mode Exit fullscreen mode

Schedule this via cron in production. For monitoring, log failures and expose metrics on unprocessed events.

Gotchas and Lessons

  • Signature Verification: Always verify webhook signatures first—Stripe's test mode uses different keys.
  • Retry Logic: Stripe retries failed webhooks, so idempotency is non-negotiable. But don't process "failed" events again unless you implement custom retries.
  • Version Conflicts: In high-traffic apps, optimistic locking might conflict often; consider pessimistic if needed, but it's overkill here.
  • Testing: Use Stripe CLI for local webhooks. Mock the repo in unit tests to simulate duplicates.

This setup scales well—events are lightweight, and the unique constraint catches issues early. For a NestJS + Stripe combo, it's a solid foundation. If you're building payments, start with this pattern to save debugging headaches later.

Top comments (0)