Singularity Payments LogoSingularity Payments

B2B Payments

Send money from your business to businesses using M-Pesa B2B

Overview

B2B (Business to Business) payments allow you to send money from your business account to another business account. This is useful for supplier payments, inter-company transfers, and business-to-business transactions.

How It Works

  1. Initiate Payment: Your application sends a B2B request to M-Pesa
  2. M-Pesa Processes: M-Pesa validates and processes the transaction
  3. Business Receives: Money is deposited to the receiving business account
  4. Receive Callback: M-Pesa sends the result to your callback URL
  5. Process Result: Your application processes the payment confirmation

Basic Usage

Server-Side

const response = await mpesa.client.b2b({
  amount: 5000,
  partyB: "600998",
  commandID: "BusinessPayBill",
  senderIdentifierType: "4",
  receiverIdentifierType: "4",
  accountReference: "INV-2024-001",
  remarks: "Payment for supplies",
  resultUrl: "https://your-domain.ngrok-free.dev/api/mpesa/b2b-result",
  timeoutUrl: "https://your-domain.ngrok-free.dev/api/mpesa/b2b-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/b2b-result
MPESA_TIMEOUT_URL=https://yourdomain.com/api/mpesa/b2b-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

B2B supports different types of business transfers:

Command IDUse CaseDescription
BusinessPayBillPaybill to PaybillTransfer from your Paybill to another Paybill
BusinessBuyGoodsPaybill to TillTransfer from your Paybill to a Till number
DisburseFundsToBusinessUtility paymentsDisburse funds to business utility accounts
BusinessToBusinessTransferGeneral transfersStandard business-to-business transfers
MerchantToMerchantTransferMerchant transfersBetween merchant accounts

Identifier Types

TypeDescriptionUsage
1MSISDNMobile number
2Till NumberBuy Goods Till
4PaybillPaybill number

Complete Example

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

interface SupplierPaymentRequest {
  supplierId: string;
  invoiceId: string;
  amount: number;
}

async function paySupplier(request: SupplierPaymentRequest) {
  const { supplierId, invoiceId, amount } = request;

  try {
    const supplier = await db
      .select()
      .from(suppliers)
      .where(eq(suppliers.id, supplierId))
      .limit(1);

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

    const invoice = await db
      .select()
      .from(invoices)
      .where(eq(invoices.id, invoiceId))
      .limit(1);

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

    if (invoice[0].status === "paid") {
      throw new Error("Invoice already paid");
    }

    const response = await mpesa.client.b2b({
      amount: Number(amount),
      partyB: supplier[0].paybillNumber,
      commandID: "BusinessPayBill",
      senderIdentifierType: "4",
      receiverIdentifierType: "4",
      accountReference: invoice[0].invoiceNumber,
      remarks: `Payment for invoice ${invoice[0].invoiceNumber}`,
    });

    await db.insert(payments).values({
      conversationId: response.ConversationID,
      originatorConversationId: response.OriginatorConversationID,
      supplierId,
      invoiceId,
      amount,
      status: "pending",
      createdAt: new Date(),
    });

    return {
      success: true,
      conversationId: response.ConversationID,
      message: response.ResponseDescription,
    };
  } catch (error) {
    console.error("Supplier payment 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: {
      onB2BResult: async (data) => {
        if (data.isSuccess) {
          console.log("B2B Payment successful:", {
            transactionId: data.transactionId,
            amount: data.amount,
          });

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

          await db
            .update(invoices)
            .set({
              status: "paid",
              paidAt: new Date(),
            })
            .where(eq(invoices.id, invoiceId));

          await sendPaymentConfirmation(supplierId, invoiceId);
        } else {
          console.log("B2B Payment failed:", data.errorMessage);

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

          await notifyPaymentFailure(supplierId, data.errorMessage);
        }
      },
    },
  },
);

API Reference

B2B Request

interface B2BRequest {
  amount: number;
  partyB: string;
  commandID: B2BCommandID;
  senderIdentifierType: "1" | "2" | "4";
  receiverIdentifierType: "1" | "2" | "4";
  remarks: string;
  accountReference: string;
  resultUrl?: string;
  timeoutUrl?: string;
}

Parameters

ParameterTypeRequiredDescription
amountnumberYesAmount to send in KES. Minimum: 1
partyBstringYesReceiving business shortcode
commandIDstringYesType of B2B transfer
senderIdentifierTypestringYesSender account type: 1 (MSISDN), 2 (Till), 4 (Paybill)
receiverIdentifierTypestringYesReceiver account type: 1 (MSISDN), 2 (Till), 4 (Paybill)
accountReferencestringYesReference for the transaction. Max 13 characters
remarksstringYesTransaction remarks. Max 100 characters
resultUrlstringNoOverride default result callback URL
timeoutUrlstringNoOverride default timeout callback URL

B2B Response

interface B2BResponse {
  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 B2BCallbackData {
  isSuccess: boolean;
  transactionId?: string;
  amount?: number;
  errorMessage?: string;
}

Payment Scenarios

Paybill to Paybill

await mpesa.client.b2b({
  amount: 10000,
  partyB: "888888",
  commandID: "BusinessPayBill",
  senderIdentifierType: "4",
  receiverIdentifierType: "4",
  accountReference: "ACC-12345",
  remarks: "Monthly subscription",
});

Paybill to Till

await mpesa.client.b2b({
  amount: 5000,
  partyB: "555555",
  commandID: "BusinessBuyGoods",
  senderIdentifierType: "4",
  receiverIdentifierType: "2",
  accountReference: "ORDER-789",
  remarks: "Purchase goods",
});

Utility Payment

await mpesa.client.b2b({
  amount: 2500,
  partyB: "320320",
  commandID: "DisburseFundsToBusiness",
  senderIdentifierType: "4",
  receiverIdentifierType: "4",
  accountReference: "UTIL-456",
  remarks: "Utility payment",
});

Best Practices

1. Store Transaction References

Always store conversation IDs and account references:

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

await db.payment.create({
  data: {
    conversationId: response.ConversationID,
    originatorConversationId: response.OriginatorConversationID,
    accountReference: accountReference,
    status: "PENDING",
  },
});

2. Validate Business Accounts

Verify recipient business details before payment:

async function validateBusinessAccount(paybillNumber: string) {
  const business = await db.business.findFirst({
    where: { paybillNumber },
  });

  if (!business || !business.verified) {
    throw new Error("Invalid business account");
  }

  return business;
}

3. Implement Approval Workflow

Add multi-level approval for B2B payments:

async function initiateB2BPayment(request: B2BPaymentRequest) {
  if (request.amount > 50000) {
    await createApprovalRequest({
      type: "B2B",
      amount: request.amount,
      recipient: request.partyB,
      requiredApprovers: 2,
    });
    return { status: "pending_approval" };
  }

  return await processB2BPayment(request);
}

4. Handle Reconciliation

Track payments for accounting reconciliation:

callbackOptions: {
  onB2BResult: async (data) => {
    if (data.isSuccess) {
      await db.reconciliation.create({
        data: {
          transactionId: data.transactionId,
          amount: data.amount,
          type: "B2B",
          status: "completed",
          reconciled: false,
        },
      });
    }
  },
}

5. Monitor Failed Transactions

Track and retry failed B2B payments:

async function retryFailedPayments() {
  const failed = await db.payment.findMany({
    where: {
      status: "failed",
      retryCount: { lt: 3 },
    },
  });

  for (const payment of failed) {
    try {
      await processB2BPayment(payment);
      await db.payment.update({
        where: { id: payment.id },
        data: { retryCount: payment.retryCount + 1 },
      });
    } catch (error) {
      console.error(`Retry failed for payment ${payment.id}`);
    }
  }
}

Troubleshooting

"Invalid Initiator"

Cause: Incorrect initiator name or security credential

Solution:

// Verify credentials in M-Pesa configuration
const mpesa = new MpesaClient({
  initiatorName: "CORRECT_INITIATOR_NAME",
  securityCredential: "CORRECT_SECURITY_CREDENTIAL",
  // ... other config
});

"Invalid ShortCode"

Cause: PartyB shortcode is incorrect or not registered

Solution:

// Validate shortcode format
function isValidShortcode(shortcode: string): boolean {
  return /^\d{6,7}$/.test(shortcode);
}

// Verify with supplier database
const supplier = await db.supplier.findFirst({
  where: { paybillNumber: partyB },
});

"Insufficient Balance"

Cause: Business account has insufficient funds

Solution:

const balance = await mpesa.client.accountBalance();

if (balance.availableBalance < amount) {
  throw new Error("Insufficient balance for B2B transfer");
}

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 routes exist
# app/api/mpesa/b2b-result/route.ts
# app/api/mpesa/b2b-timeout/route.ts

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

"Transaction Failed"

Cause: Receiver account not configured for B2B

Solution:

// Ensure receiver has enabled B2B on their account
// Contact receiver to verify B2B settings
// Use correct identifier types for the transaction

Security Considerations

1. Validate Callback IP

Always validate callbacks are from Safaricom:

callbackOptions: {
  validateIp: true,
}

2. Implement Transaction Limits

Set maximum transaction amounts:

const MAX_B2B_AMOUNT = 1000000;

async function processB2BPayment(request: B2BPaymentRequest) {
  if (request.amount > MAX_B2B_AMOUNT) {
    throw new Error(`Amount exceeds maximum limit of ${MAX_B2B_AMOUNT}`);
  }

  return await mpesa.client.b2b(request);
}

3. Audit Trail

Maintain comprehensive audit logs:

await db.auditLog.create({
  data: {
    action: "B2B_PAYMENT",
    userId: userId,
    amount: amount,
    recipient: partyB,
    status: "initiated",
    timestamp: new Date(),
  },
});

4. Duplicate Prevention

Prevent duplicate payments:

async function isDuplicatePayment(
  invoiceId: string,
  amount: number,
): Promise<boolean> {
  const existing = await db.payment.findFirst({
    where: {
      invoiceId,
      amount,
      status: { in: ["pending", "completed"] },
    },
  });

  return !!existing;
}

5. Rate Limiting

Protect against abuse:

const mpesa = new MpesaClient(config, {
  rateLimitOptions: {
    enabled: true,
    maxRequests: 30,
    windowMs: 60000,
  },
});

Batch Processing

Process multiple B2B payments efficiently:

async function processBatchPayments(payments: B2BPaymentRequest[]) {
  const results = [];

  for (const payment of payments) {
    try {
      const result = await mpesa.client.b2b(payment);
      results.push({ success: true, data: result });

      await new Promise((resolve) => setTimeout(resolve, 1000));
    } catch (error) {
      results.push({ success: false, error: error.message });
    }
  }

  return results;
}
Edit on GitHub

On this page