Skip to content
← Back to blog
NestJSStripeSubscriptionsPaymentsSaaSTutorial

How to Add Stripe Subscriptions to NestJS: Complete 2026 Guide

Firas Sayah·July 18, 2026·

Why Stripe Subscriptions Are the Foundation of SaaS Revenue

The first time I integrated Stripe into a production NestJS app, I thought it would take a weekend. It took three weeks. Not because the Stripe API is hard -- it is actually well-designed -- but because the edge cases are brutal. What happens when a webhook fires twice? What if the customer's card fails mid-trial? What about prorations when switching plans? I debugged all of these so you do not have to.

Code displayed on monitor screen

Every SaaS product needs recurring billing. Stripe handles the complexity of payment processing, tax calculations, invoicing, and dunning — so you can focus on your product. But integrating Stripe subscriptions into NestJS requires careful architecture: webhook idempotency, subscription state synchronization, and a clean module structure that scales.

This guide walks you through a complete, production-ready Stripe integration in NestJS. No shortcuts, no toy examples — this is what actually ships. If you are new to NestJS, start with our NestJS REST API tutorial first.

Step 1: Project Setup and Configuration

Install the Stripe SDK and configure your environment:

npm install stripe

Add your keys to .env:

STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_live_...

Create a strongly-typed configuration module:

// stripe.config.ts
import { registerAs } from '@nestjs/config';

export const stripeConfig = registerAs('stripe', () => ({
  secretKey: process.env.STRIPE_SECRET_KEY,
  webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
  publishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
}));

Cloudrix SaaS Starter ships with this pre-configured and tested.

Skip weeks of boilerplate — auth, payments, multi-tenancy, and deployment included out of the box.

Try the live demo →

Step 2: The Stripe Module

Encapsulate all Stripe logic in a dedicated module. This keeps your payment code isolated and testable:

// stripe.module.ts
@Module({
  imports: [ConfigModule, TypeOrmModule.forFeature([Subscription, Organization])],
  providers: [StripeService, StripeWebhookService],
  controllers: [StripeController, StripeWebhookController],
  exports: [StripeService],
})
export class StripeModule {}

Initialize the Stripe client as an injectable service:

// stripe.service.ts
@Injectable()
export class StripeService {
  private stripe: Stripe;

  constructor(private configService: ConfigService) {
    this.stripe = new Stripe(this.configService.get('stripe.secretKey'), {
      apiVersion: '2025-12-18.acacia',
    });
  }
}

Step 3: Create Products and Prices

Define your plans in Stripe's dashboard or via the API. Here is how to sync them programmatically:

async createProduct(name: string, prices: PriceConfig[]): Promise<Stripe.Product> {
  const product = await this.stripe.products.create({
    name,
    metadata: { app: 'your-saas' },
  });

  for (const price of prices) {
    await this.stripe.prices.create({
      product: product.id,
      unit_amount: price.amount,
      currency: 'usd',
      recurring: { interval: price.interval },
      metadata: { tier: price.tier },
    });
  }

  return product;
}

Step 4: Checkout Sessions

Checkout Sessions are the safest way to collect payment. Stripe hosts the payment form, handles 3D Secure, and manages PCI compliance:

