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
- Initiate Payment: Your application sends a B2B request to M-Pesa
- M-Pesa Processes: M-Pesa validates and processes the transaction
- Business Receives: Money is deposited to the receiving business account
- Receive Callback: M-Pesa sends the result to your callback URL
- 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-timeoutThe 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 ID | Use Case | Description |
|---|---|---|
BusinessPayBill | Paybill to Paybill | Transfer from your Paybill to another Paybill |
BusinessBuyGoods | Paybill to Till | Transfer from your Paybill to a Till number |
DisburseFundsToBusiness | Utility payments | Disburse funds to business utility accounts |
BusinessToBusinessTransfer | General transfers | Standard business-to-business transfers |
MerchantToMerchantTransfer | Merchant transfers | Between merchant accounts |
Identifier Types
| Type | Description | Usage |
|---|---|---|
1 | MSISDN | Mobile number |
2 | Till Number | Buy Goods Till |
4 | Paybill | Paybill 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
| Parameter | Type | Required | Description |
|---|---|---|---|
amount | number | Yes | Amount to send in KES. Minimum: 1 |
partyB | string | Yes | Receiving business shortcode |
commandID | string | Yes | Type of B2B transfer |
senderIdentifierType | string | Yes | Sender account type: 1 (MSISDN), 2 (Till), 4 (Paybill) |
receiverIdentifierType | string | Yes | Receiver account type: 1 (MSISDN), 2 (Till), 4 (Paybill) |
accountReference | string | Yes | Reference for the transaction. Max 13 characters |
remarks | string | Yes | Transaction remarks. Max 100 characters |
resultUrl | string | No | Override default result callback URL |
timeoutUrl | string | No | Override default timeout callback URL |
B2B Response
interface B2BResponse {
ConversationID: string;
OriginatorConversationID: string;
ResponseCode: string;
ResponseDescription: string;
}Response Codes
| Code | Description | Action |
|---|---|---|
0 | Success. Request accepted | Wait for callback |
1 | Rejected | Check error message |
500.001.1001 | Invalid credentials | Verify 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:
- Result URL not publicly accessible
- Firewall blocking Safaricom IPs
- 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 transactionSecurity 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;
}