← Back to list

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…

Shadrack Kipkoech · 2026-06-06 17:39 · 0 claps · 4.6 min read
#lipa-na-mpesa #mpesa #mpesa-integration
Open on Medium ↗
Wiki topics: FIN · Fintech & Banking

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)
  1. You POST a payment request to Safaricom
  2. Safaricom returns an immediate ConversationIDthis is not a confirmation
  3. A few seconds to minutes later, Safaricom POSTs the actual result to your ResultURL
  4. 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 deduction
  • SalaryPayment — for payroll, may have different limits
  • PromotionPayment — 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 OriginatorConversationID you 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