Back to Blog

Stripe Subscription Billing in NestJS — Complete Implementation With Webhooks

Published: 2026-06-23
Stripe Subscription Billing in NestJS — Complete Implementation With Webhooks

A quarter of your SaaS churn is not product churn — it is a invoice.payment_failed webhook event that nobody handled. Roughly 0.8 points of the average 3.5% monthly churn is involuntary: failed payments, expired cards, and silent subscription cancellations that have nothing to do with whether your product is any good (industry benchmarks, 2024). That is revenue you already earned, walking out the door because the webhook handler for "card declined, try again" was never written.

This is the article I wish we had when we built our first Stripe integration — a complete Stripe subscription billing implementation in NestJS that handles every event in the subscription lifecycle, not just the happy path. We scattered our first billing code across three microservices, mixed the webhook handler in with the API controllers, and learned about Stripe's event ordering the hard way: when a customer.subscription.updated arrived before the checkout.session.completed that should have preceded it, and our database briefly thought the new subscriber had a cancelled subscription.

This post covers everything we do differently now: product and price setup, customer creation, checkout, webhook handling with signature verification, every critical event, dunning management, database syncing, and idempotency. If you are building NestJS billing for the first time, this is the implementation you reach for instead of the one you debug at midnight.

Stripe subscription billing implementation in NestJS — a laptop displaying an online checkout form representing the subscription checkout flow

Stripe Product and Price Setup

Before any code, you need the billing primitives. In Stripe, a Product is what you sell (e.g., "Pro Plan"), and a Price is what it costs and how often (e.g., "$29/month" or "$290/year"). They are two separate resources because one product can have multiple prices — monthly, annual, enterprise custom.

You can create these in the Stripe Dashboard, but for a repeatable setup that matches your environment (test vs production), define them in a NestJS seeder or migration:

TypeScript
1import { Injectable } from '@nestjs/common';
2import Stripe from 'stripe';
3
4@Injectable()
5export class StripeSetupService {
6  constructor(private readonly stripe: Stripe) {}
7
8  async createProductsAndPrices() {
9    const product = await this.stripe.products.create({
10      name: 'Pro Plan',
11      description: 'Everything in Starter plus API access and team members',
12    });
13
14    const monthlyPrice = await this.stripe.prices.create({
15      product: product.id,
16      unit_amount: 2900,
17      currency: 'usd',
18      recurring: { interval: 'month' },
19    });
20
21    const annualPrice = await this.stripe.prices.create({
22      product: product.id,
23      unit_amount: 29000,
24      currency: 'usd',
25      recurring: { interval: 'year' },
26    });
27
28    return { product, monthlyPrice, annualPrice };
29  }
30}

Store the returned Price IDs (price_xxx) in your environment config or database. Your frontend references these IDs when creating checkout sessions. Never hardcode them — you will want different Price IDs for test and production.

Creating Customers on Signup

Every Stripe subscription is tied to a Customer. Create one during user registration and store the returned cus_xxx ID in your users table:

TypeScript
1@Injectable()
2export class SubscriptionService {
3  constructor(
4    private readonly stripe: Stripe,
5    @InjectRepository(User) private readonly userRepo: Repository<User>,
6  ) {}
7
8  async createCustomer(user: User): Promise<string> {
9    const customer = await this.stripe.customers.create({
10      email: user.email,
11      metadata: { userId: user.id },
12    });
13
14    await this.userRepo.update(user.id, { stripeCustomerId: customer.id });
15
16    return customer.id;
17  }
18}

Why create the customer on signup instead of lazily at checkout? Because downstream events — trial reminders, invoice failures, dunning emails — all reference the customer ID. If you defer customer creation until checkout and the checkout fails, you have a user in your database with no Stripe customer record, and every subsequent billing operation has to handle the missing-ID edge case. Create it early. It is cheap and it makes every other handler simpler.

Checkout Session Flow

When a user clicks "Subscribe," your backend creates a Stripe Checkout Session and returns the URL for the frontend to redirect to:

TypeScript
1async createCheckoutSession(
2  customerId: string,
3  priceId: string,
4  successUrl: string,
5  cancelUrl: string,
6): Promise<Stripe.Checkout.Session> {
7  const session = await this.stripe.checkout.sessions.create({
8    customer: customerId,
9    mode: 'subscription',
10    line_items: [{ price: priceId, quantity: 1 }],
11    success_url: successUrl,
12    cancel_url: cancelUrl,
13    metadata: { source: 'web_app' },
14  });
15
16  return session;
17}

