Singularity Payments LogoSingularity Payments

B2C Payments

Send money from your business to customers using M-Pesa B2C

Overview

B2C (Business to Customer) payments allow you to send money from your business account directly to customer M-Pesa accounts. This is useful for salary payments, refunds, promotions, and other disbursements.

How It Works

  1. Initiate Payment: Your application sends a B2C request to M-Pesa
  2. M-Pesa Processes: M-Pesa validates and processes the transaction
  3. Customer Receives: Money is deposited directly to customer's M-Pesa account
  4. Receive Callback: M-Pesa sends the result to your callback URL
  5. Process Result: Your application processes the payment confirmation

Basic Usage

env

MPESA_SHORTCODE=600998 # This worked for me for sandbox, for production replace with your shortcode

Server-Side

const response = await mpesa.client.b2c({
  amount: 1000,
  phoneNumber: "254712345678",
  commandID: "BusinessPayment",
  remarks: "Salary payment for January",
  occasion: "Payroll",
  resultUrl: "https://yourdomain.com/api/mpesa/b2c-result",
  timeoutUrl: "https://yourdomain.com/api/mpesa/b2c-timeout",
});

The resultUrl and timeoutUrl are required fields. It defaults to the urls in the config but if you are doing more than one type of mpesa transactions on your app, you will have different endpoints to handle each transaction.

If you are only doing B2C payments

You can update your env to this

MPESA_RESULT_URL=https://yourdomain.com/api/mpesa/b2c-result
MPESA_TIMEOUT_URL=https://yourdomain.com/api/mpesa/b2c-timeout

The response will look like this if successful:

{
  "ConversationID": "AG_20231222_00004e3c6a87f9f8a4e1",
  "OriginatorConversationID": "12345-67890-1",
  "ResponseCode": "0",
  "ResponseDescription": "Accept the service request successfully."
}

Command IDs

B2C supports three types of payments:

Command IDUse CaseDescription
BusinessPaymentGeneral paymentsStandard business payments like refunds
SalaryPaymentSalary disbursementsEmployee salary payments
PromotionPaymentPromotional offersMarketing promotions and rewards

It is important that they are accurate as they are subject to different taxation. For example, salary payments are subject to PAYE (Pay As You Earn) tax, while promotional payments are not.

Complete Example

import { db } from "./db";
import { disbursements } from "./schema";
import { eq } from "drizzle-orm";
import { mpesa } from "@/lib/mpesa";

interface DisbursementRequest {
  userId: string;
  amount: number;
  phoneNumber: string;
  reason: string;
}

async function processDisbursement(request: DisbursementRequest) {
  const { userId, amount, phoneNumber, reason } = request;

  try {
    // 1. Validate user and amount
    const user = await db
      .select()
      .from(users)
      .where(eq(users.id, userId))
      .limit(1);

    if (user.length === 0) {
      throw new Error("User not found");
    }

    // 2. Initiate B2C payment
    const response = await mpesa.client.b2c({
      amount: Number(amount),
      phoneNumber,
      commandID: "BusinessPayment",
      remarks: reason,
      occasion: `Disbursement-${userId}`,
    });

    // 3. Store the disbursement with pending status
    await db.insert(disbursements).values({
      conversationId: response.ConversationID,
      originatorConversationId: response.OriginatorConversationID,
      userId,
      phoneNumber,
      amount,
      reason,
      status: "pending",
      createdAt: new Date(),
    });

    return {
      success: true,
      conversationId: response.ConversationID,
      message: response.ResponseDescription,
    };
  } catch (error) {
    console.error("Disbursement failed:", error);
    throw error;
  }
}

Handling Callbacks

Configure callbacks in your M-Pesa client initialization:

import { MpesaClient } from "@singularity-payments/nextjs";

const mpesa = new MpesaClient(
  {
    // ... your config
  },
  {
    callbackOptions: {
      onB2CResult: async (data) => {
        if (data.isSuccess) {
          console.log("B2C Payment successful:", {
            transactionId: data.transactionId,
            amount: data.amount,
            recipientPhone: data.recipientPhone,
            charges: data.charges,
          });

          // Update database
          await db
            .update(disbursements)
            .set({
              status: "completed",
              transactionId: data.transactionId,
              completedAt: new Date(),
            })
            .where(eq(disbursements.conversationId, conversationId));

          // Send notification
          await sendNotification(data.recipientPhone, data.amount);
        } else {
          console.log("B2C Payment failed:", data.errorMessage);

          // Update database
          await db
            .update(disbursements)
            .set({
              status: "failed",
              errorMessage: data.errorMessage,
              updatedAt: new Date(),
            })
            .where(eq(disbursements.conversationId, conversationId));
        }
      },
    },
  },
);

API Reference

B2C Request

