How I Built a Production M-Pesa B2C Payment System in PHP (The Hard Way)
A no-fluff guide to Business-to-Customer M-Pesa disbursements using Daraja API — with callbacks, background polling, and real error…
How I Built a Production M-Pesa B2C Payment System in PHP (The Hard Way)
A no-fluff guide to Business-to-Customer M-Pesa disbursements using Daraja API — with callbacks, background polling, and real error handling.
Everyone talks about STK Push. Tutorials for it are everywhere. But the moment you need to send money to a customer — refunds, commissions, withdrawals, salaries — you’re on your own. The Safaricom documentation is sparse, the error messages are cryptic, and the community content is almost nonexistent.
I learned this the hard way while building PesaVoucher, a contactless payments platform for Kenyan merchants. Here’s everything I wish I had known.
What is B2C and When Should You Use It?
B2C (Business to Customer) is the Daraja API endpoint that lets your business send money directly to a customer’s M-Pesa number. Common use cases:
- Customer refunds
- Commission/agent payouts
- Salary disbursements
- Loyalty/cashback rewards
- Withdrawal requests from wallets
Unlike STK Push (where the customer initiates), B2C is server-initiated — your backend pushes the funds, and Safaricom calls your server back with the result.
How It Actually Works
The flow is asynchronous and has three parts:
Your Server → Safaricom (initiate) → Safaricom → Your Callback URL (result)
↘ Your Timeout URL (if no response)
- You POST a payment request to Safaricom
- Safaricom returns an immediate
ConversationID— this is not a confirmation - A few seconds to minutes later, Safaricom POSTs the actual result to your
ResultURL - If it times out, Safaricom hits your
QueueTimeOutURL
This async nature is where most developers get tripped up.
Step 1: Get Your Credentials
You need:
- Consumer Key & Secret from developer.safaricom.co.ke
- Initiator Name — a username you create in the M-Pesa portal
- Security Credential — your Initiator Password encrypted with Safaricom’s public certificate
- Shortcode — your paybill or till number
Generating the Security Credential
function generateSecurityCredential(string $initiatorPassword): string {
$certPath = __DIR__ . '/certs/ProductionCertificate.cer'; // or SandboxCertificate.cer
$cert = file_get_contents($certPath);
$pubKey = openssl_pkey_get_public($cert);
openssl_public_encrypt($initiatorPassword, $encrypted, $pubKey, OPENSSL_PKCS1_PADDING);
return base64_encode($encrypted);
}
Download the certificates from Safaricom’s developer portal. Use SandboxCertificate.cer for testing, ProductionCertificate.cer for live.
Step 2: Get an Access Token
function getMpesaAccessToken(string $consumerKey, string $consumerSecret): string {
$credentials = base64_encode("$consumerKey:$consumerSecret");
$ch = curl_init('https://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Basic $credentials"],
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
return $response['access_token'];
}
Cache this token — it’s valid for 1 hour. Don’t fetch a new one on every request or you’ll hit rate limits.
Step 3: Initiate the B2C Payment
function initiateB2C(array $params): array {
$token = getMpesaAccessToken(CONSUMER_KEY, CONSUMER_SECRET);
$payload = [
'InitiatorName' => $params['initiator_name'],
'SecurityCredential' => $params['security_credential'],
'CommandID' => 'BusinessPayment', // or SalaryPayment / PromotionPayment
'Amount' => $params['amount'],
'PartyA' => $params['shortcode'],
'PartyB' => $params['phone'], // 2547XXXXXXXX format
'Remarks' => $params['remarks'],
'QueueTimeOutURL' => $params['timeout_url'],
'ResultURL' => $params['result_url'],
'Occasion' => $params['occasion'] ?? '',
];
$ch = curl_init('https://api.safaricom.co.ke/mpesa/b2c/v3/paymentrequest');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
],
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
return $response;
}
CommandID options:
BusinessPayment— general disbursement, no tax deductionSalaryPayment— for payroll, may have different limitsPromotionPayment— for promotions/rewards
Step 4: Handle the Result Callback
This is the most critical part. Safaricom will POST JSON to your ResultURL. You must respond with HTTP 200 quickly — if you don't, Safaricom will retry.
// result_callback.php
$payload = json_decode(file_get_contents('php://input'), true);
$result = $payload['Result'] ?? null;
if (!$result) {
http_response_code(400);
exit;
}
$resultCode = $result['ResultCode'];
$conversationId = $result['ConversationID'];
$originatorConvId = $result['OriginatorConversationID'];
$resultDesc = $result['ResultDesc'];
// Respond to Safaricom immediately
header('Content-Type: application/json');
echo json_encode(['ResultCode' => 0, 'ResultDesc' => 'Accepted']);
ob_flush();
flush();
// Now do your DB work
if ($resultCode === 0) {
// Success — extract the items
$items = [];
foreach ($result['ResultParameters']['ResultParameter'] as $param) {
$items[$param['Key']] = $param['Value'];
}
$mpesaReceiptNumber = $items['TransactionReceipt'] ?? null;
$amount = $items['TransactionAmount'] ?? null;
$receiverNumber = $items['ReceiverPartyPublicName'] ?? null;
$completedTime = $items['TransactionCompletedDateTime'] ?? null;
// Update your DB: mark transaction as SUCCESS
updateTransactionStatus($originatorConvId, 'SUCCESS', $mpesaReceiptNumber, $amount);
} else {
// Failed — log the reason
updateTransactionStatus($originatorConvId, 'FAILED', null, null, $resultDesc);
}
Always echo the response before doing DB work. Safaricom has a short timeout on your callback response. Use ob_flush() + flush() to send the HTTP response before executing slow operations.
Step 5: Handle the Timeout Callback
Sometimes Safaricom queues your request and it just… sits there. Your QueueTimeOutURL gets called in that case.
// timeout_callback.php
$payload = json_decode(file_get_contents('php://input'), true);
$result = $payload['Result'] ?? null;
header('Content-Type: application/json');
echo json_encode(['ResultCode' => 0, 'ResultDesc' => 'Accepted']);
ob_flush(); flush();
if ($result) {
$originatorConvId = $result['OriginatorConversationID'];
updateTransactionStatus($originatorConvId, 'TIMEOUT');
// Queue for manual review or retry
}
Step 6: Background Polling Service
Here’s the part no tutorial covers: what if the callback never arrives? It happens — network issues, server downtime, Safaricom hiccups. You need a polling service that checks on pending transactions.
// B2CPoller.php — run via cron every 5 minutes
$pdo = new PDO(DSN, DB_USER, DB_PASS);
$stmt = $pdo->prepare("
SELECT id, originator_conversation_id, created_at
FROM b2c_transactions
WHERE status = 'PENDING'
AND created_at < NOW() - INTERVAL '3 minutes'
AND created_at > NOW() - INTERVAL '24 hours'
");
$stmt->execute();
$pending = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($pending as $tx) {
$status = queryB2CStatus($tx['originator_conversation_id']);
if ($status) {
updateTransactionStatus($tx['originator_conversation_id'], $status['ResultCode'] === 0 ? 'SUCCESS' : 'FAILED');
}
}
function queryB2CStatus(string $originatorConvId): ?array {
$token = getMpesaAccessToken(CONSUMER_KEY, CONSUMER_SECRET);
// Use the transaction status query API
$payload = [
'Initiator' => INITIATOR_NAME,
'SecurityCredential' => SECURITY_CREDENTIAL,
'CommandID' => 'TransactionStatusQuery',
'TransactionID' => '', // if you have the M-Pesa receipt
'OriginatorConversationID' => $originatorConvId,
'PartyA' => SHORTCODE,
'IdentifierType' => '4',
'ResultURL' => RESULT_URL,
'QueueTimeOutURL' => TIMEOUT_URL,
'Remarks' => 'Status check',
'Occasion' => '',
];
$ch = curl_init('https://api.safaricom.co.ke/mpesa/transactionstatus/v1/query');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
],
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
return $response;
}
Add to crontab:
*/5 * * * * php /var/www/your-app/B2CPoller.php >> /var/log/b2c_poller.log 2>&1
Common Errors and What They Mean
Error Code Message Fix 401.002.01 Invalid Access Token Token expired — refresh it 400.002.02 Bad Request Check your JSON payload keys exactly 500.001.1001 Unable to lock subscriber Customer's M-Pesa is locked/inactive B2C.B2C04 Insufficient funds Your shortcode balance is low 2001 Wrong credentials Security Credential is wrong — re-encrypt
Production Checklist
- [ ] Use HTTPS on your callback URLs — Safaricom rejects HTTP
- [ ] Whitelist Safaricom IPs on your firewall for callback endpoints
- [ ] Store the
OriginatorConversationIDyou generate — it's your transaction reference - [ ] Always respond HTTP 200 to callbacks before doing anything else
- [ ] Cache your access token (Redis, file, or DB) — don’t fetch per request
- [ ] Run a background poller for stuck PENDING transactions
- [ ] Log the full raw callback payload — you’ll need it for disputes
Final Thoughts
B2C is powerful but unforgiving. The async nature trips up most developers the first time. The key insight is: the initial API response means nothing — only the callback matters.
Once you get the pattern right, you can build robust payout systems, commission engines, and wallet withdrawals on top of M-Pesa.
I’m Shaddy, a full-stack developer in Nairobi building PesaVoucher — a contactless payment platform for Kenyan merchants. Follow me for more M-Pesa and Flutter content.
메타데이터
- post_id
- b9dd4d731550
- slug
- how-i-built-a-production-m-pesa-b2c-payment-system-in-php-the-hard-way-b9dd4d731550
- url
- https://medium.com/@shadrackrito/how-i-built-a-production-m-pesa-b2c-payment-system-in-php-the-hard-way-b9dd4d731550
- canonical_url
- https://medium.com/@shadrackrito/how-i-built-a-production-m-pesa-b2c-payment-system-in-php-the-hard-way-b9dd4d731550
- author_url
- https://medium.com/@shadrackrito
- status
- ok
- fetched_at
- 2026-06-26 06:47:43