The frontend calls this endpoint, gets back the session URL, and calls window.location.href = session.url. Stripe handles the rest — card collection, confirmation, receipt email.

A note on the cancel URL: Stripe redirects here when the user clicks "back" during checkout. Do not redirect to your pricing page and hope they try again. Redirect to a "Still interested? Here is a discount" page if you have one, or capture the query parameters and trigger a follow-up email sequence. The gap between "started checkout" and "completed checkout" is the most expensive 90 seconds in your SaaS, and most teams leave money on the table by treating the cancel URL as a throwaway.

Webhook Handler Setup With Signature Verification

This is where most implementations go slightly wrong. Stripe sends webhooks from its servers to your endpoint, and you need to verify that the request actually came from Stripe. The verification requires the raw request body — the exact bytes Stripe sent — because the signature is computed against the raw payload, not the parsed JSON.

In NestJS, the default JSON body parser intercepts the raw body before your webhook controller ever sees it. You need to register a raw body middleware on the webhook route specifically:

TypeScript
1// main.ts
2import { NestFactory } from '@nestjs/core';
3import { AppModule } from './app.module';
4import * as express from 'express';
5
6async function bootstrap() {
7  const app = await NestFactory.create(AppModule);
8
9  app.use('/webhooks/stripe', express.raw({ type: 'application/json' }));
10
11  await app.listen(3000);
12}
13bootstrap();

Then your webhook controller:

TypeScript
1@Controller('webhooks')
2export class WebhooksController {
3  constructor(private readonly webhooksService: WebhooksService) {}
4
5  @Post('stripe')
6  async handleStripeWebhook(
7    @Req() req: Request,
8    @Headers('stripe-signature') signature: string,
9  ) {
10    const event = this.webhooksService.constructEvent(
11      req.body,
12      signature,
13    );
14
15    return this.webhooksService.handleEvent(event);
16  }
17}

And the service:

TypeScript
1@Injectable()
2export class WebhooksService {
3  private readonly endpointSecret: string;
4
5  constructor(private readonly stripe: Stripe) {
6    this.endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
7  }
8
9  constructEvent(payload: Buffer, signature: string): Stripe.Event {
10    return this.stripe.webhooks.constructEvent(
11      payload,
12      signature,
13      this.endpointSecret,
14    );
15  }
16
17  async handleEvent(event: Stripe.Event): Promise<void> {
18    switch (event.type) {
19      case 'checkout.session.completed':
20        return this.handleCheckoutCompleted(event.data.object);
21      case 'invoice.paid':
22        return this.handleInvoicePaid(event.data.object);
23      case 'invoice.payment_failed':
24        return this.handleInvoicePaymentFailed(event.data.object);
25      case 'customer.subscription.updated':
26        return this.handleSubscriptionUpdated(event.data.object);
27      case 'customer.subscription.deleted':
28        return this.handleSubscriptionDeleted(event.data.object);
29      default:
30        console.log(`Unhandled event type: ${event.type}`);
31    }
32  }
33}

If the signature verification fails, constructEvent throws an error and Stripe knows to retry. Do not catch that error and return 200 — returning a 200 on an unverified webhook is how you accidentally process a fake event from someone who found your endpoint URL. (Yes, people scan for these.)

Handling checkout.session.completed

The checkout.session.completed event fires when the user finishes the Stripe Checkout flow. This is where you activate the subscription in your database:

TypeScript
1async handleCheckoutCompleted(session: Stripe.Checkout.Session) {
2  const subscriptionId = session.subscription as string;
3  const customerId = session.customer as string;
4
5  const subscription = await this.stripe.subscriptions.retrieve(
6    subscriptionId,
7  );
8
9  await this.subscriptionRepo.save({
10    stripeSubscriptionId: subscription.id,
11    stripeCustomerId: customerId,
12    status: subscription.status,
13    currentPeriodStart: new Date(
14      subscription.current_period_start * 1000,
15    ),
16    currentPeriodEnd: new Date(
17      subscription.current_period_end * 1000,
18    ),
19    planId: subscription.items.data[0].price.id,
20    quantity: subscription.items.data[0].quantity || 1,
21    cancelAtPeriodEnd: subscription.cancel_at_period_end,
22    userId: session.metadata?.userId,
23  });
24}

A subtle detail: fetch the full subscription object here rather than relying on the session data alone. The session object contains basic info, but the subscription object has the line items, the current period boundaries, and the metadata you need for accurate entitlement calculations.