async createCheckoutSession(
  organizationId: string,
  priceId: string,
  userId: string,
): Promise<Stripe.Checkout.Session> {
  const org = await this.orgRepo.findOneOrFail({ where: { id: organizationId } });

  let customerId = org.stripeCustomerId;
  if (!customerId) {
    const customer = await this.stripe.customers.create({
      metadata: { organizationId, createdBy: userId },
    });
    customerId = customer.id;
    await this.orgRepo.update(organizationId, { stripeCustomerId: customerId });
  }

  return this.stripe.checkout.sessions.create({
    customer: customerId,
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${this.appUrl}/billing?status=success`,
    cancel_url: `${this.appUrl}/billing?status=cancelled`,
    metadata: { organizationId },
  });
}

Step 5: Webhook Handling — The Critical Piece

Webhooks are where subscriptions actually get recorded. Never trust the client redirect — always rely on webhooks for state changes. If your app is multi-tenant, make sure your webhook handler resolves the correct tenant context before updating subscription records.

// stripe-webhook.controller.ts
@Controller('webhooks/stripe')
export class StripeWebhookController {
  constructor(private webhookService: StripeWebhookService) {}

  @Post()
  @HttpCode(200)
  async handleWebhook(
    @Req() req: RawBodyRequest<Request>,
    @Headers('stripe-signature') signature: string,
  ) {
    const event = this.stripe.webhooks.constructEvent(
      req.rawBody,
      signature,
      this.configService.get('stripe.webhookSecret'),
    );
    await this.webhookService.processEvent(event);
  }
}

Handle the key subscription events:

// stripe-webhook.service.ts
@Injectable()
export class StripeWebhookService {
  async processEvent(event: Stripe.Event): Promise<void> {
    // Idempotency check — never process the same event twice
    const existing = await this.eventRepo.findOne({
      where: { stripeEventId: event.id },
    });
    if (existing) return;

    await this.eventRepo.save({ stripeEventId: event.id, type: event.type });

    switch (event.type) {
      case 'checkout.session.completed':
        await this.handleCheckoutComplete(event.data.object);
        break;
      case 'customer.subscription.updated':
        await this.handleSubscriptionUpdate(event.data.object);
        break;
      case 'customer.subscription.deleted':
        await this.handleSubscriptionCancelled(event.data.object);
        break;
      case 'invoice.payment_failed':
        await this.handlePaymentFailed(event.data.object);
        break;
    }
  }
}

Step 6: Subscription State Synchronization

Map Stripe subscription statuses to your internal model:

async handleSubscriptionUpdate(sub: Stripe.Subscription): Promise<void> {
  const orgId = sub.metadata.organizationId;

  await this.subscriptionRepo.upsert({
    organizationId: orgId,
    stripeSubscriptionId: sub.id,
    status: this.mapStatus(sub.status),
    currentPeriodEnd: new Date(sub.current_period_end * 1000),
    cancelAtPeriodEnd: sub.cancel_at_period_end,
    priceId: sub.items.data[0].price.id,
  }, ['organizationId']);
}

private mapStatus(stripeStatus: string): SubscriptionStatus {
  const statusMap: Record<string, SubscriptionStatus> = {
    active: SubscriptionStatus.ACTIVE,
    past_due: SubscriptionStatus.PAST_DUE,
    canceled: SubscriptionStatus.CANCELLED,
    trialing: SubscriptionStatus.TRIALING,
    unpaid: SubscriptionStatus.UNPAID,
  };
  return statusMap[stripeStatus] ?? SubscriptionStatus.INACTIVE;
}

Step 7: Customer Portal for Self-Service

Let customers manage their own subscriptions — update payment methods, switch plans, cancel — without building any UI:

async createPortalSession(organizationId: string): Promise<Stripe.BillingPortal.Session> {
  const org = await this.orgRepo.findOneOrFail({ where: { id: organizationId } });

  return this.stripe.billingPortal.sessions.create({
    customer: org.stripeCustomerId,
    return_url: `${this.appUrl}/billing`,
  });
}

Configure your portal in the Stripe Dashboard to allow plan changes, cancellations, and payment method updates.

Want all of this pre-built and tested? SaaS Starter includes the complete Stripe subscription integration shown in this guide, with checkout, webhooks, customer portal, and subscription guards -- plus authentication and multi-tenancy. 55+ tests included. Try the free lite version →

Laptop showing development environment

Step 8: Subscription Guards

Protect routes based on subscription status:

@Injectable()
export class SubscriptionGuard implements CanActivate {
  constructor(private subscriptionService: SubscriptionService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const orgId = request.user.organizationId;
    const sub = await this.subscriptionService.getActive(orgId);
    if (!sub) throw new ForbiddenException('Active subscription required');
    return true;
  }
}

// Usage
@UseGuards(AuthGuard, SubscriptionGuard)
@Controller('api/premium-features')
export class PremiumController {}

Step 9: Testing Webhooks Locally

Use the Stripe CLI to forward webhooks to your local server:

stripe listen --forward-to localhost:3000/webhooks/stripe

This gives you a temporary webhook signing secret for local development. Test each event type:

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

Common Pitfalls to Avoid

  1. Skipping idempotency: Stripe may send the same webhook multiple times. Always deduplicate by event ID.
  2. Trusting the redirect: The success URL redirect is for UX only. The webhook is the source of truth.
  3. Not handling past_due: A failed payment does not immediately cancel. Handle the grace period.
  4. Raw body parsing: Webhook signature verification requires the raw request body. Configure your NestJS app to preserve it on the webhook route.
  5. Hardcoding prices: Store price IDs in your database or config, not in code. You will change them.

Skip the Boilerplate — Start with SaaS Starter

This guide covers the Stripe integration layer, but a real SaaS also needs authentication, multi-tenancy, role-based access, email notifications, and an admin dashboard. That is weeks of additional work.

Dashboard UI showing subscription analytics

SaaS Starter ships all of this out of the box — including a complete Stripe subscription integration with checkout, webhooks, customer portal, and subscription guards. The free Lite version gives you the core NestJS + Angular architecture to evaluate before committing.

Try the live demo and see subscriptions working in a real multi-tenant environment. Clone, configure your Stripe keys, and you are billing customers today — not next month.

Pricing:

  • Lite (Free) — Core architecture to explore
  • Pro ($149) — Auth + Stripe billing + dashboard
  • Business ($249) — Multi-tenancy + RBAC + advanced features
  • Enterprise ($399) — Everything + priority support

View pricing | Try the live demo | Get the free lite version

Related posts: NestJS + Angular vs Next.js comparison | Multi-tenancy architecture guide | Deploy to AWS with Terraform

F

Firas Sayah

Senior Software Engineer

Full-stack developer with 5+ years building production SaaS applications with NestJS and Angular.