How to Add Stripe Subscriptions to NestJS: Complete 2026 Guide
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.
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 →
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
- Skipping idempotency: Stripe may send the same webhook multiple times. Always deduplicate by event ID.
- Trusting the redirect: The success URL redirect is for UX only. The webhook is the source of truth.
- Not handling
past_due: A failed payment does not immediately cancel. Handle the grace period. - Raw body parsing: Webhook signature verification requires the raw request body. Configure your NestJS app to preserve it on the webhook route.
- 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.
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