In the fragmented landscape of African Fintech, "integration fatigue" is a tangible bottleneck. As developers, we often spend 70% of our time deciphering inconsistent documentation, handling HTTP timeouts, and normalizing error codes, and only 30% building the actual product.
When integrating Mobile Money API aggregators (like Pawapay) across borders, dealing with MTN in Uganda, Airtel in Zambia, and Vodafone in Tanzania, the complexity multiplies.
I recently set out to solve this friction for the Node.js community. My goal wasn't just to write a wrapper; it was to engineer a superior Developer Experience (DX).
The result is the launch of the Pawapay Node.js SDK and a Live Interactive Playground. This article is a surgical breakdown of the architecture, the code patterns, and the "why" behind the build.
1. The Problem: The "Raw HTTP" Trap
To understand the value of the SDK, we first have to look at what it replaces.
A typical "raw" integration in a Node.js Express app often looks like this "Spaghetti Code":
// THE OLD WAY: Vulnerable, Untyped, and Verbose
const axios = require('axios');
async function payWithMtn(phone, amount) {
try {
const response = await axios.post('https://api.pawapay.cloud/deposits', {
depositId: 'txn_' + Math.random(),
amount: amount,
currency: 'GHS',
payer: { type: 'MSISDN', address: phone },
mobileMoneyProviderId: 'MTN'
}, {
headers: {
'Authorization': `Bearer ${process.env.API_KEY}`,
'Content-Type': 'application/json'
}
});
return response.data;
} catch (error) {
console.log(error.response.data);
throw new Error('Payment failed');
}
}
Why this fails in production:
- No Type Safety: Compiler won't warn you about invalid data types
- Idempotency Issues:
Math.random() for transaction IDs causes collisions
- Ambiguous Errors: Generic error catching makes debugging impossible
2. The Solution: Structured Service Architecture
I architected the @katorymnd/pawapay-node-sdk with TypeScript as a first-class citizen. The philosophy: Catch errors in VS Code IDE, not in production logs.
Configuration Setup
# PawaPay API Configuration
PAWAPAY_SANDBOX_API_TOKEN=your_sandbox_api_token_here
PAWAPAY_PRODUCTION_API_TOKEN=your_production_api_token_here
# Katorymnd SDK Licensing (Required for Production and sandbox)
KATORYMND_PAWAPAY_SDK_LICENSE_KEY=your_sdk_license_key_here
PAWAPAY_SDK_LICENSE_DOMAIN=your-licensed-domain.com
PAWAPAY_SDK_LICENSE_SECRET=your_sdk_license_secret_here
Let me show you how to build the complete service layer step by step.
Step 1: Foundation Setup with Logging
// Part 1: Foundation & Logging Setup
const path = require('path');
const winston = require('winston');
require('dotenv').config();
const { ApiClient, Helpers, FailureCodeHelper } = require('@katorymnd/pawapay-node-sdk');
const logsDir = path.resolve(__dirname, '../logs');
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.File({
filename: path.join(logsDir, 'payment_success.log'),
level: 'info'
}),
new winston.transports.File({
filename: path.join(logsDir, 'payment_failed.log'),
level: 'error'
})
]
});
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
Step 2: Service Class Initialization
// Part 2: Service Class Structure
class PawaPayService {
constructor(config = {}) {
const activeToken = process.env.PAWAPAY_SANDBOX_API_TOKEN;
const maskedToken = activeToken ? `${activeToken.substring(0, 5)}...` : 'NONE';
console.log(`[PawaPayService] Initializing with token: ${maskedToken}`);
this.pawapay = new ApiClient({
apiToken: activeToken,
environment: 'sandbox',
licenseKey: process.env.KATORYMND_PAWAPAY_SDK_LICENSE_KEY,
sslVerify: false
});
}
}
Step 3: Core Deposit Implementation
// Part 3: Deposit Implementation
async deposit(depositData, apiVersion = 'v1') {
const depositId = Helpers.generateUniqueId();
const { amount, currency, mno, payerMsisdn, description, metadata = [] } = depositData;
// Strict validation
if (!amount || !mno || !payerMsisdn || !description || !currency) {
logger.error('Validation failed - Missing required fields', depositData);
return { success: false, error: 'Validation failed - Missing required fields' };
}
const amountRegex = /^\d+(\.\d{1,2})?$/;
if (!amountRegex.test(amount) || parseFloat(amount) <= 0) {
logger.error('Invalid amount format', { amount });
return { success: false, error: 'Invalid amount' };
}
logger.info('Initiating deposit', { depositId, amount, currency, mno, apiVersion });
let response;
if (apiVersion === 'v2') {
response = await this.pawapay.initiateDepositV2(
depositId, amount, currency, payerMsisdn, mno, description, null, null, metadata
);
} else {
response = await this.pawapay.initiateDeposit(
depositId, amount, currency, mno, payerMsisdn, description, metadata
);
}
if (response.status === 200 || response.status === 201) {
logger.info('Deposit initiated successfully', { depositId });
const statusCheck = await this.checkTransactionStatus(depositId, apiVersion);
return {
success: true,
depositId,
transactionId: depositId,
status: statusCheck.status || 'SUBMITTED',
message: 'Deposit initiated',
rawResponse: response,
statusCheck: statusCheck
};
} else {
let errorMessage = 'Deposit initiation failed';
let failureCode = 'UNKNOWN';
if (response.response?.rejectionReason?.rejectionMessage) {
errorMessage = response.response.rejectionReason.rejectionMessage;
} else if (response.response?.failureReason?.failureCode) {
failureCode = response.response.failureReason.failureCode;
errorMessage = FailureCodeHelper.getFailureMessage(failureCode);
} else if (response.response?.message) {
errorMessage = response.response.message;
}
logger.error('Deposit initiation failed', { depositId, error: errorMessage });
return { success: false, error: errorMessage, depositId, statusCode: response.status };
}
}
Step 4: Universal Status Checking
// Part 4: Status Checking Implementation
async checkTransactionStatus(transactionId, apiVersion = 'v1', type = 'deposit') {
let response;
if (apiVersion === 'v2') {
response = await this.pawapay.checkTransactionStatusV2(transactionId, type);
} else {
response = await this.pawapay.checkTransactionStatus(transactionId, type);
}
if (response.status === 200) {
let data, status;
if (apiVersion === 'v2') {
if (response.response?.status !== 'FOUND') {
return { success: true, status: 'PROCESSING', transactionId, message: 'Processing' };
}
data = response.response.data;
status = data?.status || 'UNKNOWN';
} else {
const raw = response.response;
data = Array.isArray(raw) ? raw[0] : raw;
status = data?.status || 'UNKNOWN';
}
return { success: true, status: status, transactionId, data: data };
} else {
return { success: false, error: `Status check failed: ${response.status}` };
}
}
Step 5: Payment Page Session Creation
// Part 5: Payment Page Implementation
async initiatePaymentPage(pageData, apiVersion = 'v1') {
const depositId = Helpers.generateUniqueId();
const { amount, currency, payerMsisdn, description, returnUrl, metadata = [], country = 'UGA', reason = 'Payment' } = pageData;
if (!amount || !description || !currency || !returnUrl) {
logger.error('Missing required fields for payment page', pageData);
return { success: false, error: 'Missing required fields' };
}
const cleanMsisdn = payerMsisdn ? payerMsisdn.replace(/\D/g, '') : null;
let response;
if (apiVersion === 'v2') {
response = await this.pawapay.createPaymentPageSessionV2({
depositId, returnUrl, customerMessage: description,
amountDetails: { amount: String(amount), currency },
phoneNumber: cleanMsisdn, country, reason, metadata
});
} else {
response = await this.pawapay.createPaymentPageSession({
depositId, returnUrl, amount: String(amount), currency,
msisdn: cleanMsisdn, statementDescription: description,
country, reason, metadata
});
}
if (response.status >= 200 && response.status < 300) {
const redirectUrl = response.response?.redirectUrl || response.response?.url;
logger.info('Payment Page created', { depositId });
return { success: true, depositId, redirectUrl, message: 'Session created' };
} else {
const errorMsg = response.response?.message || 'Failed to create session';
logger.error('Payment Page creation failed', { depositId, error: errorMsg });
return { success: false, error: errorMsg, depositId };
}
}
Step 6: Payout Implementation (Disbursements)
// Part 6: Payout Implementation
async payout(payoutData, apiVersion = 'v1') {
const payoutId = Helpers.generateUniqueId();
let { amount, currency, mno, provider, correspondent, recipientMsisdn,
description, statementDescription, customerMessage, reason, metadata = [] } = payoutData;
const resolvedMno = mno || provider || correspondent;
const resolvedDescription = description || statementDescription || customerMessage || reason || 'Transaction Processing';
if (!amount || !resolvedMno || !recipientMsisdn || !resolvedDescription || !currency) {
logger.error('Payout validation failed', { payoutId, ...payoutData });
return { success: false, error: 'Missing required fields' };
}
const amountRegex = /^\d+(\.\d{1,2})?$/;
if (!amountRegex.test(amount) || parseFloat(amount) <= 0) {
logger.error('Invalid payout amount', { amount, payoutId });
return { success: false, error: 'Invalid amount' };
}
logger.info('Initiating payout', { payoutId, amount, currency, mno: resolvedMno, apiVersion });
let response;
if (apiVersion === 'v2') {
response = await this.pawapay.initiatePayoutV2(
payoutId, amount, currency, recipientMsisdn, resolvedMno, resolvedDescription, metadata
);
} else {
response = await this.pawapay.initiatePayout(
payoutId, amount, currency, resolvedMno, recipientMsisdn, resolvedDescription, metadata
);
}
if (response.status === 200 || response.status === 201 || response.status === 202) {
const statusCheck = await this.checkTransactionStatus(payoutId, apiVersion);
return {
success: true,
payoutId,
transactionId: payoutId,
status: statusCheck.status || 'SUBMITTED',
message: 'Payout initiated',
rawResponse: response
};
} else {
let errorMessage = 'Payout initiation failed';
if (response.response?.rejectionReason?.rejectionMessage) {
errorMessage = response.response.rejectionReason.rejectionMessage;
} else if (response.response?.failureReason?.failureCode) {
errorMessage = FailureCodeHelper.getFailureMessage(response.response.failureReason.failureCode);
} else if (response.response?.message) {
errorMessage = response.response.message;
}
logger.error('Payout initiation failed', { payoutId, error: errorMessage });
return { success: false, error: errorMessage, payoutId, statusCode: response.status };
}
}
Step 7: Refund Implementation
// Part 7: Refund Implementation
async refund(refundData, apiVersion = 'v1') {
const refundId = Helpers.generateUniqueId();
const { depositId, amount, currency, reason, metadata = [] } = refundData;
if (!depositId || !amount) {
logger.error('Refund validation failed', refundData);
return { success: false, error: 'Missing required fields' };
}
if (apiVersion === 'v2' && !currency) {
logger.error('V2 Refund missing currency', refundData);
return { success: false, error: 'Currency required for V2' };
}
const amountRegex = /^\d+(\.\d{1,2})?$/;
if (!amountRegex.test(amount) || parseFloat(amount) <= 0) {
logger.error('Invalid refund amount', { amount });
return { success: false, error: 'Invalid amount' };
}
logger.info('Initiating refund', { refundId, depositId, amount, currency, apiVersion });
let response;
if (apiVersion === 'v2') {
response = await this.pawapay.initiateRefundV2(refundId, depositId, amount, currency, metadata);
} else {
response = await this.pawapay.initiateRefund(refundId, depositId, amount, metadata);
}
if (response.status >= 200 && response.status < 300) {
const statusCheck = await this.checkTransactionStatus(refundId, apiVersion, 'refund');
return {
success: true,
refundId,
transactionId: refundId,
depositId,
status: statusCheck.status || 'SUBMITTED',
message: 'Refund initiated',
rawResponse: response
};
} else {
let errorMessage = 'Refund initiation failed';
if (response.response?.rejectionReason?.rejectionMessage) {
errorMessage = response.response.rejectionReason.rejectionMessage;
} else if (response.response?.failureReason?.failureCode) {
errorMessage = FailureCodeHelper.getFailureMessage(response.response.failureReason.failureCode);
} else if (response.response?.message) {
errorMessage = response.response.message;
}
logger.error('Refund initiation failed', { refundId, error: errorMessage });
return { success: false, error: errorMessage, refundId, statusCode: response.status };
}
}
Step 8: Export the Complete Service
// Part 8: Export the Service
module.exports = PawaPayService;
Complete File Assembly: Combine these 8 parts sequentially to create pawapayService.js. Each section handles specific functionality while maintaining clean separation of concerns.
3. Real-World Testing Implementation
//test-sdk-deposit.js
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
const PawaPayService = require('./pawapayService');
async function testSDKConnection() {
console.log('\n========================================');
console.log('PAWAPAY SDK DEPOSIT TEST');
console.log('========================================\n');
const service = new PawaPayService();
const commonData = {
amount: '1000',
currency: 'UGX',
mno: 'MTN_MOMO_UGA',
payerMsisdn: '256783456789',
description: 'SDK Integration Test'
};
// Test V1 Deposit
console.log('\nStep 1: Testing V1 Deposit...');
const v1Result = await service.deposit({
...commonData,
description: 'V1 Test Payment'
}, 'v1');
if (v1Result.success) {
console.log('V1 Initiation Success:', {
depositId: v1Result.depositId,
status: v1Result.status
});
console.log('Waiting 5 seconds for V1 propagation...');
await new Promise(r => setTimeout(r, 5000));
console.log('Checking V1 Status...');
const statusCheck = await service.checkTransactionStatus(
v1Result.depositId,
'v1',
'deposit'
);
console.log(`Final V1 Status: [ ${statusCheck.status} ]`);
}
// Test V2 Deposit
console.log('\nStep 2: Testing V2 Deposit...');
const v2Result = await service.deposit({
...commonData,
description: 'V2 Test Payment',
metadata: [
{ orderId: "ORD-SDK-TEST" },
{ customerId: "*Emails are not allowed*", isPII: true }
]
}, 'v2');
if (v2Result.success) {
console.log('V2 Initiation Success:', {
depositId: v2Result.depositId,
status: v2Result.status
});
console.log('Waiting 5 seconds for V2 propagation...');
await new Promise(r => setTimeout(r, 5000));
console.log('Checking V2 Status...');
const statusCheck = await service.checkTransactionStatus(
v2Result.depositId,
'v2',
'deposit'
);
console.log(`Final V2 Status: [ ${statusCheck.status} ]`);
}
console.log('\nSDK Testing Complete');
process.exit(0);
}
testSDKConnection();
4. The Critical Component: Webhook Security
Payments are asynchronous. The SDK provides utilities to type-check incoming payloads, ensuring your server processes only valid data from authenticated sources.
5. Visualizing the Invisible: The Live Playground
Documentation is static. APIs are dynamic. The Pawapay Live Playground at katorymnd.dev solves "Parameter Anxiety" with:
- Direct Sandbox Connection: Real API calls with CORS handling
- Schema Validation: Real-time input validation using the same Zod schemas as the SDK
- JSON Export: Copy-paste successful payloads directly into your unit tests
6. Why This Matters for Your Stack
For Node.js backends (NestJS, Express, Fastify) or Serverless stacks (AWS Lambda, Vercel Functions):
- 70% Code Reduction: Replace ~200 lines of axios config with 3 lines of SDK instantiation
- Zero Maintenance: API updates handled via
npm update
- Production Ready: Pre-configured compliance headers and timestamp formats
Call to Action
This project embodies my software engineering philosophy: Complex logic should be invisible to code consumers. The heavy lifting happens inside the package, leaving your implementation layer clean and maintainable.
Let's build better payment flows for Africa.