Engineering Developer Experience: A Deep Dive into the Pawapay Node.js SDK  Playground

Engineering Developer Experience: A Deep Dive into the Pawapay Node.js SDK Playground

posted 8 min read

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:

  1. No Type Safety: Compiler won't warn you about invalid data types
  2. Idempotency Issues: Math.random() for transaction IDs causes collisions
  3. 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:

  1. Direct Sandbox Connection: Real API calls with CORS handling
  2. Schema Validation: Real-time input validation using the same Zod schemas as the SDK
  3. 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.

1 Comment

0 votes
0

More Posts

Beyond the Crisis: Why Engineering Your Personal Health Baseline Matters

Huifer - Jan 24

ContainerCraft: A Deep Dive into Node.js Containerization

TheToriqul - Jan 30, 2025

The "Privacy vs. Utility" trade-off in FinTech AI is a false dichotomy

Pocket Portfolio - Mar 30

From Subjective Narratives to Objective Data: Re-engineering the Elderly Care Communication Loop

Huifer - Jan 28

Legacy in the Data: Transforming Family Medical History into a Blueprint for Longevity

Huifer - Jan 29
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

2 comments
2 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!