

Adapter pattern, native idempotency, client-side end-to-end encryption, separate webhooks and an append-only audit table: the skeleton for integrating six payment gateways without debt.
Integrating multiple payment gateways into a fintech platform is easy. Doing it in production without incidents is another story. Here's the pattern I use.
On Nexus, the platform accepts several payment methods: bank card, mobile money (MTN, Orange, Moov), bank transfer, sometimes crypto. Each gateway lives in its own ecosystem: its SDK, its webhook format, its idempotency logic, its failure regime. Without clear organization, the integration ends up as a spaghetti of nested if statements no new developer can decipher.
The goal of this article: present the pattern that let me integrate six distinct gateways without duplicating a single line of business logic, with native end-to-end encryption and idempotency.
First temptation: a big PaymentService with a switch on the requested method. Works at 2 gateways, unmanageable at 6. Symptoms:
switch when adding Mobile MoneyRule: one payment method = one isolated module wired by contract.
The payment service knows only one interface: PaymentGateway. Each gateway provides its implementation. Routing is centralized, the rest is polymorphic.
| 1 | // src/services/payment/types.ts |
| 2 | export type PaymentMethod = |
| 3 | | "card" |
| 4 | | "mobile_money_mtn" |
| 5 | | "mobile_money_orange" |
| 6 | | "mobile_money_moov" |
| 7 | | "bank_transfer" |
| 8 | | "crypto_usdc"; |
| 9 | |
| 10 | export interface PaymentInitiateInput { |
| 11 | amount: number; |
| 12 | currency: "XOF" | "USD" | "EUR"; |
| 13 | customerId: string; |
| 14 | method: PaymentMethod; |
| 15 | reference: string; // business reference (no PII) |
| 16 | metadata?: Record<string, string>; |
| 17 | } |
| 18 | |
| 19 | export interface PaymentSession { |
| 20 | sessionId: string; |
| 21 | redirectUrl?: string; |
| 22 | expiresAt: Date; |
| 23 | } |
| 24 | |
| 25 | export interface PaymentGateway { |
| 26 | readonly name: string; |
| 27 | readonly supports: ReadonlyArray<PaymentMethod>; |
| 28 | initiate(input: PaymentInitiateInput): Promise<PaymentSession>; |
| 29 | confirm(sessionId: string, evidence: WebhookPayload): Promise<PaymentResult>; |
| 30 | refund(transactionId: string, amount: number): Promise<RefundResult>; |
| 31 | } |
Each adapter exposes the methods it supports via supports. The central router picks the right one based on the requested method:
| 1 | // src/services/payment/PaymentRouter.ts |
| 2 | import { PaymentGateway, PaymentMethod } from "./types.js"; |
| 3 | |
| 4 | export class PaymentRouter { |
| 5 | private readonly gateways = new Map<PaymentMethod, PaymentGateway>(); |
| 6 | |
| 7 | register(gateway: PaymentGateway) { |
| 8 | for (const method of gateway.supports) { |
| 9 | if (this.gateways.has(method)) { |
| 10 | throw new Error(`Method ${method} already registered by ${this.gateways.get(method)!.name}`); |
| 11 | } |
| 12 | this.gateways.set(method, gateway); |
| 13 | } |
| 14 | } |
| 15 | |
| 16 | resolve(method: PaymentMethod): PaymentGateway { |
| 17 | const gateway = this.gateways.get(method); |
| 18 | if (!gateway) throw new Error(`No gateway registered for method ${method}`); |
| 19 | return gateway; |
| 20 | } |
| 21 | } |
To add a 7th gateway: write the adapter, register it on boot, the rest of the code never has to know it exists.
"E2E encryption" is an abused phrase. On a payment platform it means concretely: sensitive data (PAN, CVV, IBAN, mobile number) never touches our server in clear. It's encrypted client-side with the gateway's public key, transmitted encrypted through our backend, and decrypted only by the final gateway.
Frontend:
| 1 | // frontend/src/lib/payment/encryptPan.ts |
| 2 | export async function encryptPan(pan: string, gatewayPublicKeyPem: string): Promise<string> { |
| 3 | const key = await importPublicKey(gatewayPublicKeyPem); |
| 4 | const buffer = new TextEncoder().encode(pan); |
| 5 | const encrypted = await crypto.subtle.encrypt( |
| 6 | { name: "RSA-OAEP" }, |
| 7 | key, |
| 8 | buffer, |
| 9 | ); |
| 10 | return arrayBufferToBase64(encrypted); |
| 11 | } |
Backend: the encrypted PAN transits as-is to the gateway API. No log, no persistence, no hash. The only element left on our side is an opaque token returned by the gateway, valid for future refunds.
This approach eliminates 80% of PCI-DSS risks. The server doesn't store card data, can't emit a copy, and can't be compromised to leak PANs.
Every payment operation must be idempotent. The client must be able to replay the same request 200 times without creating 200 payments. The discipline:
| 1 | // src/controllers/payment/initiatePayment.ts |
| 2 | export async function initiatePayment(req: Request, res: Response) { |
| 3 | const idempotencyKey = req.header("idempotency-key"); |
| 4 | if (!idempotencyKey) { |
| 5 | return res.status(400).json({ error: "idempotency-key header required" }); |
| 6 | } |
| 7 | |
| 8 | // 1. Does a session already exist for this key? |
| 9 | const cached = await IdempotencyCache.findOne({ where: { key: idempotencyKey } }); |
| 10 | if (cached) { |
| 11 | return res.status(cached.statusCode).json(cached.response); |
| 12 | } |
| 13 | |
| 14 | // 2. Otherwise process the request |
| 15 | const session = await paymentRouter |
| 16 | .resolve(req.body.method) |
| 17 | .initiate(req.body); |
| 18 | |
| 19 | const response = { sessionId: session.sessionId, redirectUrl: session.redirectUrl }; |
| 20 | |
| 21 | // 3. Cache the response for 24 h |
| 22 | await IdempotencyCache.create({ |
| 23 | key: idempotencyKey, |
| 24 | statusCode: 201, |
| 25 | response, |
| 26 | expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), |
| 27 | }); |
| 28 | |
| 29 | res.status(201).json(response); |
| 30 | } |
The client generates a UUID v7 (temporally orderable) per attempt. On disconnect, it sends the same key. The server returns the same response. No double payment.
Each gateway sends webhooks at its own pace and format. The classic trap: try to normalize everything in a single endpoint. Bad idea. Prefer one endpoint per gateway with its own signature validator.
| 1 | // src/routes/webhooks.ts |
| 2 | router.post("/webhooks/mtn", verifyMtnSignature, mtnWebhookHandler); |
| 3 | router.post("/webhooks/orange", verifyOrangeSignature, orangeWebhookHandler); |
| 4 | router.post("/webhooks/stripe", verifyStripeSignature, stripeWebhookHandler); |
Each handler converts its proprietary format into a normalized PaymentEvent, persists the event, acknowledges receipt immediately (200 OK within 200 ms), and dispatches a queue job for real processing.
| 1 | // src/middlewares/verifyStripeSignature.ts |
| 2 | import Stripe from "stripe"; |
| 3 | |
| 4 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); |
| 5 | const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; |
| 6 | |
| 7 | export function verifyStripeSignature(req: Request, res: Response, next: NextFunction) { |
| 8 | const signature = req.header("stripe-signature"); |
| 9 | if (!signature) return res.status(400).json({ error: "missing signature" }); |
| 10 | try { |
| 11 | const event = stripe.webhooks.constructEvent(req.rawBody, signature, webhookSecret); |
| 12 | req.body = event; |
| 13 | next(); |
| 14 | } catch (error) { |
| 15 | return res.status(400).json({ error: "invalid signature" }); |
| 16 | } |
| 17 | } |
Important: use req.rawBody (before JSON parsing) for signature check — otherwise it breaks due to spaces added/removed by the parser.
No webhook is guaranteed. A gateway can go down, your server can be offline, the network can drop packets. Periodic reconciliation is mandatory: a job that queries each gateway every 15 minutes to fetch the last 24 hours of transactions and cross-check with your database.
| 1 | // src/jobs/reconcilePayments.ts |
| 2 | export async function reconcilePayments(gateway: PaymentGateway) { |
| 3 | const since = new Date(Date.now() - 24 * 60 * 60 * 1000); |
| 4 | const remoteTxs = await gateway.listTransactions({ since }); |
| 5 | |
| 6 | for (const remote of remoteTxs) { |
| 7 | const local = await Transaction.findOne({ |
| 8 | where: { gateway: gateway.name, externalId: remote.id }, |
| 9 | }); |
| 10 | |
| 11 | if (!local) { |
| 12 | logger.warn("Missing local transaction", { remote }); |
| 13 | await createTransactionFromRemote(gateway, remote); |
| 14 | } else if (local.status !== remote.status) { |
| 15 | logger.warn("Status mismatch", { local: local.status, remote: remote.status }); |
| 16 | await local.update({ status: remote.status, reconciledAt: new Date() }); |
| 17 | } |
| 18 | } |
| 19 | } |
Reconciliation saved Nexus twice during mobile money provider outages: confirmed-but-not-notified payments were recovered without manual intervention.
Every financial operation must be traced. Not for marketing, for compliance. At every balance or transaction mutation, we write to a payment_audit table:
| 1 | CREATE TABLE payment_audit ( |
| 2 | id UUID PRIMARY KEY, |
| 3 | transaction_id UUID NOT NULL, |
| 4 | actor_type VARCHAR(20) NOT NULL, -- 'user' | 'system' | 'admin' |
| 5 | actor_id UUID NULL, |
| 6 | action VARCHAR(40) NOT NULL, -- 'initiated' | 'confirmed' | 'refunded' | ... |
| 7 | before_state JSONB NULL, |
| 8 | after_state JSONB NOT NULL, |
| 9 | trace_id VARCHAR(64) NOT NULL, |
| 10 | at TIMESTAMP NOT NULL DEFAULT NOW() |
| 11 | ); |
| 12 | CREATE INDEX ON payment_audit (transaction_id, at); |
This table is append-only. No UPDATE, no DELETE. Any attempt is refused by a Postgres trigger. It's the basis on which an auditor reconstructs a transaction's history.
| Pitfall | Symptom | Fix |
|---|---|---|
| Storing the PAN even encrypted | PCI-DSS risk, heavy audit | Client-side encryption, never touch it server-side |
| Single webhook endpoint | Coupling between gateways | One endpoint per gateway, gateway-specific signature |
No idempotency-key | Double payments on retry | Mandatory header, 24 h cache |
| Manual reconciliation | Transactions lost during outages | Periodic reconciliation job |
| Audit in the same table | Pollution + lock | Dedicated payment_audit table, append-only |
| Logs with PAN | Mass leak in case of breach | Filter sensitive fields before logging |
Multi-gateway payment isn't a hard technical problem. It's a discipline problem: isolate each gateway, normalize events, idempotency everywhere, untouchable audit trail, end-to-end encryption for real.
If you're starting today, take this skeleton: PaymentGateway interface, central PaymentRouter, separate webhook endpoints, mandatory idempotency-key, append-only audit table, reconciliation job. Everything else is gateway-specific plumbing.
If this topic feels close to a real product problem, I can help on diagnosis, architecture, backend, interface and automations that make a platform usable in production.
Reader reactions
No comment yet
Be the first to share your reaction.