Handling invoice.paid and invoice.payment_failed

These two events are the heartbeat of your billing system. invoice.paid confirms the payment went through. invoice.payment_failed means the card was declined, and your dunning clock starts ticking.

TypeScript
1async handleInvoicePaid(invoice: Stripe.Invoice) {
2  if (invoice.subscription) {
3    await this.subscriptionRepo.update(
4      { stripeSubscriptionId: invoice.subscription as string },
5      { status: 'active' },
6    );
7  }
8}
9
10async handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
11  if (!invoice.subscription) return;
12
13  await this.subscriptionRepo.update(
14    { stripeSubscriptionId: invoice.subscription as string },
15    { status: 'past_due' },
16  );
17
18  if (invoice.attempt_count >= 3) {
19    await this.notificationService.notifyAdmin(
20      `Subscription ${invoice.subscription} failed payment after 3 attempts`,
21    );
22  } else {
23    await this.notificationService.notifyCustomer({
24      email: invoice.customer_email,
25      subject: 'Payment failed — update your billing info',
26      template: 'payment_failed',
27      data: {
28        attempts: invoice.attempt_count,
29        nextAttempt: invoice.next_payment_attempt
30          ? new Date(invoice.next_payment_attempt * 1000).toISOString()
31          : 'soon',
32      },
33    });
34  }
35}

The attempt_count field tells you which retry you are on. Stripe's default dunning configuration retries up to three times over a configurable schedule (typically days 3, 5, and 7 after the initial failure). Track this. If you hit attempt three without success, the fallback is manual outreach — do not let Stripe silently drop the subscription without a human knowing.

Handling customer.subscription.updated

This event fires when the subscription changes — upgrades, downgrades, plan modifications, or status transitions. It can fire more often than you expect:

TypeScript
1async handleSubscriptionUpdated(
2  subscription: Stripe.Subscription,
3) {
4  await this.subscriptionRepo.update(
5    { stripeSubscriptionId: subscription.id },
6    {
7      status: subscription.status,
8      currentPeriodStart: new Date(
9        subscription.current_period_start * 1000,
10      ),
11      currentPeriodEnd: new Date(
12        subscription.current_period_end * 1000,
13      ),
14      planId: subscription.items.data[0].price.id,
15      quantity: subscription.items.data[0].quantity || 1,
16      cancelAtPeriodEnd: subscription.cancel_at_period_end,
17    },
18  );
19}

A note on event ordering: Stripe delivers webhooks with at-least-once semantics, which means duplicates are normal and order is not guaranteed. The customer.subscription.updated for a plan change can arrive before or after the associated invoice.paid. Design your database updates to be idempotent — same event, same subscription, same result.

Handling customer.subscription.deleted

This event fires when a subscription is cancelled and reaches the end of its current billing period. It is your signal to de-provision access:

TypeScript
1async handleSubscriptionDeleted(
2  subscription: Stripe.Subscription,
3) {
4  const record = await this.subscriptionRepo.findOneBy({
5    stripeSubscriptionId: subscription.id,
6  });
7
8  if (record) {
9    await this.subscriptionRepo.update(record.id, {
10      status: 'canceled',
11      canceledAt: new Date(),
12    });
13
14    await this.entitlementService.revokeAccess(record.userId);
15  }
16}

Do not de-provision access immediately when the customer clicks "cancel" in the frontend — we covered exactly that flow in our SaaS billing portal post. The subscription continues until the period end; that is what the customer paid for. Only revoke access when the customer.subscription.deleted event arrives, which Stripe sends after the period ends.

NestJS Stripe webhook handling flow — smartphone displaying Stripe app on a laptop with eCommerce site open, symbolizing payment processing integration

Dunning Management

Dunning is the process of handling failed payments gracefully — retrying the charge, notifying the customer, and escalating before the subscription is lost. It is the most underbuilt piece of most subscription billing systems, and it is where the involuntary churn lives.

Stripe subscription billing dunning flow — a hand holding a company invoice representing failed-payment recovery

Stripe has built-in dunning that handles the retry schedule, but your application needs to respond to the state changes:

