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
- Initiate Payment: Your application sends a B2C request to M-Pesa
- M-Pesa Processes: M-Pesa validates and processes the transaction
- Customer Receives: Money is deposited directly to customer's M-Pesa account
- Receive Callback: M-Pesa sends the result to your callback URL
- 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 shortcodeServer-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-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
B2C supports three types of payments:
| Command ID | Use Case | Description |
|---|---|---|
BusinessPayment | General payments | Standard business payments like refunds |
SalaryPayment | Salary disbursements | Employee salary payments |
PromotionPayment | Promotional offers | Marketing 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
| Parameter | Type | Required | Description |
|---|---|---|---|
amount | number | Yes | Amount to send in KES. Minimum: 10 |
phoneNumber | string | Yes | Recipient's phone number. Format: 254XXXXXXXXX |
commandID | string | Yes | Type of payment: BusinessPayment, SalaryPayment, or PromotionPayment |
remarks | string | Yes | Transaction remarks. Max 100 characters |
occasion | string | No | Optional additional information |
resultUrl | string | No | Override default result callback URL |
timeoutUrl | string | No | Override default timeout callback URL |
B2C Response
interface B2CResponse {
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 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:
- 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 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,
},
});