Building Subscription Payments: Stripe from Design to Production
Payments are the most unforgiving part of any SaaS product — a bug here means lost revenue. We built Stripe's full subscription flow into a microservices architecture, including webhook retry logic, billing aggregation, and the edge cases nobody warns you about.
Payments are the place where you cannot afford a silent failure. A missed webhook, a double-charge, a subscription that activates but doesn't provision — all of these cost real money and real users. At Fygurs we built the full Stripe subscription flow into our microservices architecture. The API Gateway handles webhook verification; the Payment service owns every Stripe SDK call. Here's the architecture and the edge cases that forced us to design it this way.
What is Stripe?
Stripe is a payment infrastructure platform that handles the complexity of online payments. Instead of building payment processing, fraud detection, and compliance systems yourself, Stripe provides APIs that abstract these concerns into simple function calls.
Why Stripe for SaaS?
- Subscription billing: Built-in support for recurring payments, trials, and plan changes
- Global payments: Accept 135+ currencies with automatic conversion
- PCI compliance: Stripe handles sensitive card data, reducing your compliance burden
- Developer experience: Excellent documentation, SDKs for every language, and testing tools
- Hosted pages: Pre-built Checkout and Customer Portal reduce frontend work
Stripe Core Concepts
Understanding Stripe's data model is essential before integration. These objects form the foundation of any payment system.
STRIPE OBJECT RELATIONSHIPS
┌──────────────┐
│ Customer │ ────────────────────────────────────┐
│ cus_xxx │ │
└──────┬───────┘ │
│ has many │ owns
▼ ▼
┌───────────────┐ ┌───────────────┐ ┌─────────────┐
│ PaymentMethod │ │ Subscription │────────▶│ Invoice │
│ pm_xxx │ │ sub_xxx │generates│ in_xxx │
└───────────────┘ └───────┬───────┘ └─────────────┘
│
│ contains
▼
┌─────────────┐
│ Price │
│ price_xxx │
└──────┬──────┘
│ belongs to
▼
┌─────────────┐
│ Product │
│ prod_xxx │
└─────────────┘
Products and Prices
Stripe separates what you sell (Products) from how much it costs (Prices). A single product can have multiple prices for different billing intervals, currencies, or tiers.
- Product: Represents your offering (e.g., "Pro Plan", "Enterprise Plan")
- Price: Defines the cost and billing frequency (e.g., $29/month, $290/year)
// One product, multiple prices
const product = { id: 'prod_xxx', name: 'Pro Plan' };
const monthlyPrice = {
id: 'price_monthly',
product: 'prod_xxx',
unit_amount: 2900, // $29.00
recurring: { interval: 'month' }
};
const yearlyPrice = {
id: 'price_yearly',
product: 'prod_xxx',
unit_amount: 29000, // $290.00 (2 months free)
recurring: { interval: 'year' }
};
Customers
A Customer object stores payment methods, billing details, and links to subscriptions. Always create a Stripe customer for each user or company in your system.
Subscriptions
Subscriptions handle recurring billing automatically. Stripe creates invoices, charges payment methods, and handles failures with configurable retry logic.
- Status: active, past_due, canceled, trialing, incomplete
- Billing cycle: Anchor date determines when invoices generate
- Proration: Automatic credit/charge when changing plans mid-cycle
Payment Methods
Payment Methods represent a customer's payment instrument: cards, bank accounts, or digital wallets (Apple Pay, Google Pay). They're stored securely by Stripe and referenced by ID.
Invoices
Invoices are generated automatically for subscriptions or manually for one-time charges. They track what was charged, when, and provide PDF receipts.
Stripe Features for SaaS
Checkout Sessions
Stripe Checkout is a hosted payment page that handles the entire payment flow: card input, validation, 3D Secure, Apple Pay, and Google Pay. You redirect users to Checkout and receive a webhook when payment completes.
CHECKOUT FLOW Your App Stripe Checkout Your App │ │ │ │ 1. Create Session │ │ │ ───────────────────────▶ │ │ │ │ │ │ 2. Redirect to Checkout URL │ │ │ ◀─────────────────────── │ │ │ │ │ │ User enters payment details │ │ │ │ │ │ 3. Webhook: completed │ │ │ ───────────────────────▶│ │ │ │ │ 4. Redirect to success_url │ │ │ ◀───────────────────────────┼──────────────────────────│
Customer Portal
A hosted page where customers can manage their subscriptions, update payment methods, view invoices, and cancel plans. No frontend code required.
const session = await stripe.billingPortal.sessions.create({
customer: 'cus_xxx',
return_url: 'https://yourapp.com/billing',
});
// Redirect user to session.url
Webhooks
Webhooks notify your application when events occur in Stripe: successful payments, failed charges, subscription changes, or disputes. They're essential for keeping your database in sync with Stripe's state.
Key webhook events for SaaS:
checkout.session.completed- Payment successful, provision accesscustomer.subscription.updated- Plan changed, update entitlementscustomer.subscription.deleted- Subscription ended, revoke accessinvoice.payment_failed- Payment failed, notify userinvoice.paid- Recurring payment successful
Test Mode
Stripe provides a complete test environment with separate API keys. Test mode uses fake card numbers and simulates all payment scenarios without real charges.
# Test card numbers
4242424242424242 # Successful payment
4000000000000002 # Card declined
4000002500003155 # Requires 3D Secure
Microservices Architecture
With the fundamentals covered, let's implement Stripe in a NestJS microservices architecture. The Payment service is isolated from the API Gateway, ensuring that payment logic, sensitive API keys, and Stripe SDK dependencies are contained within a single service.
STRIPE MICROSERVICES ARCHITECTURE
┌─────────────┐
│ Client │
│ (Next.js) │
└──────┬──────┘
│ REST
▼
┌─────────────────────────────────────────────────────────────────────┐
│ API Gateway │
│ │
│ ┌───────────────────┐ ┌────────────────────┐ │
│ │ StripeController │ │ WebhookController │ │
│ │ POST /checkout │ │ POST /webhook │ │
│ │ GET /products │ │ │ │
│ └─────────┬─────────┘ └──────────┬─────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────────┐ ┌────────────────────┐ │
│ │ StripeService │ │ WebhookService │ │
│ │ (ClientProxy) │ │ (Signature Verify) │ │
│ └─────────┬─────────┘ └──────────┬─────────┘ │
│ │ │ │
└─────────────┼───────────────────────────────────┼───────────────────┘
│ TCP │ TCP
▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ Payment Microservice │
│ │
│ ┌───────────────────┐ ┌────────────────────┐ │
│ │ StripeController │ │ WebhooksController │ │
│ │ @MessagePattern │ │ @MessagePattern │ │
│ └─────────┬─────────┘ └──────────┬─────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────────┐ ┌────────────────────┐ │
│ │ StripeService │ │ WebhooksService │ │
│ │ (Stripe SDK) │ │ (Event Handlers) │ │
│ └─────────┬─────────┘ └──────────┬─────────┘ │
│ │ │ │
└─────────────┼───────────────────────────────────┼───────────────────┘
│ │
└───────────────┬───────────────────┘
│ Prisma
▼
┌─────────────────┐
│ PostgreSQL │
└─────────────────┘
Payment Flow
The complete payment flow involves three stages: creating a checkout session, user payment, and webhook processing.
PAYMENT FLOW
1. CREATE CHECKOUT SESSION
┌────────┐ ┌─────────────┐ ┌─────────────────┐ ┌────────┐
│ Client │─────▶│ API Gateway │─────▶│ Payment Service │─────▶│ Stripe │
│ │ REST │ │ TCP │ (Stripe SDK) │ API │ │
└────────┘ └─────────────┘ └─────────────────┘ └───┬────┘
│
◀───────────────────────────────────────────┘
Return Checkout Session URL
2. USER COMPLETES PAYMENT
┌────────┐ ┌──────────────────┐
│ Client │─────▶│ Stripe Checkout │ (Hosted payment page)
│ │ │ Enter card, pay │
└────────┘ └──────────────────┘
3. WEBHOOK NOTIFICATION
┌────────┐ POST /webhook ┌─────────────┐ TCP ┌─────────────────┐
│ Stripe │───────────────▶│ API Gateway │───────▶│ Payment Service │
│ │ │ (verify) │ │ (process event) │
└────────┘ └─────────────┘ └─────────────────┘
Step 1: Create Checkout Session
The client requests a checkout session. The API Gateway forwards the request to the Payment microservice, which uses the Stripe SDK to create a session and returns the checkout URL.
Step 2: User Completes Payment
The client redirects to Stripe's hosted checkout page. The user enters payment details and completes the transaction. Stripe handles PCI compliance, 3D Secure, and fraud detection.
Step 3: Webhook Notification
After successful payment, Stripe sends a checkout.session.completed webhook to your API Gateway. The gateway verifies the signature and forwards the event to the Payment microservice, which provisions access, saves the subscription, and sends confirmation emails.
Dynamic Module Setup
The Stripe SDK requires an API key at initialization. Using NestJS Dynamic Modules with forRootAsync() allows secure injection from environment variables.
// stripe.module.ts
import { Module, DynamicModule } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { StripeService } from './stripe.service';
import { StripeController } from './stripe.controller';
@Module({})
export class StripeModule {
static forRootAsync(): DynamicModule {
return {
module: StripeModule,
imports: [ConfigModule.forRoot()],
controllers: [StripeController],
providers: [
StripeService,
{
provide: 'STRIPE_API_KEY',
useFactory: (configService: ConfigService) =>
configService.get('STRIPE_API_KEY'),
inject: [ConfigService],
},
],
exports: [StripeService],
};
}
}
Service Initialization
// stripe.service.ts (Payment Microservice)
import { Inject, Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
@Injectable()
export class StripeService {
private stripe: Stripe;
private readonly logger = new Logger(StripeService.name);
constructor(
@Inject('STRIPE_API_KEY')
private readonly apiKey: string,
) {
this.stripe = new Stripe(this.apiKey, {
apiVersion: '2024-12-18.acacia', // Pin API version
});
}
}
Pinning the apiVersion prevents breaking changes when Stripe updates their API. Check the Stripe changelog before upgrading.
API Gateway Layer
The API Gateway exposes REST endpoints and forwards requests to the Payment microservice via TCP using NestJS ClientProxy.
Controller
// stripe.controller.ts (API Gateway)
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { StripeService } from './stripe.service';
import { CreateSubscriptionDto } from './dto/create-subscription.dto';
@Controller('stripe')
export class StripeController {
constructor(private readonly stripeService: StripeService) {}
@Get('products')
async getProducts() {
return this.stripeService.getProducts();
}
@Get('customers')
async getCustomers() {
return this.stripeService.getCustomers();
}
@Post('subscriptions')
async createSubscription(@Body() dto: CreateSubscriptionDto) {
return this.stripeService.createSubscription(dto.customerId, dto.priceId);
}
}
Service (ClientProxy)
// stripe.service.ts (API Gateway)
import { HttpException, Inject, Injectable } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
@Injectable()
export class StripeService {
constructor(@Inject('PAYMENT') private paymentClient: ClientProxy) {}
async getProducts() {
try {
return await this.paymentClient
.send('getProducts', {})
.toPromise();
} catch (error) {
throw new HttpException(error, 400);
}
}
async createSubscription(customerId: string, priceId: string) {
try {
return await this.paymentClient
.send('createSubscription', { customerId, priceId })
.toPromise();
} catch (error) {
throw new HttpException(error, 400);
}
}
}
Payment Microservice
The Payment microservice handles all Stripe SDK operations using @MessagePattern decorators to receive messages from the API Gateway.
Controller
// stripe.controller.ts (Payment Microservice)
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { StripeService } from './stripe.service';
@Controller()
export class StripeController {
constructor(private readonly stripeService: StripeService) {}
@MessagePattern('getProducts')
async getProducts() {
return this.stripeService.getProducts();
}
@MessagePattern('getCustomers')
async getCustomers() {
return this.stripeService.getCustomers();
}
@MessagePattern('createSubscription')
async createSubscription(
@Payload() data: { customerId: string; priceId: string },
) {
return this.stripeService.createSubscription(data.customerId, data.priceId);
}
@MessagePattern('createCustomer')
async createCustomer(@Payload() data: { email: string; name: string }) {
return this.stripeService.createCustomer(data.email, data.name);
}
}
Service (Stripe SDK)
// stripe.service.ts (Payment Microservice)
@Injectable()
export class StripeService {
private stripe: Stripe;
private readonly logger = new Logger(StripeService.name);
constructor(
@Inject('STRIPE_API_KEY') private readonly apiKey: string,
private prisma: PrismaService,
) {
this.stripe = new Stripe(this.apiKey, {
apiVersion: '2024-12-18.acacia',
});
}
async getProducts(): Promise {
const products = await this.stripe.products.list();
this.logger.log('Products fetched successfully');
return products.data;
}
async createCustomer(email: string, name: string): Promise {
const customer = await this.stripe.customers.create({ email, name });
this.logger.log(`Customer created: ${email}`);
return customer;
}
async createSubscription(
customerId: string,
priceId: string,
): Promise {
const subscription = await this.stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
});
this.logger.log(`Subscription created for customer ${customerId}`);
return subscription;
}
}
Webhook Architecture
Webhooks are critical for reacting to asynchronous Stripe events. We implement a two-tiered architecture: the API Gateway verifies signatures, and the Payment microservice processes events.
WEBHOOK FLOW
Stripe Cloud
│
│ POST /webhook
│ Headers: stripe-signature
│ Body: raw Buffer
▼
┌─────────────────────────────────────────────────────┐
│ API Gateway │
│ │
│ 1. Validate stripe-signature header exists │
│ 2. Verify rawBody is Buffer (not parsed JSON) │
│ 3. constructEvent(rawBody, sig, webhookSecret) │
│ 4. Reject invalid signatures (400) │
│ 5. Forward verified Stripe.Event to microservice │
│ │
└────────────────────────┬────────────────────────────┘
│ TCP (verified event)
▼
┌─────────────────────────────────────────────────────┐
│ Payment Microservice │
│ │
│ switch (event.type) { │
│ case 'checkout.session.completed': ... │
│ case 'customer.subscription.updated': ... │
│ case 'customer.subscription.deleted': ... │
│ case 'invoice.payment_failed': ... │
│ } │
│ │
└─────────────────────────────────────────────────────┘
Gateway: Signature Verification
// webhook.controller.ts (API Gateway)
import { Controller, Post, Req, Headers, BadRequestException } from '@nestjs/common';
import { Request } from 'express';
import { WebhookService } from './webhook.service';
@Controller('webhook')
export class WebhookController {
constructor(private readonly webhookService: WebhookService) {}
@Post()
async handleStripeWebhook(
@Req() req: Request,
@Headers('stripe-signature') sig: string,
) {
if (!sig) {
throw new BadRequestException('Missing stripe-signature header');
}
const rawBody = req.body;
if (!Buffer.isBuffer(rawBody)) {
throw new BadRequestException('Invalid request body format');
}
return this.webhookService.handleWebhook(rawBody, sig);
}
}
// webhook.service.ts (API Gateway)
import { HttpException, Inject, Injectable, Logger } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import Stripe from 'stripe';
@Injectable()
export class WebhookService {
private readonly logger = new Logger(WebhookService.name);
private stripe: Stripe;
constructor(@Inject('PAYMENT') private paymentClient: ClientProxy) {
this.stripe = new Stripe(process.env.STRIPE_API_KEY, {
apiVersion: '2024-12-18.acacia',
});
}
async handleWebhook(rawBody: Buffer, sig: string) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event: Stripe.Event;
try {
event = this.stripe.webhooks.constructEvent(rawBody, sig, webhookSecret);
this.logger.log(`Webhook verified: ${event.type}`);
} catch (err) {
this.logger.error(`Signature verification failed: ${err.message}`);
throw new HttpException('Invalid signature', 400);
}
// Forward verified event to Payment microservice
return this.paymentClient.send('handleWebhook', event).toPromise();
}
}
Raw Body Middleware
Stripe requires the raw request body for signature verification. Configure Express to preserve the raw body for the webhook endpoint:
// main.ts
import { NestFactory } from '@nestjs/core';
import * as bodyParser from 'body-parser';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bodyParser: false });
// Raw body for Stripe webhooks
app.use('/webhook', bodyParser.raw({ type: 'application/json' }));
// JSON for everything else
app.use(bodyParser.json());
await app.listen(3000);
}
Microservice: Event Processing
// webhooks.service.ts (Payment Microservice)
@Injectable()
export class WebhooksService {
constructor(
@Inject('STRIPE_API_KEY') private readonly apiKey: string,
private readonly prisma: PrismaService,
) {
this.stripe = new Stripe(this.apiKey, {
apiVersion: '2024-12-18.acacia',
});
}
async handleWebhook(event: Stripe.Event) {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
// 1. Check idempotency (prevent duplicate processing)
// 2. Save subscription to database
// 3. Provision user access / assign plan
// 4. Send confirmation email
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object;
// 1. Update subscription status in database
// 2. Handle plan upgrades/downgrades
// 3. Adjust user entitlements
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
// 1. Mark subscription as canceled in database
// 2. Revoke access or downgrade to free plan
// 3. Send cancellation email
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
// 1. Log payment failure
// 2. Notify user to update payment method
// 3. Optionally pause access after retries exhausted
break;
}
default:
this.logger.warn(`Unhandled event type: ${event.type}`);
}
}
}
Checkout Sessions
Stripe Checkout provides a hosted payment page with built-in support for cards, Apple Pay, Google Pay, and 3D Secure authentication.
async createCheckoutSession(priceId: string, userId: string) {
const session = await this.stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.APP_URL}/billing?success=true`,
cancel_url: `${process.env.APP_URL}/billing?canceled=true`,
metadata: { userId }, // Link payment to your user
});
return { url: session.url };
}
Security: Never trust the success_url redirect to grant access. Always rely on the checkout.session.completed webhook to confirm payment.
Subscription Lifecycle
Upgrading Plans
async upgradeSubscription(subscriptionId: string, newPriceId: string) {
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);
const itemId = subscription.items.data[0].id;
return this.stripe.subscriptions.update(subscription.id, {
items: [{ id: itemId, price: newPriceId }],
proration_behavior: 'always_invoice', // Charge difference immediately
});
}
Canceling Subscriptions
async cancelSubscription(subscriptionId: string, immediate: boolean = false) {
if (immediate) {
// Cancel now, revoke access immediately
return this.stripe.subscriptions.cancel(subscriptionId);
} else {
// Cancel at period end, user keeps access until then
return this.stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
}
}
Best Practices
Security
- Pin API versions: Specify
apiVersionto prevent breaking changes - Verify webhooks: Always validate signatures before processing events
- Isolate secrets: Keep Stripe keys in the Payment microservice only
- Use metadata: Link Stripe objects to your domain entities (userId)
Reliability
- Idempotency: Check if events are already processed to prevent duplicates
- Expand objects: Use
expandto reduce API calls - Handle failures: Log payment failures and notify users
- Test webhooks: Use Stripe CLI to trigger events locally
Testing with Stripe CLI
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login to your account
stripe login
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/webhook
# Trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
Conclusion
Integrating Stripe into a NestJS microservices architecture separates concerns effectively: the API Gateway handles HTTP routing and webhook verification, while the Payment microservice owns all Stripe SDK operations and business logic.
The two-tiered webhook architecture ensures that invalid requests are rejected at the gateway before reaching your payment logic. Combined with idempotency checks and proper error handling, this setup handles the full subscription lifecycle from checkout to cancellation.
Written by

Technical Lead and Full Stack Engineer leading a 5-engineer team at Fygurs (Paris, Remote) on Azure cloud-native SaaS. Graduate of 1337 Coding School (42 Network / UM6P). Writes about architecture, cloud infrastructure, and engineering leadership.