TypeScript
1@Injectable()
2export class DunningService {
3  constructor(
4    private readonly stripe: Stripe,
5    private readonly subscriptionRepo: Repository<Subscription>,
6    private readonly notificationService: NotificationService,
7  ) {}
8
9  async handlePaymentFailure(
10    invoice: Stripe.Invoice,
11  ) {
12    const record = await this.subscriptionRepo.findOneBy({
13      stripeSubscriptionId: invoice.subscription as string,
14    });
15    if (!record) return;
16
17    await this.subscriptionRepo.update(record.id, {
18      status: 'past_due',
19      failedAttempts: invoice.attempt_count,
20      lastFailureReason: invoice.last_failure_error?.message,
21      paymentDueBy: invoice.next_payment_attempt
22        ? new Date(invoice.next_payment_attempt * 1000)
23        : null,
24    });
25
26    if (invoice.attempt_count === 1) {
27      await this.notificationService.sendEmail({
28        to: record.user.email,
29        subject: 'Payment reminder — your subscription is safe',
30        template: 'payment_retry_1',
31      });
32    } else if (invoice.attempt_count === 3) {
33      await this.notificationService.sendEmail({
34        to: record.user.email,
35        subject: 'Action required — subscription at risk',
36        template: 'payment_retry_final',
37      });
38    }
39  }
40}

The dunning configuration in Stripe's Dashboard controls the retry schedule — you can set intervals like "retry after 3 days, then 5 days, then 7 days." Your code responds to the resulting invoice.payment_failed events. The combination of Stripe's retry engine and your notification logic covers the full dunning cycle.

One pattern we added after losing a subscription to a silent failure: monitor the invoice.payment_failed event at attempt count zero. Stripe sends the event even on the initial payment attempt, not just retries. If the first attempt fails, you know immediately and can prompt the customer to update their card while they are still engaged — not three days later when they have forgotten they signed up.

Syncing Stripe State to Your Database

The golden rule of Stripe billing: your database is the source of truth for entitlements, and Stripe is the source of truth for payments. Sync both directions.

When a webhook arrives, update your local subscription record. Do not query Stripe on every request to check if the user is still subscribed — that couples your application's uptime to Stripe's API and adds latency to every authenticated request.

Your subscriptions table should mirror the relevant Stripe fields:

TypeScript
1@Entity()
2export class Subscription {
3  @PrimaryGeneratedColumn('uuid')
4  id: string;
5
6  @Column({ unique: true })
7  stripeSubscriptionId: string;
8
9  @Column()
10  stripeCustomerId: string;
11
12  @Column()
13  userId: string;
14
15  @Column()
16  status: string;
17
18  @Column()
19  planId: string;
20
21  @Column({ default: 1 })
22  quantity: number;
23
24  @Column()
25  currentPeriodStart: Date;
26
27  @Column()
28  currentPeriodEnd: Date;
29
30  @Column({ default: false })
31  cancelAtPeriodEnd: boolean;
32
33  @Column({ nullable: true })
34  failedAttempts: number;
35
36  @Column({ nullable: true })
37  trialEnd: Date;
38
39  @Column({ default: false })
40  isDunning: boolean;
41}

Sync this record in every webhook handler. The pattern is always: receive event, update local record, recompute entitlements. If Stripe is temporarily unreachable during a webhook delivery, your application can still authorize requests correctly using the local data — you may be a few seconds stale on the subscription state, but you are never blindly accepting or rejecting users.

Idempotency: Handling Duplicate Webhook Deliveries

Stripe delivers webhooks with at-least-once semantics. The same event can arrive multiple times, especially during network interruptions or Stripe-side retries. If your webhook handler is not idempotent, duplicate events will create duplicate database records — and in billing, a duplicate subscription record means a customer who appears to be paying twice.

The event ID is your idempotency key:

TypeScript
1@Injectable()
2export class IdempotencyService {
3  constructor(
4    @InjectRepository(IdempotencyRecord)
5    private readonly idempotencyRepo: Repository<IdempotencyRecord>,
6  ) {}
7
8  async isProcessed(eventId: string): Promise<boolean> {
9    const record = await this.idempotencyRepo.findOneBy({
10      eventId,
11    });
12    return !!record;
13  }
14
15  async markProcessed(eventId: string): Promise<void> {
16    await this.idempotencyRepo.insert({
17      eventId,
18      processedAt: new Date(),
19    });
20  }
21}

The event ID from the Stripe event object is your idempotency key. Store it with a unique constraint at the database level so simultaneous duplicate deliveries only succeed once:

TypeScript
1@Entity()
2export class IdempotencyRecord {
3  @PrimaryColumn()
4  eventId: string;
5
6  @Column()
7  processedAt: Date;
8}

