Engineering Deep Dive • 9 min read • 09/20/2026

Multi-Provider Payment Gateway Architecture: Handling Stripe, Adyen and Asian Regional Payment Providers

Bài này có bản tiếng Việt. Đọc bản tiếng Việt →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Payment terminal at a counter
Photo: Avataranedotas · CC0

A travel marketplace selling to customers in several markets can’t integrate just one payment gateway. Japanese customers are used to paying with LINE Pay. Korean customers use KakaoPay or Toss Payments. International customers pay by Visa/Mastercard through Stripe or Adyen.

By the sixth provider, the problem is no longer the integration itself. It’s keeping the payment layer from rotting.

1. Calling each provider’s SDK directly in the checkout route

The easiest approach, and the easiest to get wrong, is calling each provider’s SDK directly in the checkout route.

if (paymentMethod === "stripe") {
  await stripe.paymentIntents.create({ ... });
} else if (paymentMethod === "adyen") {
  await adyenClient.payments({ ... });
} else if (paymentMethod === "linepay") {
  // ...
}

Every provider has its own data model: Stripe uses PaymentIntent with its own confirm flow, Adyen uses the Sessions flow, and LINE Pay and KakaoPay force a redirect to the mobile app. Webhooks differ. Refunds and captures differ too.

Go from 2 providers to 6 and the checkout route becomes a pile of if-else that nobody dares touch.

2. A PaymentProvider interface: every provider goes through one door

We defined a common interface that every provider has to follow. The core logic (create the order, decrement inventory, send the email) only talks to that interface and doesn’t need to know how Stripe differs from Adyen:

interface PaymentProvider {
  readonly id: string; // "stripe" | "adyen" | "linepay" | ...

  initiatePayment(input: InitiatePaymentInput): Promise<PaymentSession>;

  capturePayment(sessionId: string): Promise<PaymentResult>;

  refundPayment(paymentId: string, amount?: number): Promise<RefundResult>;

  // Each provider verifies the webhook signature in its own way,
  // but always returns a normalized event
  parseWebhookEvent(rawBody: Buffer, headers: Record<string, string>): NormalizedPaymentEvent;
}

Each provider implements this interface separately. A trimmed-down example for Stripe:

class StripeProvider implements PaymentProvider {
  readonly id = "stripe";

  async initiatePayment(input: InitiatePaymentInput): Promise<PaymentSession> {
    const intent = await this.client.paymentIntents.create({
      amount: input.amountMinorUnits,
      currency: input.currency,
      metadata: { bookingId: input.bookingId },
    });
    return { sessionId: intent.id, clientSecret: intent.client_secret, status: "pending" };
  }

  async capturePayment(sessionId: string): Promise<PaymentResult> {
    const intent = await this.client.paymentIntents.capture(sessionId);
    return { status: intent.status === "succeeded" ? "captured" : "failed", raw: intent };
  }

  parseWebhookEvent(rawBody: Buffer, headers: Record<string, string>): NormalizedPaymentEvent {
    const event = this.client.webhooks.constructEvent(
      rawBody,
      headers["stripe-signature"],
      this.webhookSecret,
    );
    return { type: this.mapEventType(event.type), paymentId: event.data.object.id, raw: event };
  }
}

A simple registry maps providerId → implementation, and the checkout and webhook routes just look up the registry and call through the common interface:

const provider = paymentProviderRegistry.get(order.paymentMethod);
const result = await provider.capturePayment(order.paymentSessionId);

Need to add KakaoPay or Toss? Write one more class that implements PaymentProvider and register it. Done. The routes and order logic don’t change by a single line.

3. Webhook idempotency: where double-processing happens most easily

The provider will resend the same event if it doesn’t get a 200 response in time: timeouts, server restarts, network glitches, all of it. If the webhook handler isn’t idempotent, one “payment succeeded” event delivered twice gets the order confirmed as paid twice. Worse, the confirmation email goes out twice too.

How we handle it:

  • Every NormalizedPaymentEvent carries a unique eventId. If the provider gives us one, we use it. If not, we generate one from a hash of provider + paymentId + eventType + timestamp.
  • Before processing, write the eventId into a processed_webhook_events table with a unique constraint. If the insert fails on a duplicate key, the event was already handled: return 200 right away and do nothing more.
  • Recording the processed event and updating the order status happen in the same DB transaction, with no gap between the two steps.
await db.transaction(async (trx) => {
  const inserted = await trx("processed_webhook_events")
    .insert({ event_id: event.id, provider: provider.id })
    .onConflict("event_id")
    .ignore();

  if (inserted.rowCount === 0) return; // already processed earlier, skip

  await applyPaymentEvent(trx, event);
});

4. Reconciliation: when the webhook never arrives

Webhooks can get lost. Bad config, a firewall blocking them, a provider incident, and that’s it. If you rely only on webhooks, some orders sit in pending forever even though the money was charged successfully on the provider’s side.

We added a reconciliation job that runs every 15 minutes. It scans pending orders older than a threshold and asks the provider directly through its API what the real status is, without waiting for a webhook:

const stalePending = await orderService.findStalePendingPayments({ olderThanMinutes: 15 });

for (const order of stalePending) {
  const provider = paymentProviderRegistry.get(order.paymentMethod);
  const status = await provider.queryPaymentStatus(order.paymentSessionId);
  if (status.isSettled) {
    await applyPaymentEvent(db, status.toNormalizedEvent());
  }
}

The reconciliation job shares the event-processing logic with the webhook handler through applyPaymentEvent.

Put the two paths together and one payment flows like this.

sequenceDiagram
  participant P as Payment provider
  participant W as Webhook route
  participant DB as Database
  participant R as Reconciliation job
  P->>W: send payment succeeded event
  W->>W: parseWebhookEvent — verify signature, normalize event
  W->>DB: one transaction: insert eventId and update order status
  alt eventId already in processed_webhook_events
    DB-->>W: duplicate key on insert
    W-->>P: return 200, skip
  else new event
    DB-->>W: write done
    W-->>P: return 200
  end
  Note over R: runs every 15 minutes
  R->>DB: find pending orders past the threshold
  R->>P: queryPaymentStatus, ask the provider directly
  P-->>R: the real payment status
  R->>DB: applyPaymentEvent once settled

5. Refunds and partial refunds

Refunds look easy. They aren’t. Not every provider supports partial refunds the same way, and travel bookings are rarely refunded at 100%, because the cancellation fee depends on the tour’s policy.

The refundPayment interface takes amount?: number. Leave it out and you refund everything; pass it and you refund part. Each provider validates the amount itself before calling the API: no more than the original amount, no negatives.

For KakaoPay and some regional e-wallets, the refund API sometimes doesn’t support partial refunds directly. The provider implementation handles that itself: refund the full amount, then charge the difference again, and log clearly so the ops team knows this was a composite refund, not something the provider’s API did natively.

6. When there’s an incident, ask two questions first

The PaymentProvider abstraction doesn’t remove the complexity of integrating 5-6 different payment gateways. It confines that complexity to each provider’s own file, so the core order logic doesn’t have to care which provider sits behind it.

When there’s a payment incident, I ask two questions first: has this event been written to processed_webhook_events, and has the reconciliation job caught this order? Those two questions narrow down 90% of the causes.

Need a developer for a contract or freelance project?

I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.