interface B2CRequest {
  amount: number;
  phoneNumber: string;
  commandID: B2CCommandID;
  remarks: string;
  occasion?: string;
  resultUrl?: string;
  timeoutUrl?: string;
}

Parameters

ParameterTypeRequiredDescription
amountnumberYesAmount to send in KES. Minimum: 10
phoneNumberstringYesRecipient's phone number. Format: 254XXXXXXXXX
commandIDstringYesType of payment: BusinessPayment, SalaryPayment, or PromotionPayment
remarksstringYesTransaction remarks. Max 100 characters
occasionstringNoOptional additional information
resultUrlstringNoOverride default result callback URL
timeoutUrlstringNoOverride default timeout callback URL

B2C Response

interface B2CResponse {
  ConversationID: string;
  OriginatorConversationID: string;
  ResponseCode: string;
  ResponseDescription: string;
}

Response Codes

CodeDescriptionAction
0Success. Request acceptedWait for callback
1RejectedCheck error message
500.001.1001Invalid credentialsVerify initiator name/security credential

Callback Data Structure

interface B2CCallbackData {
  isSuccess: boolean;
  transactionId?: string;
  amount?: number;
  recipientPhone?: string;
  charges?: number;
  errorMessage?: string;
}

Best Practices

1. Store Conversation IDs

Always store conversation IDs to track payment status:

const response = await mpesa.client.b2c({
  /* ... */
});

await db.disbursement.create({
  data: {
    conversationId: response.ConversationID,
    originatorConversationId: response.OriginatorConversationID,
    status: "PENDING",
    // ... other fields
  },
});

2. Validate Recipients

Verify recipient details before initiating payments:

async function validateRecipient(phoneNumber: string) {
  // Check if phone number is registered
  const user = await db.user.findFirst({
    where: { phoneNumber },
  });

  if (!user || !user.mpesaVerified) {
    throw new Error("Invalid recipient");
  }

  return user;
}

3. Handle All Result Codes

Process different payment outcomes appropriately:

callbackOptions: {
  onB2CResult: async (data) => {
    if (data.isSuccess) {
      await processSuccessfulPayment(data);
    } else {
      await handleFailedPayment(data);

      // Notify admin for manual review
      if (data.errorMessage?.includes("insufficient funds")) {
        await notifyAdmin("B2C failed - insufficient balance");
      }
    }
  },
}

4. Implement Retry Logic

Handle temporary failures with retry mechanisms:

async function disburseFundsWithRetry(
  request: DisbursementRequest,
  maxRetries = 3,
) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await processDisbursement(request);
    } catch (error: any) {
      if (attempt === maxRetries) throw error;

      // Wait before retry
      await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
    }
  }
}

5. Monitor Transaction Charges

Track B2C charges for financial reporting:

callbackOptions: {
  onB2CResult: async (data) => {
    if (data.isSuccess) {
      // Record charges
      await db.transactionCharges.create({
        data: {
          transactionId: data.transactionId,
          amount: data.amount,
          charges: data.charges,
          type: "B2C",
        },
      });
    }
  },
}

Troubleshooting

"Invalid Initiator"

Cause: Incorrect initiator name or security credential

Solution:

# Verify credentials in developer portal
# Ensure initiator is approved for B2C transactions

"Insufficient Balance"

Cause: Business account has insufficient funds

Solution:

// Check balance before B2C
const balance = await mpesa.client.accountBalance();
if (balance.availableBalance < amount) {
  throw new Error("Insufficient business account balance");
}

Callback Not Received

Causes:

  1. Result URL not publicly accessible
  2. Firewall blocking Safaricom IPs
  3. Route not properly configured

Solutions:

# 1. Use ngrok for local development
ngrok http 3000

# 2. Verify route exists
# app/api/mpesa/b2c-result/route.ts

# 3. Allow Safaricom IPs
# 196.201.214.200, 196.201.214.206, etc.

"Transaction Failed"

Cause: Recipient phone number invalid or not registered

Solution:

// Validate phone number format
function isValidKenyanPhone(phone: string): boolean {
  return /^254[17]\d{8}$/.test(phone);
}

// Verify M-Pesa registration
await verifyMpesaRegistration(phoneNumber);

Security Considerations

1. Validate Callback IP

Always validate callbacks are from Safaricom:

callbackOptions: {
  validateIp: true,
}

2. Implement Approval Workflow

Add approval for large disbursements:

async function initiateDisbursement(request: DisbursementRequest) {
  if (request.amount > 10000) {
    // Require approval
    await createApprovalRequest(request);
    return { status: "pending_approval" };
  }

  return await processDisbursement(request);
}

3. Rate Limiting

Protect against abuse with rate limits:

const mpesa = new MpesaClient(config, {
  rateLimitOptions: {
    enabled: true,
    maxRequests: 50,
    windowMs: 60000,
  },
});
Edit on GitHub

On this page