When a business needs to integrate payments, code often gets mixed with business logic, the database grows without a clear structure, and Stripe webhooks are processed without proper reliability. This leads to duplicate payments, subscription errors, and customer loss. The problem is especially acute in startups, where speed is prioritized over reliability — but mistakes cost the most.
In this article, we will build a production-ready payment microservice with NestJS, PostgreSQL, and Stripe. You will get not abstract advice, but a concrete architecture ready for deployment.
Why Payment Logic Requires a Separate Microservice
Embedding payments directly into a monolith is a common mistake. Any failure in payment processing can block the entire API. Additionally, PCI DSS requirements mandate isolating payment data. A separate microservice solves both problems.
If you plan to scale — for example, adding new payment providers (PayPal, Adyen) — you can do so without changing the main code. Teams that separate payments into a dedicated service reduce incident response time: according to a Stripe survey (2023), the average recovery time after a payment module failure in a monolith is 4-6 hours, while in a microservice it is 1-2 hours.
Tip: Start with a microservice, even if the project seems small. It will pay off on the first release with subscriptions.
Three Architectural Approaches to Integrating Payments with NestJS
Let's look at three options that teams most often use.
1. Monolith with Embedded Payments
Pros: Simple to start, single database.
Cons: Any update to the Stripe SDK requires rebuilding the entire application. An error in the payment module brings down the whole service.
Example: Stripe releases an API update with new fields for subscriptions. In a monolith, you need to update the SDK, check all endpoints (including user and notification logic), and rebuild the entire application. This can take 2-3 days. In a microservice, you only update the payment service in a few hours.
2. Microservice with Synchronous REST
Pros: Isolation, easy to test. Cons: If the payment service fails, the entire order flow goes down.
How it looks in practice: The main service calls the payment service via HTTP (POST /payments/create). If the payment service is temporarily unavailable (due to load or failure), the user cannot complete the order. For critical scenarios (real-time payment), this is unacceptable.
3. Microservice with Asynchronous Bus (CQRS + Event Sourcing)
Pros: Maximum fault tolerance, ability to replay events. Cons: Complexity, overkill for an MVP.
When it's needed: If you are building a fintech platform with audit of all transactions (e.g., for SOX or GDPR compliance). Event Sourcing allows you to restore any payment state in the past, which is critical for investigations.
For 80% of projects, option 2 (synchronous REST) with asynchronous webhooks is optimal. Option 3 is recommended only for high audit and fault tolerance requirements.
Choosing the Approach: Asynchronous Microservice with PostgreSQL and Event-Driven Webhooks
The best solution is to separate the payment service with its own PostgreSQL database, which communicates with the main application via an event bus (RabbitMQ, NATS, or Redis Pub/Sub). Stripe webhooks are received and processed in this same service, and payment statuses are synchronized asynchronously.
This architecture ensures that even under peak load (1000 payments per minute), the main service will not crash. PostgreSQL provides strict data integrity, which is critical for financial transactions.
Example: In one project, our team switched from a monolith to this scheme and reduced payment downtime from 3 hours to 15 minutes per month. The key improvement was using queues (Bull with Redis) for webhook processing: if Stripe sends 50 webhooks simultaneously, they do not block the HTTP server but are processed by background workers.
Step-by-Step Implementation of a Payment Microservice with NestJS, PostgreSQL, and Stripe
Step 1. Initialize the NestJS Project
Install the NestJS CLI and create a new project:
npm i -g @nestjs/cli
nest new payment-service
Choose a package manager (npm or yarn) and TypeScript. After initialization, the project structure will look like this:
payment-service/
├── src/
│ ├── app.module.ts
│ ├── main.ts
│ └── ...
├── package.json
└── tsconfig.jsonStep 2. Connect PostgreSQL via TypeORM
Install TypeORM and the PostgreSQL driver:
npm install @nestjs/typeorm typeorm pg
Configure the module in app.module.ts:
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DB_HOST,
port: 5432,
username: process.env.DB_USER,
password: process.env.DB_PASS,
database: 'payments',
entities: [Payment, Subscription],
synchronize: true, // only for dev
})
Important: In production, use synchronize: false and migrations (via typeorm migration:create). This prevents accidental data deletion when changing entities.
Create a Payment entity with fields: id, stripePaymentIntentId, amount, currency, status, userId, createdAt. Example:
@Entity()
export class Payment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
stripePaymentIntentId: string;
@Column('int')
amount: number; // in cents
@Column({ length: 3 })
currency: string; // 'usd'
@Column({ default: 'pending' })
status: string; // 'pending', 'succeeded', 'failed'
@Column()
userId: string;
@CreateDateColumn()
createdAt: Date;
}
Step 3. Integrate Stripe
Install the Stripe SDK:
npm install @stripe/stripe-js stripe
Create a StripeService that wraps the Stripe API. For example, the createPaymentIntent method:
@Injectable()
export class StripeService {
private stripe: Stripe;
constructor() {
this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2023-10-16',
});
}
async createPaymentIntent(amount: number, currency: string) {
const paymentIntent = await this.stripe.paymentIntents.create({
amount,
currency,
});
return paymentIntent.client_secret;
}
}
Additionally: Add a method for creating a subscription:
async createSubscription(customerId: string, priceId: string) {
const subscription = await this.stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
});
return subscription;
}
Step 4. Handle Stripe Webhooks
Create a controller to receive webhooks. Important: disable global body parsing for this endpoint, otherwise the webhook signature will not pass validation.
@Post('webhook')
@Header('stripe-signature', '')
async handleWebhook(@Req() req, @Res() res) {
const sig = req.headers['stripe-signature'];
const event = this.stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
// Process the event
}
How signature validation works: Stripe signs each webhook with a secret key (endpoint secret). If you do not disable global body parsing (e.g., body-parser), NestJS converts the raw body to JSON, which breaks the signature. Use raw-body for this endpoint.
Inside the handler, save the payment status to PostgreSQL via PaymentService:
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object;
await this.paymentService.updateStatus(paymentIntent.id, 'succeeded');
}
Step 5. Asynchronous Synchronization with the Main Service
After updating the status in the database, emit an event to Redis Pub/Sub or RabbitMQ:
this.eventEmitter.emit('payment.succeeded', { userId, amount });
The main service subscribes to this event and activates the subscription or sends a notification.
Example with RabbitMQ:
@Injectable()
export class PaymentEventsService {
constructor(@Inject('RABBITMQ_CLIENT') private client: ClientProxy) {}
async emitPaymentSuccess(userId: string, amount: number) {
this.client.emit('payment.succeeded', { userId, amount });
}
}
In the main service:
@EventPattern('payment.succeeded')
async handlePaymentSuccess(data: { userId: string; amount: number }) {
await this.subscriptionService.activateSubscription(data.userId);
await this.notificationService.sendEmail(data.userId, 'Payment succeeded');
}
Three Common Mistakes When Integrating NestJS, PostgreSQL, and Stripe
Even experienced teams make these mistakes.
1. Ignoring Webhook Idempotency
Stripe may send the same webhook multiple times (due to network timeouts or retries). If you do not check the unique event id, you will create duplicate payments.
How to do it right: Store the event id (stripeEventId) in the database and check its uniqueness before processing using a unique index. Example entity:
@Entity()
export class StripeEvent {
@PrimaryColumn()
id: string; // unique event id from Stripe
@Column()
type: string;
@CreateDateColumn()
processedAt: Date;
}
Before processing the webhook:
const existingEvent = await this.stripeEventRepository.findOne({ where: { id: event.id } });
if (existingEvent) {
return res.status(200).send('Event already processed');
}
2. Synchronous Processing of All Webhooks
If processing a payment takes 2 seconds (including writing to the database and sending events), and 100 webhooks arrive per minute, the service will hang due to HTTP thread blocking.
How to do it right: Place the event in a queue (Bull with Redis) and process it asynchronously. Example with Bull:
@Processor('webhook-queue')
export class WebhookProcessor {
@Process()
async handleWebhook(job: Job) {
const event = job.data;
if (event.type === 'payment_intent.succeeded') {
await this.paymentService.updateStatus(event.data.object.id, 'succeeded');
}
}
}
In the controller:
@Post('webhook')
async handleWebhook(@Req() req, @Res() res) {
const event = this.stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], endpointSecret);
await this.webhookQueue.add(event);
return res.status(200).send('OK');
}
3. Storing the Stripe Secret Key in Code
Even in a .env file, if it ends up in the repository (e.g., through an accidental commit), the key will be stolen. The Stripe Secret Key gives full access to your Stripe account, including creating payments and managing subscriptions.
How to do it right: Use a secrets manager (AWS Secrets Manager, HashiCorp Vault) or at least exclude .env from git via .gitignore. Example with AWS Secrets Manager:
const secrets = await this.secretsManager.getSecretValue({ SecretId: 'stripe/secret-key' });
const stripe = new Stripe(secrets.SecretString);
Additionally: Enable two-factor authentication for your Stripe account and restrict key access through a role-based model (IAM for AWS).
Conclusion
We have covered how to build a reliable payment microservice with NestJS, PostgreSQL, and Stripe. Key takeaways:
Isolate payment logic in a separate microservice — this simplifies scaling and increases reliability. Built-in webhook processing via queues (Bull) protects against overloads.
Use TypeORM for working with PostgreSQL and be sure to apply unique indexes for webhook idempotency. This prevents duplicate payments.
Asynchronous event processing via a data bus (RabbitMQ, Redis Pub/Sub) protects the main service from overloads and makes it easy to add new subscribers (e.g., for analytics or notifications).
Ready to try this architecture in your project? Sign up for a free consultation, and we will help you integrate payments without the pain.










