Microservices with NestJS: Integrating Multi-Gateway Payments
Lessons from building a payments layer across Stripe and PayPal in a NestJS microservices architecture — idempotency, webhooks, and keeping money flows reliable.

Payments are where architectural shortcuts come back to bite you. A dropped webhook or a double-charged customer isn't a bug report — it's a refund, a chargeback, and a trust problem. Here's how I structure a reliable, multi-gateway payments layer in NestJS.
Treat the gateway as a detail, not the design
Your domain shouldn't know whether a payment went through Stripe or PayPal. Define an interface and let each gateway implement it:
export interface PaymentGateway {
createIntent(order: Order): Promise<PaymentIntent>;
capture(intentId: string): Promise<PaymentResult>;
refund(paymentId: string, amount: number): Promise<RefundResult>;
}
A PaymentService selects the right provider at runtime. Adding a third gateway later means writing one adapter, not rewriting the booking flow.
Make every operation idempotent
Networks retry. Users double-click. Webhooks fire twice. Every money operation needs an idempotency key so a retry is a no-op rather than a second charge:
await this.payments.createIntent(order, {
idempotencyKey: `order:${order.id}:intent`,
});
Store the key with the result. If the same key arrives again, return the stored result instead of calling the gateway.
Webhooks are the source of truth
Never mark an order paid because the client said so. The client can lie, lose connection, or close the tab. The webhook from the gateway is authoritative.
- Verify the signature on every webhook. Reject anything unsigned.
- Make the handler idempotent — gateways deliver "at least once."
- Acknowledge fast, process async. Push the work onto a queue.
Treat the client confirmation as a UX hint and the webhook as the ledger. Reconcile to the webhook, always.
Use a message broker between services
In a microservices setup, the payment service shouldn't synchronously call the order service and the notification service. Emit an event:
this.events.emit("payment.succeeded", {
orderId: order.id,
amount: order.total,
});
Orders, notifications and analytics each subscribe. Services stay decoupled, and a slow email provider can't block a checkout. NestJS makes this clean with its built-in microservice transport (Kafka, RabbitMQ, Redis).
Log money like an accountant
Every state transition — intent created, captured, refunded, failed — gets a durable, append-only record. When a customer disputes a charge six weeks later, you want a timeline, not a guess.
Reliable payments aren't about clever code. They're about assuming everything will be retried, every message will arrive twice, and the network will fail at the worst moment — and designing so none of that costs anyone money.
Keep reading


