Bonaventure OgetoBy Bonaventure Ogeto|

Building a Payment Provider Abstraction Layer

Right abstraction: a PaymentGateway interface with methods matching your business operations (initializePayment, verifyPayment, refundPayment, createWebhookHandler). Each gateway is an adapter implementing this interface. Your application calls the interface; the adapter calls the gateway. Wrong abstraction: mapping every Paystack feature to a generic interface — you lose gateway-specific features (Paystack subaccounts, Paystack Plans) that do not map cleanly to a generic interface. Principle: abstract at the level of your business operations, not at the level of every API endpoint.

Payment Gateway Interface and Paystack Adapter

// Payment gateway interface — what your application code depends on
class PaymentGateway {
  async initializePayment({ amount, email, reference, callbackUrl, metadata }) {
    throw new Error('Not implemented');
  }

  async verifyPayment(reference) {
    throw new Error('Not implemented');
  }

  async refundPayment({ transactionId, amount }) {
    throw new Error('Not implemented');
  }

  validateWebhookSignature(rawBody, signature) {
    throw new Error('Not implemented');
  }

  parseWebhookEvent(body) {
    throw new Error('Not implemented');
  }
}

// Paystack adapter
class PaystackAdapter extends PaymentGateway {
  constructor(secretKey) {
    super();
    this.secretKey = secretKey;
    this.baseUrl = 'https://api.paystack.co';
  }

  get headers() {
    return { Authorization: 'Bearer ' + this.secretKey, 'Content-Type': 'application/json' };
  }

  async initializePayment({ amount, email, reference, callbackUrl, metadata }) {
    var res = await fetch(this.baseUrl + '/transaction/initialize', {
      method: 'POST',
      headers: this.headers,
      body: JSON.stringify({ amount, email, reference, callback_url: callbackUrl, metadata }),
    });
    var data = (await res.json()).data;
    return {
      authorizationUrl: data.authorization_url,
      accessCode: data.access_code,
      reference: data.reference,
    };
  }

  async verifyPayment(reference) {
    var res = await fetch(this.baseUrl + '/transaction/verify/' + reference, { headers: this.headers });
    var data = (await res.json()).data;
    return {
      status: data.status === 'success' ? 'success' : 'failed',
      amount: data.amount, // in kobo
      reference: data.reference,
      gatewayResponse: data.gateway_response,
    };
  }

  async refundPayment({ transactionId, amount }) {
    var res = await fetch(this.baseUrl + '/refund', {
      method: 'POST',
      headers: this.headers,
      body: JSON.stringify({ transaction: transactionId, amount }),
    });
    return (await res.json()).data;
  }

  validateWebhookSignature(rawBody, signature) {
    var crypto = require('crypto');
    var expected = crypto.createHmac('sha512', process.env.PAYSTACK_WEBHOOK_SECRET)
      .update(rawBody).digest('hex');
    return signature === expected;
  }

  parseWebhookEvent(body) {
    var event = JSON.parse(body);
    return { eventType: event.event, data: event.data };
  }
}

// Your application uses the interface, not the Paystack adapter directly
var gateway = new PaystackAdapter(process.env.PAYSTACK_SECRET_KEY);
var { authorizationUrl } = await gateway.initializePayment({ amount: 500000, email: 'user@example.com', reference: 'PAY_001', callbackUrl: 'https://yourapp.com/callback' });

Learn More

See gateway failover design to understand the failover pattern this abstraction enables.

Sign up for the McTaba newsletter

Key Takeaways

  • Abstraction lives at your business operation level: initializePayment, verifyPayment, refundPayment, listTransactions.
  • Each gateway is an adapter that maps the interface to the gateway's actual API.
  • Gateway-specific features (Paystack subaccounts) should be used directly — do not abstract them generically.
  • Build the abstraction with one gateway (Paystack). Add the second adapter only when you have a concrete need.
  • The interface is the contract your application code depends on — it should not change when you add a new adapter.

Frequently Asked Questions

Should I build the abstraction layer before I need a second gateway?
Build a thin version of it early: the interface definition and one adapter (Paystack). The interface is just a class with method signatures — it costs almost nothing to add. The value is that when you need a second gateway (for failover or country coverage), you implement the second adapter instead of rewriting your checkout code. Build the interface now, implement additional adapters when you actually need them.
How do I handle gateway-specific features like Paystack subaccounts?
Use them directly. Gateway-specific features that do not generalize should not be forced through the abstraction. Your marketplace payout code can call the PaystackAdapter directly for subaccount operations — that code is already Paystack-specific by nature. The abstraction layer is for your standard checkout flow (initialize → verify → refund), which is common across gateways. Forcing subaccounts into a generic interface creates a leaky abstraction that is worse than no abstraction.
Does this pattern work for subscription management too?
Subscription APIs differ too much across gateways to abstract cleanly. Paystack uses Plans and Subscriptions; Stripe uses Products, Prices, and Subscriptions; Flutterwave uses a different model. Attempting a unified subscription interface produces a lowest-common-denominator design that loses important features from each gateway. Keep subscription management gateway-specific and encapsulate it separately from the standard checkout abstraction.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon