DEV Community

Cover image for Simplifying African Mobile Payments: Introducing the Pawapay Node.js SDK & Live Playground
katorymnddev
katorymnddev

Posted on

Simplifying African Mobile Payments: Introducing the Pawapay Node.js SDK & Live Playground

If you’ve ever tried to build a payment integration targeted at the African market, you know the landscape is… unique.

Credit cards aren't the primary driver. Mobile Money is king.

But integrating with MTN, Airtel, Vodafone, M-Pesa across multiple countries individually is a massive headache of fragmented APIs, varying documentation standards, and long compliance processes.

This is where Pawapay shines. It aggregates these massive mobile money providers into a single, unified API.

Today, I am thrilled to announce two major tools that make integrating Pawapay into your JavaScript/TypeScript stack faster than ever: the brand new, fully-typed Pawapay Node.js SDK, and an interactive Live Demo Playground.

This article is a surgical look at the "hows" and "whens" of using these new tools to handle payments in Africa.


The Problem with Raw HTTP Requests

While Pawapay’s REST API is clean, consuming raw REST APIs in a production Node.js environment quickly requires boilerplate. You have to handle:

  • Request signing and authentication headers.
  • Manual type definitions for request bodies and responses (if you use TypeScript).
  • Error handling normalization.
  • Retries and timeout logic.

We built the @katorymnd/pawapay-node-sdk to abstract this away, letting you focus on your business logic, not HTTP plumbing.

Key SDK Features:

  • 🛡️ First-class TypeScript Support: Written in TypeScript for robust type definitions and Intellisense autocomplete.
  • Promise-based: Built for modern async/await workflows.
  • 📦 Comprehensive: Covers the full spectrum of payments: Deposits (collections), Payouts (disbursements), Refunds, and account validation.

The Surgical "How-To": Using the SDK

Let's dissect a real-world scenario: An e-commerce checkout where a user in Ghana wants to pay using MTN Mobile Money.

Step 1: Installation and Setup

First, get the package into your project.

npm install @katorymnd/pawapay-node-sdk
# or
yarn add @katorymnd/pawapay-node-sdk
# or 
pnpm add @katorymnd/pawapay-node-sdk

Enter fullscreen mode Exit fullscreen mode

Initialize the client with your credentials. In a real app, use environment variables.

//pawapay.js

const path = require('path');
const winston = require('winston');
require('dotenv').config(); 

// Load the SDK and destructure the public exports directly
// We use 'ApiClient' because that's the class name export
const { ApiClient, Helpers, FailureCodeHelper } = require('@katorymnd/pawapay-node-sdk');

// ========== LOGGING SETUP ==========
const logsDir = path.resolve(__dirname, '../logs');

const logger = winston.createLogger({
    format: winston.format.combine(
        winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
        winston.format.errors({ stack: true }),
        winston.format.json()
    ),
    transports: [
        new winston.transports.File({
            filename: path.join(logsDir, 'payment_success.log'),
            level: 'info',
            format: winston.format.combine(
                winston.format.timestamp(),
                winston.format.json()
            )
        }),
        new winston.transports.File({
            filename: path.join(logsDir, 'payment_failed.log'),
            level: 'error',
            format: winston.format.combine(
                winston.format.timestamp(),
                winston.format.json()
            )
        })
    ]
});

if (process.env.NODE_ENV !== 'production') {
    logger.add(new winston.transports.Console({
        format: winston.format.combine(
            winston.format.colorize(),
            winston.format.simple()
        )
    }));
}
// ========== END LOGGING SETUP ==========

class PawaPayService {
    /**
     * @param {Object} config - Optional configuration
     * @param {string} config.token - Custom API Token to override .env
     */
    constructor(config = {}) {
        // Prioritize ENV
        const activeToken = process.env.PAWAPAY_SANDBOX_API_TOKEN;

        // Debug log to confirm token is being used
        const maskedToken = activeToken ? `${activeToken.substring(0, 5)}...` : 'NONE';
        console.log(`[PawaPayService] Initializing with token: ${maskedToken}`);

        this.pawapay = new ApiClient({
            apiToken: activeToken,
            environment: 'sandbox', // or production
            licenseKey: process.env.KATORYMND_PAWAPAY_SDK_LICENSE_KEY, //it's required, and the SDK will not work if it is not defined
            sslVerify: false
        });
    }
    /**
     * Deposit money to a mobile money account
     * @param {Object} depositData - Deposit details
     * @param {string} apiVersion - 'v1' or 'v2'
     */
    async deposit(depositData, apiVersion = 'v1') {
        // Use the Helper directly from the SDK
        const depositId = Helpers.generateUniqueId();

        try {
            const {
                amount,
                currency,
                mno,
                payerMsisdn,
                description,
                metadata = []
            } = depositData;

            // 1. STRICT VALIDATION
            if (!amount || !mno || !payerMsisdn || !description || !currency) {
                const missingMsg = 'Validation failed - Missing required fields';
                logger.error(missingMsg, depositData);
                return { success: false, error: missingMsg };
            }

            // Validate Amount
            const amountRegex = /^\d+(\.\d{1,2})?$/;
            if (!amountRegex.test(amount) || parseFloat(amount) <= 0) {
                const msg = 'Invalid amount. Must be positive with max 2 decimals.';
                logger.error(msg, { amount });
                return { success: false, error: msg };
            }

            // Validate Description
            const descriptionRegex = /^[A-Za-z0-9 ]{1,22}$/;
            if (!descriptionRegex.test(description)) {
                const msg = 'Invalid description. Max 22 chars, alphanumeric only.';
                logger.error(msg, { description });
                return { success: false, error: msg };
            }

            logger.info('Initiating deposit', {
                depositId,
                amount,
                currency,
                mno,
                apiVersion
            });

            // 2. PROCESS DEPOSIT
            let response;

            if (apiVersion === 'v2') {
                response = await this.pawapay.initiateDepositV2(
                    depositId,
                    amount,
                    currency,
                    payerMsisdn,
                    mno, // provider
                    description, // customerMessage
                    null, // clientReferenceId
                    null, // preAuthorisationCode
                    metadata
                );
            } else {
                response = await this.pawapay.initiateDeposit(
                    depositId,
                    amount,
                    currency,
                    mno, // correspondent
                    payerMsisdn,
                    description, // statementDescription
                    metadata
                );
            }

            // 3. HANDLE RESPONSE
            if (response.status === 200 || response.status === 201) {
                logger.info('Deposit initiated successfully', { depositId, status: response.status });

                const statusCheck = await this.checkTransactionStatus(depositId, apiVersion);

                return {
                    success: true,
                    depositId,
                    transactionId: depositId,
                    reference: depositId,
                    status: statusCheck.status || 'SUBMITTED',
                    message: 'Deposit initiated successfully',
                    rawResponse: response,
                    statusCheck: statusCheck
                };
            } else {
                // 4. HANDLE ERRORS
                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;
                    // Use the SDK's built-in error helper
                    errorMessage = FailureCodeHelper.getFailureMessage(failureCode);
                } else if (response.response?.message) {
                    errorMessage = response.response.message;
                }

                logger.error('Deposit initiation failed', {
                    depositId,
                    error: errorMessage,
                    failureCode,
                    response: response.response
                });

                return {
                    success: false,
                    error: errorMessage,
                    depositId,
                    statusCode: response.status,
                    rawResponse: response
                };
            }

        } catch (error) {
            logger.error('System Error during deposit', {
                depositId,
                error: error.message,
                stack: error.stack
            });

            return {
                success: false,
                error: error.message || 'Internal processing error',
                depositId
            };
        }
    }

  /**
     * Check Status of ANY transaction (Deposit, Payout, Refund)
     * @param {string} transactionId - The ID to check
     * @param {string} apiVersion - 'v1' or 'v2'
     * @param {string} type - 'deposit', 'payout', 'refund', 'remittance'
     */
    async checkTransactionStatus(transactionId, apiVersion = 'v1', type = 'deposit') {
        try {
            let response;

            // Pass the 'type' to the SDK so it hits the correct endpoint (e.g., /payouts vs /deposits)
            if (apiVersion === 'v2') {
                response = await this.pawapay.checkTransactionStatusV2(transactionId, type);
            } else {
                response = await this.pawapay.checkTransactionStatus(transactionId, type);
            }

            logger.info(`Checking ${type} status`, { transactionId, status: response.status });

            if (response.status === 200) {
                let data;
                let status;

                // Normalize V1 (Array/Object) vs V2 (Object wrapper)
                if (apiVersion === 'v2') {
                    if (response.response?.status !== 'FOUND') {
                        return {
                            success: true,
                            status: 'PROCESSING',
                            transactionId,
                            message: 'Transaction processing'
                        };
                    }
                    data = response.response.data;
                    status = data?.status || 'UNKNOWN';
                } else {
                    // V1 legacy can be array [ { ... } ] or object
                    const raw = response.response;
                    data = Array.isArray(raw) ? raw[0] : raw;
                    status = data?.status || 'UNKNOWN';
                }

                return {
                    success: true,
                    status: status,
                    transactionId: transactionId,
                    data: data,
                    rawResponse: response
                };
            } else {
                return {
                    success: false,
                    error: `Status check failed with code ${response.status}`,
                    statusCode: response.status
                };
            }
        } catch (error) {
            logger.error('Status check error', { error: error.message });
            return {
                success: false,
                error: error.message || 'Status check failed'
            };
        }
    }

    validateToken(token) {
        if (!token || token.trim() === '') return { isValid: false, error: 'Token is required' };
        return { isValid: true, type: 'JWT', message: 'Valid token format' };
    }

    /**
     * Create a Payment Page Session
     * @param {Object} pageData - Payment details
     * @param {string} apiVersion - 'v1' or 'v2'
     */
    async initiatePaymentPage(pageData, apiVersion = 'v1') {
        const depositId = Helpers.generateUniqueId();

        try {
            const {
                amount,
                currency,
                payerMsisdn,
                description,
                returnUrl,
                metadata = [],
                country = 'UGA', // Default to Uganda for testing
                reason = 'Payment'
            } = pageData;

            // 1. STRICT VALIDATION
            if (!amount || !description || !currency || !returnUrl) {
                const missingMsg = 'Validation failed - Missing required fields (returnUrl is mandatory)';
                logger.error(missingMsg, pageData);
                return { success: false, error: missingMsg };
            }

            logger.info('Initiating Payment Page', {
                depositId,
                amount,
                apiVersion
            });

            // 2. PREPARE PAYLOAD & CALL SDK
            let response;

            // Normalize phone (remove +)
            const cleanMsisdn = payerMsisdn ? payerMsisdn.replace(/\D/g, '') : null;

            if (apiVersion === 'v2') {
                // V2 Payload Construction
                const v2Params = {
                    depositId,
                    returnUrl,
                    customerMessage: description,
                    amountDetails: {
                        amount: String(amount),
                        currency: currency
                    },
                    phoneNumber: cleanMsisdn,
                    country,
                    reason,
                    metadata
                };
                response = await this.pawapay.createPaymentPageSessionV2(v2Params);
            } else {
                // V1 Payload Construction
                const v1Params = {
                    depositId,
                    returnUrl,
                    amount: String(amount),
                    currency,
                    msisdn: cleanMsisdn,
                    statementDescription: description,
                    country,
                    reason,
                    metadata
                };
                response = await this.pawapay.createPaymentPageSession(v1Params);
            }

            // 3. HANDLE RESPONSE
            // Note: API returns 200/201 for success
            if (response.status >= 200 && response.status < 300) {
                const redirectUrl = response.response?.redirectUrl || response.response?.url;

                logger.info('Payment Page created', { depositId, redirectUrl });

                return {
                    success: true,
                    depositId,
                    redirectUrl,
                    message: 'Session created successfully',
                    rawResponse: response
                };
            } else {
                // 4. HANDLE ERRORS
                const errorMsg = response.response?.message || 'Failed to create payment session';

                logger.error('Payment Page creation failed', {
                    depositId,
                    error: errorMsg,
                    response: response.response
                });

                return {
                    success: false,
                    error: errorMsg,
                    depositId,
                    statusCode: response.status
                };
            }

        } catch (error) {
            logger.error('System Error during payment page creation', {
                depositId,
                error: error.message,
                stack: error.stack
            });

            return {
                success: false,
                error: error.message || 'Internal processing error',
                depositId
            };
        }
    }


    /**
 * Payout money to a mobile money account (Disbursement)
 * @param {Object} payoutData - Payout details
 * @param {string} apiVersion - 'v1' or 'v2'
 */
    async payout(payoutData, apiVersion = 'v1') {
        const payoutId = Helpers.generateUniqueId();

        try {
            // 1. EXTRACT DATA WITH FALLBACKS
            let {
                amount,
                currency,
                mno,            // Logic might send this
                provider,       // V2 logic might send this
                correspondent,  // V1 logic might send this
                recipientMsisdn,
                description,    // Direct description
                statementDescription, // V1 alternative
                customerMessage, // V2 alternative
                reason,         // Another possible field
                metadata = []
            } = payoutData;

            // 🛠️ FIX: Normalize the operator code
            // If 'mno' is undefined, use 'provider' (V2) or 'correspondent' (V1)
            const resolvedMno = mno || provider || correspondent;

            // ============================================================
            // SURGICAL FIX: Ensure 'description' is never missing
            // PawaPay API requires this field for both V1 and V2.
            // ============================================================
            const resolvedDescription = description
                || statementDescription
                || customerMessage
                || reason
                || 'Transaction Processing'; // Ultimate fallback

            // 2. STRICT VALIDATION & DEBUG LOGGING
            // We check specific fields to give a precise error message
            const missingFields = [];
            if (!amount) missingFields.push('amount');
            if (!resolvedMno) missingFields.push(`mno (looked for: mno, provider, correspondent)`);
            if (!recipientMsisdn) missingFields.push('recipientMsisdn');
            if (!resolvedDescription) missingFields.push('description');
            if (!currency) missingFields.push('currency');

            if (missingFields.length > 0) {
                const missingMsg = `Validation failed - Missing fields: [${missingFields.join(', ')}]`;

                // 🔍 DEBUG: Construct a detailed log entry for the error.log
                const debugPayload = {
                    ERROR_TYPE: 'PAYOUT_VALIDATION_ERROR',
                    PAYOUT_ID: payoutId,
                    API_VERSION: apiVersion,
                    MISSING: missingFields,
                    RESOLVED_MNO: resolvedMno || 'UNDEFINED (This is likely the issue)',
                    RESOLVED_DESCRIPTION: resolvedDescription || 'UNDEFINED',
                    RAW_RECEIVED: JSON.stringify(payoutData, null, 2) // Pretty print the full object
                };

                // Log to your system logger
                logger.error(missingMsg, debugPayload);

                // Return failure with details
                return {
                    success: false,
                    error: missingMsg,
                    debug: debugPayload // Return this so the frontend/controller can see it too
                };
            }

            // Assign the resolved values back to variables used in logic
            mno = resolvedMno;
            description = resolvedDescription; // 🚨 CRITICAL: Update the description variable

            // Validate Amount Format
            const amountRegex = /^\d+(\.\d{1,2})?$/;
            if (!amountRegex.test(amount) || parseFloat(amount) <= 0) {
                const msg = 'Invalid amount. Must be positive with max 2 decimals.';
                logger.error(msg, { amount, payoutId });
                return { success: false, error: msg };
            }

            logger.info('Initiating payout', {
                payoutId,
                amount,
                currency,
                mno,
                description, // Log the resolved description
                apiVersion
            });

            // 3. PROCESS PAYOUT
            let response;

            if (apiVersion === 'v2') {
                // V2 Payout
                response = await this.pawapay.initiatePayoutV2(
                    payoutId,
                    amount,
                    currency,
                    recipientMsisdn,
                    mno, // provider
                    description, // customerMessage - using the resolved description
                    metadata
                );
            } else {
                // V1 Payout
                response = await this.pawapay.initiatePayout(
                    payoutId,
                    amount,
                    currency,
                    mno, // correspondent
                    recipientMsisdn, // recipient address
                    description, // statementDescription - using the resolved description
                    metadata
                );
            }

            // 4. HANDLE RESPONSE
            if (response.status === 200 || response.status === 201 || response.status === 202) {
                logger.info('Payout initiated successfully', {
                    payoutId,
                    status: response.status,
                    description // Log successful description
                });

                const statusCheck = await this.checkTransactionStatus(payoutId, apiVersion);

                return {
                    success: true,
                    payoutId,
                    transactionId: payoutId,
                    status: statusCheck.status || 'SUBMITTED',
                    message: 'Payout initiated successfully',
                    rawResponse: response,
                    statusCheck: statusCheck
                };
            } else {
                // 5. HANDLE ERRORS
                let errorMessage = 'Payout 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('Payout initiation failed', {
                    payoutId,
                    error: errorMessage,
                    failureCode,
                    response: response.response,
                    description // Log description even on failure
                });

                return {
                    success: false,
                    error: errorMessage,
                    payoutId,
                    statusCode: response.status,
                    rawResponse: response
                };
            }

        } catch (error) {
            logger.error('System Error during payout', {
                payoutId,
                error: error.message,
                stack: error.stack,
                inputData: JSON.stringify(payoutData) // Log input on crash too
            });

            return {
                success: false,
                error: error.message || 'Internal processing error',
                payoutId
            };
        }
    }


    /**
   * Initiate a Refund (Partial or Full)
   * @param {Object} refundData - Refund details
   * @param {string} apiVersion - 'v1' or 'v2'
   */
  async refund(refundData, apiVersion = 'v1') {
    const refundId = Helpers.generateUniqueId();

    try {
      const {
        depositId,
        amount,
        currency, // Required for V2
        reason,
        metadata = []
      } = refundData;

      // 1. STRICT VALIDATION
      if (!depositId || !amount) {
        const missingMsg = 'Validation failed - Missing required fields (depositId, amount)';
        logger.error(missingMsg, refundData);
        return { success: false, error: missingMsg };
      }

      // V2 Specific Validation
      if (apiVersion === 'v2' && !currency) {
        const msg = 'Validation failed - V2 Refunds require a currency code';
        logger.error(msg, refundData);
        return { success: false, error: msg };
      }

      // Validate Amount
      const amountRegex = /^\d+(\.\d{1,2})?$/;
      if (!amountRegex.test(amount) || parseFloat(amount) <= 0) {
        const msg = 'Invalid amount. Must be positive with max 2 decimals.';
        logger.error(msg, { amount });
        return { success: false, error: msg };
      }

      logger.info('Initiating refund', {
        refundId,
        depositId,
        amount,
        currency,
        apiVersion
      });

      // 2. PROCESS REFUND
      let response;

      if (apiVersion === 'v2') {
        // V2 Refund
        response = await this.pawapay.initiateRefundV2(
          refundId,
          depositId,
          amount,
          currency,
          metadata
        );
      } else {
        // V1 Refund
        response = await this.pawapay.initiateRefund(
          refundId,
          depositId,
          amount,
          metadata
        );
      }

      // 3. HANDLE RESPONSE
      // Refunds typically return 200/201/202
      if (response.status >= 200 && response.status < 300) {
        logger.info('Refund initiated successfully', { refundId, status: response.status });

        // Check status immediately (passing 'refund' as type is critical)
        const statusCheck = await this.checkTransactionStatus(refundId, apiVersion, 'refund');

        return {
          success: true,
          refundId,
          transactionId: refundId,
          depositId: depositId,
          status: statusCheck.status || 'SUBMITTED',
          message: 'Refund initiated successfully',
          rawResponse: response,
          statusCheck: statusCheck
        };
      } else {
        // 4. HANDLE ERRORS
        let errorMessage = 'Refund 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('Refund initiation failed', {
          refundId,
          depositId,
          error: errorMessage,
          failureCode,
          response: response.response
        });

        return {
          success: false,
          error: errorMessage,
          refundId,
          statusCode: response.status,
          rawResponse: response
        };
      }

    } catch (error) {
      logger.error('System Error during refund', {
        refundId,
        depositId: refundData.depositId,
        error: error.message,
        stack: error.stack
      });

      return {
        success: false,
        error: error.message || 'Internal processing error',
        refundId
      };
    }
    }

}

module.exports = PawaPayService;

Enter fullscreen mode Exit fullscreen mode

Step 2: Initiating a Deposit (Collection)

The user clicks "Pay Now". You need to trigger a mobile money prompt on their phone.

Instead of constructing a complex JSON fetch request, you call a typed method.

// test-sdk-deposit.js
const path = require('path');
// Ensure strict loading of the root .env file
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });

const PawaPayService = require('./pawapayService');

/**
 * Run a full test of the PawaPay SDK integration (Deposits)
 */
async function testSDKConnection() {
    console.log('\n========================================');
    console.log('🧪 PAWAPAY SDK DEPOSIT TEST (With Status Check)');
    console.log('========================================\n');

    const service = new PawaPayService();

    // 1. Validate Token Format
    console.log(' Step 1: Validating API Token...');
    const testToken = process.env.PAWAPAY_SANDBOX_API_TOKEN;
    const validation = service.validateToken(testToken);

    if (!validation.isValid) {
        console.error(' Token Validation Failed:', validation);
        return;
    }
    console.log(' Token looks valid:', validation.type);

    // Common Test Data (Uganda MTN Sandbox)
    const commonData = {
        amount: '1000',
        currency: 'UGX',
        mno: 'MTN_MOMO_UGA',
        payerMsisdn: '256783456789', // Valid Sandbox Payer
        description: 'SDK Integration Test'
    };

    // --- 2. Test V1 Deposit ---
    console.log('\n Step 2: Testing V1 Deposit...');
    try {
        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
            });

            // WAIT AND CHECK
            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} ]`);
            if (statusCheck.status === 'FAILED') {
                console.warn(`   Reason: ${statusCheck.data?.failureReason?.failureMessage || 'Unknown'}`);
            }

        } else {
            console.error(' V1 Failed:', v1Result.error);
        }
    } catch (error) {
        console.error(' V1 Exception:', error.message);
    }

    // --- 3. Test V2 Deposit ---
    console.log('\n Step 3: Testing V2 Deposit...');
    try {
        const v2Result = await service.deposit({
            ...commonData,
            description: 'V2 Test Payment',
            metadata: [
                { orderId: "ORD-SDK-TEST" },
                { customerId: "test-user@example.com", isPII: true }
            ]
        }, 'v2');

        if (v2Result.success) {
            console.log(' V2 Initiation Success:', {
                depositId: v2Result.depositId,
                status: v2Result.status
            });

            // WAIT AND CHECK
            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} ]`);
            if (statusCheck.status === 'FAILED') {
                // V2 failure messages are nested in 'data' usually
                const msg = statusCheck.data?.failureReason?.failureMessage || 'Unknown';
                console.warn(`   Reason: ${msg}`);
            }

        } else {
            console.error(' V2 Failed:', v2Result.error);
        }
    } catch (error) {
        console.error(' V2 Exception:', error.message);
    }

    console.log('\n SDK Testing Complete');
    process.exit(0);
}

// Run test if called directly
if (require.main === module) {
    testSDKConnection().catch(console.error);
}

module.exports = { testSDKConnection };
Enter fullscreen mode Exit fullscreen mode

Why this is better surgically:
Notice the Intellisense. You cannot accidentally send a numeric amount when a string is required, or forget the currency field. The TypeScript definitions catch these errors before you deploy.

Step 3: The Critical Part-Handling Webhooks

Mobile money payments are asynchronous. The code above initiates the request, but the user might take 30 seconds to enter their PIN on their phone.

You cannot rely on the immediate response to confirm payment. You must rely on ID confirmation / Webhooks. Always check the authenticity of any process - in this case, you need to check the depositID returned for confirmation.
Note: Webhook logic is not implemented.

The SDK helps you define the structure of the data you will receive, and this //pawapay.js example is exactly what the SDK needs to work as it should. So if you run the test using node, the transaction will go through as expected.


The Game Changer: The Live Demo Playground

Documentation is great. SDKs are helpful. But nothing beats actually pressing a button and seeing what happens.

We realized that developers often spend hours just setting up a sandbox environment to make that very first successful API call.

We decided to kill that friction.

Introducing the Pawapay Live Playground.

👉 [https://katorymnd.dev/pawapay-demo/nodejs/]

The playground allows you to interact with the sandbox API directly from your browser without writing a single line of code.

Use the playground to:

  1. Test Provider Availability: Instantly see which mobile money providers are online in which countries in the sandbox.
  2. Simulate Flows: Trigger a deposit and see the exact JSON structure returned by the API.
  3. Debug Parameters: Not sure if the phone number format for Zambia is correct? Try it in the playground first. If it works there, you know your code inputs are wrong.
  4. View Payload Structures: Copy-paste request and response payloads directly from the playground into your unit tests.

The "Whens": Use Cases for this SDK

When should you reach for the @katorymnd/pawapay-node-sdk?

  1. E-commerce Platforms (Node.js Backends): If you are building a custom shop using NestJS, Express, or Fastify targeting Africa, this is your payment layer.
  2. Gig Economy & Marketplaces: You need to collect money from customers (Deposits) and automatically pay out your vendors/drivers/freelancers at the end of the week (Payouts). The SDK handles bulk payouts seamlessly.
  3. SaaS Subscription Collection: While mobile money isn't always ideal for recurring billing, it's perfect for prepaid "top-up" models for service access.

Wrap Up

Payments in Africa are growing exponentially, and developer tooling needs to keep up. We built the Node.js SDK and the Playground to treat developer experience as a first-class citizen in the Pawapay ecosystem.

We want you to spend less time reading docs and debugging HTTP headers, and more time building your product.

Ready to dive in?

  • 🎮 Try the Live Playground: https://katorymnd.dev/pawapay-demo/nodejs/
  • 💾 Get the SDK on NPM: npm i @katorymnd/pawapay-node-sdk
  • 📖 Read the Docs & Source: https://github.com/katorymnd/pawapay-node-sdk

If you try it out, please open an issue on GitHub with feedback or feature requests. We are actively iterating on this!

Top comments (0)