In your webhook handler:

TypeScript
1async handleEvent(event: Stripe.Event): Promise<void> {
2  if (await this.idempotencyService.isProcessed(event.id)) {
3    return { received: true, duplicate: true };
4  }
5
6  await this.idempotencyService.markProcessed(event.id);
7
8  switch (event.type) {
9    // ... handle events
10  }
11}

The database-level unique constraint on eventId is your safety net. Even if two identical webhook requests arrive simultaneously and both pass the isProcessed check before either marks the event, the second insert will hit the unique constraint violation. Wrap the entire handler in a try-catch that catches the constraint violation and returns 200 — you processed it once, which is exactly right.

Do not use in-memory caches or Redis-only deduplication for webhook idempotency. If your process restarts between duplicate deliveries, the in-memory state is lost. Database storage of idempotency keys survives restarts, and you can add a TTL to clean up old records after 24 hours since Stripe does not redeliver events beyond that window.

The Complete Event Handling Map

Here is the full routing of events to actions in a typical production billing system:

EventAction
checkout.session.completedCreate local subscription record, activate entitlements
invoice.paidUpdate subscription status to active, extend period end
invoice.payment_failedSet status to past_due, increment failure counter, notify customer
customer.subscription.updatedSync plan changes, status transitions, period boundaries
customer.subscription.deletedSet status to canceled, de-provision access at period end
customer.subscription.trial_will_endSend trial-ending reminder (optional but recommended)

If you only handle the first two events and ignore the rest, your database will consistently be wrong about who has an active subscription and who does not. The invoice.payment_failed handler is specifically where the "quarter of churn is involuntary" problem lives — skip that handler and you have no idea which of your past_due customers are about to lose access.

Stripe Subscription Billing: The Short Version

Stripe subscription billing in NestJS is not complicated. The code for each handler is fifteen to twenty lines. The complexity is in the edge cases: the webhook that arrives twice, the subscription update that arrives before the checkout completion, the failed payment that needs a human to follow up, the customer whose card expired while they were on vacation and who only notices when their dashboard stops loading.

The pattern we use across every SaaS project now is simple: create the Stripe customer on signup, sync subscription state to a local table, handle every webhook event in the subscription lifecycle, store idempotency keys in the database, and build dunning notifications that give customers a chance to fix their payment before losing access. None of it is clever. All of it is necessary.

We covered API design patterns for the endpoints that interact with this billing system in our REST API mistakes post, and the billing portal UI that customers use to manage their subscriptions is in the frontend billing portal guide. This post is the backend that makes that portal actually work. Between the three, you have a complete Stripe subscription billing system that handles the happy path and the failure modes equally.

Pick the webhook you are not handling yet and write the handler. It is the cheapest retention improvement you will make this quarter.

Frequently Asked Questions

Use the stripe.webhooks.constructEvent() method with the raw request body and your webhook signing secret. The key in NestJS is that the webhook endpoint must receive the raw body before any JSON body parser transforms it, because the signature is computed against the raw payload. Use a raw body middleware registered before the global NestJS JSON parser.

Handle at minimum checkout.session.completed, invoice.paid, invoice.payment_failed, customer.subscription.updated, and customer.subscription.deleted. These five events cover the complete subscription lifecycle — signup, successful payments, failed payments, plan changes, and cancellations. Add customer.subscription.trial_will_end if you offer trial periods.

Use idempotency keys. Stripe delivers webhooks with an Idempotency-Key header you can store in your database after processing each event. Before processing a webhook, check if an idempotency record with that key already exists. Use a unique constraint at the database level to handle race conditions where duplicate webhooks arrive simultaneously.

Create a subscriptions table in your database that mirrors the Stripe subscription state — status, current period start and end, plan ID, and metadata. Update this record inside each webhook handler so your database always reflects Stripe's truth. Never query Stripe in real-time for authorization checks during critical application paths.

Listen for the invoice.payment_failed event. When it fires, update the subscription status to past_due in your database, notify the customer, and let Stripe's built-in dunning process handle retries. Stripe retries the payment based on your dunning configuration with up to three retries over a configurable period. Track the number of failed attempts and escalate to manual outreach if Stripe's retries are exhausted.

Yes, store the Stripe customer ID in your users table. Create the Stripe customer during user signup, store the returned customer ID, and use it for all subsequent Stripe API calls. This avoids creating duplicate Stripe customers or needing to look up customers by email, which is unreliable if users can change their email address.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio