Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 052 / 100

Stripe with NestJS: a practical Payments & Stripe guide

Payments in a structured Node backend.

Expona Admin8 min read

By Day 52 of the 100-day Payment Engineering series, you've likely encountered the reality of payment systems: structure and reliability matter as much as correctness. Stripe with NestJS: a practical Payments & Stripe guide shows you how to build a Stripe integration that scales without surprises. We'll walk through the architectural patterns that handle webhooks safely, process payments idempotently, and keep your payment flow decoupled from your request handlers.

Stripe Module — Provide the client via dependency injection

In NestJS, the module system is your foundation. Rather than scattering Stripe client initialization across your codebase, create a dedicated Stripe module that exposes the Stripe instance via dependency injection. This approach keeps your payment logic testable and your configuration centralized.

Start by wrapping the Stripe client in a provider. NestJS providers are singletons by default, so you'll instantiate Stripe once and reuse it throughout your application.

typescript
import { Module } from '@nestjs/common';
import Stripe from 'stripe';

const STRIPE_PROVIDER = 'STRIPE_CLIENT';

export const stripeProvider = {
  provide: STRIPE_PROVIDER,
  useFactory: () => {
    return new Stripe(process.env.STRIPE_SECRET_KEY, {
      apiVersion: '2023-10-16',
    });
  },
};

@Module({
  providers: [stripeProvider],
  exports: [stripeProvider],
})
export class StripeModule {}

Once your module is in place, inject the Stripe client into services that need it. A typical payment service might handle charge creation, refunds, and payment intent management.

typescript
import { Injectable, Inject } from '@nestjs/common';
import Stripe from 'stripe';

const STRIPE_PROVIDER = 'STRIPE_CLIENT';

@Injectable()
export class PaymentService {
  constructor(@Inject(STRIPE_PROVIDER) private stripe: Stripe) {}

  async createPaymentIntent(
    amount: number,
    currency: string,
    customerId: string,
  ) {
    return this.stripe.paymentIntents.create({
      amount,
      currency,
      customer: customerId,
      confirmation_method: 'manual',
    });
  }

  async retrieveCharge(chargeId: string) {
    return this.stripe.charges.retrieve(chargeId);
  }
}

This pattern keeps payment logic isolated. When you write unit tests, you can mock the Stripe provider without touching your actual Stripe account. In production, the single Stripe instance is reused across all concurrent requests, reducing overhead.

Raw body — Enable rawBody for webhook verification

Stripe webhooks are signed. Before you process any webhook event, you must verify that it genuinely came from Stripe by checking the signature against the raw request body. If you allow NestJS to parse the body as JSON before you verify it, you lose the ability to confirm authenticity.

NestJS middleware lets you capture the raw request body and attach it to the request object. Create a middleware that does this before the JSON body parser runs.

typescript
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class RawBodyMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    let rawBody = '';

    req.on('data', (chunk) => {
      rawBody += chunk.toString('utf8');
    });

    req.on('end', () => {
      (req as any).rawBody = rawBody;
      next();
    });
  }
}

// In your app module, apply the middleware to webhook routes only
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(RawBodyMiddleware)
      .forRoutes('/webhook/stripe');
  }
}

Alternatively, if you're using the Express adapter, you can enable raw body parsing globally for specific routes using the `bodyParser` configuration. However, capturing it yourself gives you full control and makes the intent clear.

Webhook controller — Verify, enqueue and return 200

Your webhook controller has one job: verify the signature, enqueue the event for async processing, and return a 200 response immediately. Do not perform long-running operations in the webhook handler. Stripe will retry if you don't respond within a timeout, and slow handlers lead to duplicate event processing.

typescript
import { Controller, Post, Req, HttpCode, Inject } from '@nestjs/common';
import { Request } from 'express';
import Stripe from 'stripe';
import { Queue } from 'bullmq';
import { PAYMENT_QUEUE } from './payment.queue';

@Controller('webhook')
export class WebhookController {
  constructor(
    @Inject('STRIPE_CLIENT') private stripe: Stripe,
    @Inject(PAYMENT_QUEUE) private paymentQueue: Queue,
  ) {}

  @Post('stripe')
  @HttpCode(200)
  async handleStripeWebhook(@Req() req: Request) {
    const sig = req.headers['stripe-signature'] as string;
    const rawBody = (req as any).rawBody;

    let event: Stripe.Event;

    try {
      event = this.stripe.webhooks.constructEvent(
        rawBody,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET,
      );
    } catch (err) {
      console.error('Webhook signature verification failed:', err.message);
      throw new BadRequestException('Invalid signature');
    }

    // Enqueue the event for async processing
    await this.paymentQueue.add('process-event', { event }, {
      attempts: 3,
      backoff: {
        type: 'exponential',
        delay: 2000,
      },
    });

    return { received: true };
  }
}

Notice that the controller returns immediately after enqueuing. The actual event processing happens asynchronously. This pattern protects you from timeouts and gives you room to handle errors without leaving Stripe hanging.

Workers — BullMQ jobs process events idempotently

BullMQ is a Redis-backed job queue library. When your webhook controller enqueues an event, BullMQ stores it in Redis and a worker process picks it up. If the worker crashes mid-processing, BullMQ will retry the job automatically. This resilience is essential for payment systems where missing an event can leave your records out of sync with Stripe.

To process events idempotently, store an idempotency key or event ID in your database before making any state changes. If a job runs twice, the second run will see that the event was already processed and skip it.

typescript
import { Processor, Process, OnWorkerEvent } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { Injectable } from '@nestjs/common';
import Stripe from 'stripe';
import { EventLogService } from './event-log.service';
import { OrderService } from './order.service';

@Processor('payment-queue')
export class PaymentWorker {
  constructor(
    private eventLogService: EventLogService,
    private orderService: OrderService,
  ) {}

  @Process('process-event')
  async processPaymentEvent(job: Job) {
    const { event } = job.data as { event: Stripe.Event };

    // Check if we've already processed this event
    const existingLog = await this.eventLogService.findByEventId(event.id);
    if (existingLog) {
      console.log(`Event ${event.id} already processed`);
      return { skipped: true };
    }

    // Log the event immediately to prevent re-processing
    await this.eventLogService.create({
      eventId: event.id,
      type: event.type,
      processedAt: new Date(),
      status: 'processing',
    });

    try {
      // Handle specific event types
      switch (event.type) {
        case 'charge.succeeded':
          await this.handleChargeSucceeded(event.data.object as Stripe.Charge);
          break;
        case 'charge.failed':
          await this.handleChargeFailed(event.data.object as Stripe.Charge);
          break;
        case 'customer.subscription.updated':
          await this.handleSubscriptionUpdated(
            event.data.object as Stripe.Subscription,
          );
          break;
        default:
          console.log(`Unhandled event type: ${event.type}`);
      }

      // Mark event as processed
      await this.eventLogService.updateStatus(event.id, 'completed');
    } catch (error) {
      console.error(`Error processing event ${event.id}:`, error);
      await this.eventLogService.updateStatus(event.id, 'failed');
      throw error; // BullMQ will retry
    }
  }

  private async handleChargeSucceeded(charge: Stripe.Charge) {
    const orderId = charge.metadata?.orderId;
    if (!orderId) return;

    await this.orderService.markAsPaid(orderId, charge.id);
  }

  private async handleChargeFailed(charge: Stripe.Charge) {
    const orderId = charge.metadata?.orderId;
    if (!orderId) return;

    await this.orderService.markAsFailedPayment(orderId, charge.failure_message);
  }

  private async handleSubscriptionUpdated(subscription: Stripe.Subscription) {
    const customerId = subscription.customer as string;
    // Handle subscription logic
  }

  @OnWorkerEvent('failed')
  onFailed(job: Job, err: Error) {
    console.error(`Job ${job.id} failed:`, err.message);
  }
}

The key to idempotency is the event log table. Before processing, check if this event ID exists. If it does, skip processing. If it doesn't, create a record immediately, then proceed. This guard prevents duplicate charges, duplicate refunds, and duplicate subscription updates even if your worker restarts mid-job.

Common mistakes

  • Processing webhooks synchronously: If you try to update your database or call external APIs inside the webhook handler, you risk timeouts. Stripe will retry the webhook, and you'll process the same event twice. Always enqueue and return 200 immediately.
  • Forgetting to verify signatures: Skipping webhook signature verification is a security hole. Always call constructEvent with the raw body and secret. Do not parse the JSON body first.
  • Ignoring idempotency: Without an event log, duplicate webhook deliveries will create duplicate orders, duplicate charges, or inconsistent records. Store the Stripe event ID before processing.
  • Not handling BullMQ retries: If your worker throws an error, BullMQ will retry. Make sure your error handling is robust and your operations are truly idempotent on retry.
  • Losing the raw body: If you apply JSON parsing middleware globally before your webhook handler, the raw body is gone and signature verification will fail. Always capture raw body for webhooks.

Key takeaways

  • Use a dedicated Stripe module with dependency injection to keep your payment client centralized and testable.
  • Capture raw request body via middleware before JSON parsing so webhook signature verification works correctly.
  • Return a 200 response from your webhook handler immediately after enqueuing the event; do not process long-running operations synchronously.
  • Use BullMQ to process events asynchronously with automatic retries and exponential backoff.
  • Store an event log with Stripe event IDs to guarantee idempotent processing and prevent duplicate state changes.
  • Handle different event types in your worker and update your domain models (orders, subscriptions, customers) based on Stripe's ground truth.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles