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.
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