Back to blog
PaymentsSystem DesignNode.js

Integrating 3 Payment Gateways Without Losing Sleep

A practical guide on abstracting Stripe, Paddle, and Xendit behind a unified payment interface — handling webhooks, idempotency, and edge cases.

January 20, 2025
8 min read

At EzyCourse, we process $100K+ monthly across three payment gateways: Stripe (global), Paddle (merchant-of-record for tax compliance), and Xendit (Southeast Asia). Each has its own SDK, webhook format, and quirks. Wiring them into a product without turning your codebase into spaghetti requires deliberate design.

The Problem with Direct Integration

The naive approach is to write controllers that call Stripe, Paddle, or Xendit APIs directly. This works for the first gateway. By the third, you've duplicated checkout logic three times, three webhook handlers have subtle differences, and changing behavior requires touching every gateway-specific code path.

The Abstraction: A Unified Payment Interface

I built a PaymentProvider interface with methods every gateway must implement: createCheckoutSession, capturePayment, refund, and parseWebhook. Each gateway becomes a class implementing this interface. The application layer only knows about PaymentProvider — it never imports Stripe or Paddle directly.

interface PaymentProvider {
  createCheckoutSession(data: CheckoutData): Promise<CheckoutSession>
  capturePayment(sessionId: string): Promise<Payment>
  refund(paymentId: string, amount?: number): Promise<Refund>
  parseWebhook(payload: string, signature: string): WebhookEvent
}

class StripeProvider implements PaymentProvider { ... }
class PaddleProvider implements PaymentProvider { ... }
class XenditProvider implements PaymentProvider { ... }

Idempotency: The Most Important Lesson

Payment providers retry webhooks aggressively. If your webhook handler times out, Stripe will retry — and if you process the same payment twice, you've just double-counted revenue or granted access twice. Every webhook handler must be idempotent.

I solved this by storing a unique event_id from each webhook in a processed_webhooks table with a UNIQUE constraint. Before processing any event, I attempt to insert the event_id. If it conflicts, we've seen it before — return 200 immediately without reprocessing.

Webhook Signature Verification

Never trust webhook payloads without verifying signatures. Each provider signs webhooks differently — Stripe uses HMAC-SHA256 with a timestamp, Paddle uses a public key signature, Xendit uses a shared secret. Each provider class in my system encapsulates this verification logic so it can't be accidentally skipped.

Handling Asynchronous State

Payments aren't synchronous. A user completes checkout, but the actual charge might settle minutes later (especially with bank transfers via Xendit). Your system must handle the gap between 'checkout initiated' and 'payment confirmed' without granting access prematurely or losing track of the order.

I model this with explicit state machines: PENDING → PROCESSING → COMPLETED (or FAILED, REFUNDED). State transitions only happen via webhook events, never from the frontend. The frontend shows 'processing' UI until the webhook updates the database.

Reconciliation: Trust but Verify

Webhooks get lost. Servers go down. Providers have outages. Every payment system needs a reconciliation job — a daily process that queries each provider's API for recent payments and compares them against your database. Any discrepancies get flagged for manual review. This has caught more issues than I'd like to admit.

What I'd Do Differently

If I were starting fresh, I'd reach for a dedicated queue (BullMQ or SQS) for webhook processing from day one. Processing webhooks synchronously inside the HTTP handler works until traffic spikes and you start timing out. Async processing with retries is the right default.

Payment integration isn't hard because the APIs are complex — they're actually well-documented. It's hard because the edge cases compound: network failures, duplicate events, partial refunds, disputes, currency conversion. Build for these from the start, not as an afterthought.

Thanks for reading!

Got thoughts or questions? I'd love to hear from you.

Get in touch