AboutExpertiseProjectsJourneyBlogContact
Discuss
AboutExpertiseProjectsJourneyBlogContactDiscuss

Yao David Logan

Software Engineer fullstack specialized in SaaS, business automation and scalable web/mobile platforms.

NavigationExpertiseProjectsJourneyBlogContact
LinksGitHubLinkedInEmail
© 2026 Yao David Logan. All rights reserved.
Multi-gateway payment and E2E encryption — the pattern that doesn't break
Back to blog

Tech

Xin@

Multi-gateway payment and E2E encryption — the pattern that doesn't break

YDLYao David Logan18 min readSeptember 18, 2026

CategoryTech

Read18 min read

PublishedSeptember 18, 2026

Views1

Shares0

Comments0

Contents
  1. 011. The context
  2. 022. Why the naive approach breaks
  3. 033. The Gateway Adapter pattern
  4. 044. End-to-end encryption — for real
  5. 055. Native idempotency — the foundation
  6. 066. Webhooks — where everything derails
  7. 077. Reconciliation — your safety net
  8. 088. Audit trail — what the auditor will ask for
  9. 099. Pitfalls to avoid
  10. 1010. Closing

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.

1. The context

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.

2. Why the naive approach breaks

First temptation: a big PaymentService with a switch on the requested method. Works at 2 gateways, unmanageable at 6. Symptoms:

  • A bug in Orange MoMo propagates to MTN because methods share a cache
  • The Stripe webhook crashes because we forgot to update the switch when adding Mobile Money
  • Integration tests spin up a megafixture to test a single method
  • One gateway's API version migration forces redeploying everything

Rule: one payment method = one isolated module wired by contract.

3. The Gateway Adapter pattern

The payment service knows only one interface: PaymentGateway. Each gateway provides its implementation. Routing is centralized, the rest is polymorphic.

TypeScript
1// src/services/payment/types.ts
2export type PaymentMethod =
3 | "card"
4 | "mobile_money_mtn"
5 | "mobile_money_orange"
6 | "mobile_money_moov"
7 | "bank_transfer"
8 | "crypto_usdc";
9 
10export 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 
19export interface PaymentSession {
20 sessionId: string;
21 redirectUrl?: string;
22 expiresAt: Date;
23}
24 
25export 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:

TypeScript
1// src/services/payment/PaymentRouter.ts
2import { PaymentGateway, PaymentMethod } from "./types.js";
3 
4export 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.

4. End-to-end encryption — for real

"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:

TypeScript
1// frontend/src/lib/payment/encryptPan.ts
2export 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.

5. Native idempotency — the foundation

Every payment operation must be idempotent. The client must be able to replay the same request 200 times without creating 200 payments. The discipline:

TypeScript
1// src/controllers/payment/initiatePayment.ts
2export 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.

6. Webhooks — where everything derails

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.

TypeScript
1// src/routes/webhooks.ts
2router.post("/webhooks/mtn", verifyMtnSignature, mtnWebhookHandler);
3router.post("/webhooks/orange", verifyOrangeSignature, orangeWebhookHandler);
4router.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.

TypeScript
1// src/middlewares/verifyStripeSignature.ts
2import Stripe from "stripe";
3 
4const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
5const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
6 
7export 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.

7. Reconciliation — your safety net

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.

TypeScript
1// src/jobs/reconcilePayments.ts
2export 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.

8. Audit trail — what the auditor will ask for

Every financial operation must be traced. Not for marketing, for compliance. At every balance or transaction mutation, we write to a payment_audit table:

SQL
1CREATE 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);
12CREATE 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.

9. Pitfalls to avoid

PitfallSymptomFix
Storing the PAN even encryptedPCI-DSS risk, heavy auditClient-side encryption, never touch it server-side
Single webhook endpointCoupling between gatewaysOne endpoint per gateway, gateway-specific signature
No idempotency-keyDouble payments on retryMandatory header, 24 h cache
Manual reconciliationTransactions lost during outagesPeriodic reconciliation job
Audit in the same tablePollution + lockDedicated payment_audit table, append-only
Logs with PANMass leak in case of breachFilter sensitive fields before logging

10. Closing

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.

Comments

Reader reactions

No comment yet

No spam — email is only used to verify your identity.

·

Be the first to share your reaction.

←Previous60 fps on entry-level Android — React Native performance playbook for emerging marketsTutoriel · 17 min readNextMicroservices in Node.js without debt — field notes from a multi-surface fintech platformTech · 16 min read→
Next conversation

Turn this reading into a product decision.

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.

Format
Full-time, freelance, long mission
Focus
SaaS, API, back-office, automation
Discuss the topicDownload CV

Newsletter

Get the next technical notes.

A short selection on SaaS, backend architecture, business automation and product quality. No noise, only applicable ideas.