This guide sends money from your M-Pesa B2C shortcode to customers’ M-Pesa wallets from a Laravel app, with the Daraja B2C API: refunds, payouts to sellers or drivers, salaries. You’ll build:
- an
MpesaB2Cservice: a cached token, the SecurityCredential and the payment request, - a
b2c_payoutstable and aB2cPayoutsservice that never sends the same payout twice, - ResultURL and QueueTimeOutURL routes that only accept results that match a payout you sent,
- a feature test that runs every case without calling Safaricom, with a throwaway certificate.
Every code block was run in a fresh Laravel 13 app, and the test at the end passes. Safaricom’s own Daraja pages need a login, so the API details come from published integrations, checked in October 2026. The Node.js version of this guide covers where those integrations disagree, and the Transaction Status API.
How a payout works
B2C has no approval step: the money leaves your account as soon as Safaricom processes the request, so a duplicate request sends it twice. And unlike MTN MoMo, Daraja has no “this request doesn’t exist” answer you can rely on, so a payout with no answer is never sent again automatically.
new ──send──▶ sending ──ResponseCode 0──▶ accepted ──Result 0──▶ paid (or review if the amount differs)
│ │ └──Result ≠ 0──▶ failed
├──4xx (clear refusal)──▶ failed
└──timeout / 5xx──▶ unknown ◀──QueueTimeOutURL──┘
└──Result arrives later──▶ paid / failed
Before you start
- On the Daraja portal, create a sandbox app and copy its consumer key and secret. The sandbox test credentials include a B2C shortcode, an initiator name and an initiator password.
- Download Safaricom’s sandbox certificate from the Daraja documentation and put it outside your web root, for example
storage/app/certs/sandbox.cer. - Add the values to
.envand never commit it. Generate the callback secret withphp -r "echo bin2hex(random_bytes(24));":
MPESA_ENV=sandbox
MPESA_CONSUMER_KEY=your-consumer-key
MPESA_CONSUMER_SECRET=your-consumer-secret
MPESA_B2C_SHORTCODE=your-b2c-shortcode
MPESA_INITIATOR_NAME=your-initiator-name
MPESA_INITIATOR_PASSWORD=your-initiator-password
MPESA_CERT_PATH=/var/www/app/storage/app/certs/sandbox.cer
MPESA_B2C_COMMAND=BusinessPayment
MPESA_B2C_CALLBACK_SECRET=paste-a-random-string-of-32-or-more-characters
MPESA_B2C_CALLBACK_BASE=https://your-domain.com
MPESA_B2C_COMMAND is one of BusinessPayment, SalaryPayment or PromotionPayment. Keep “mpesa” and “safaricom” out of your callback URLs: developers report that Safaricom drops callbacks to URLs containing them, which is why the routes here live under /webhooks/b2c/.
Map the values in config/services.php:
<?php
// config/services.php
return [
// ...your existing services
'mpesa_b2c' => [
'base_url' => env('MPESA_ENV') === 'production' ? 'https://api.safaricom.co.ke' : 'https://sandbox.safaricom.co.ke',
'consumer_key' => env('MPESA_CONSUMER_KEY'),
'consumer_secret' => env('MPESA_CONSUMER_SECRET'),
'shortcode' => env('MPESA_B2C_SHORTCODE'),
'initiator_name' => env('MPESA_INITIATOR_NAME'),
'initiator_password' => env('MPESA_INITIATOR_PASSWORD'),
'cert_path' => env('MPESA_CERT_PATH'), // Safaricom's sandbox or production certificate
'command' => env('MPESA_B2C_COMMAND', 'BusinessPayment'),
'callback_secret' => env('MPESA_B2C_CALLBACK_SECRET'), // 32+ random characters, part of the result URLs
'callback_base' => env('MPESA_B2C_CALLBACK_BASE'), // https://yourdomain.com
],
];
The payouts table
<?php
// database/migrations/xxxx_xx_xx_create_b2c_payouts_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('b2c_payouts', function (Blueprint $table) {
$table->id();
$table->uuid('originator_id')->unique(); // our OriginatorConversationID, fixed for life
$table->unsignedInteger('amount'); // whole shillings
$table->string('phone', 12); // 2547XXXXXXXX or 2541XXXXXXXX
$table->string('remarks', 100);
$table->string('status', 10)->default('new'); // new | sending | accepted | unknown | paid | failed | review
$table->string('conversation_id')->nullable(); // Safaricom's ConversationID from the acknowledgement
$table->string('receipt')->nullable();
$table->string('result_code')->nullable();
$table->string('reason')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('b2c_payouts');
}
};
<?php
// app/Models/B2cPayout.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class B2cPayout extends Model
{
/** May already be on its way: never sent again. */
public const OPEN = ['sending', 'accepted', 'unknown'];
protected $fillable = ['originator_id', 'amount', 'phone', 'remarks', 'status', 'conversation_id', 'receipt', 'result_code', 'reason'];
protected $casts = ['amount' => 'integer'];
}
Run php artisan migrate.
The B2C service
Two details trip people up:
- SecurityCredential is your initiator password encrypted with the public key in Safaricom’s certificate, using RSA with PKCS#1 v1.5 padding, then base64-encoded. A credential made with the sandbox certificate fails in production, and the reverse.
- ResponseCode
0is not a payment. It means Safaricom accepted the request. The result arrives later at your ResultURL.
<?php
// app/Services/MpesaException.php
namespace App\Services;
use RuntimeException;
class MpesaException extends RuntimeException
{
/** ambiguous: the payout may have gone through. Never resend it. */
public function __construct(string $message, public readonly int $status = 0, public readonly bool $ambiguous = false, public readonly ?string $errorCode = null)
{
parent::__construct($message);
}
}
<?php
// app/Services/MpesaB2C.php
namespace App\Services;
use App\Models\B2cPayout;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class MpesaB2C
{
private function cfg(string $key): string
{
return (string) config("services.mpesa_b2c.{$key}");
}
/**
* SecurityCredential = base64(RSA PKCS#1 v1.5 encrypt(initiator password, Safaricom's certificate)).
* Use the sandbox certificate in the sandbox and the production certificate in production.
*/
public static function securityCredential(string $initiatorPassword, string $certificate): string
{
$key = openssl_pkey_get_public($certificate);
if ($key === false || ! openssl_public_encrypt($initiatorPassword, $encrypted, $key, OPENSSL_PKCS1_PADDING)) {
throw new \RuntimeException('Could not encrypt the initiator password: check MPESA_CERT_PATH');
}
return base64_encode($encrypted);
}
public function token(): string
{
return Cache::remember('mpesa_b2c_token', 3000, function () {
try {
$res = Http::timeout(30)->withBasicAuth($this->cfg('consumer_key'), $this->cfg('consumer_secret'))
->get($this->cfg('base_url').'/oauth/v1/generate', ['grant_type' => 'client_credentials']);
} catch (ConnectionException $e) {
throw new MpesaException("Daraja unreachable: {$e->getMessage()}", ambiguous: true);
}
if (! $res->successful() || ! $res->json('access_token')) {
throw new MpesaException("Daraja token failed: {$res->status()}", $res->status());
}
return (string) $res->json('access_token');
});
}
/** Sends one B2C payment request. Returns Safaricom's acknowledgement, NOT the result. */
public function pay(B2cPayout $payout): array
{
$base = rtrim($this->cfg('callback_base'), '/').'/webhooks/b2c/'.$this->cfg('callback_secret');
try {
$res = Http::timeout(30)->withToken($this->token())->post($this->cfg('base_url').'/mpesa/b2c/v3/paymentrequest', [
'OriginatorConversationID' => $payout->originator_id, // our id, saved before this call
'InitiatorName' => $this->cfg('initiator_name'),
'SecurityCredential' => self::securityCredential($this->cfg('initiator_password'), (string) file_get_contents($this->cfg('cert_path'))),
'CommandID' => $this->cfg('command'),
'Amount' => $payout->amount, // whole shillings
'PartyA' => $this->cfg('shortcode'),
'PartyB' => $payout->phone,
'Remarks' => mb_substr($payout->remarks, 0, 100),
'QueueTimeOutURL' => "{$base}/timeout/{$payout->originator_id}",
'ResultURL' => "{$base}/result",
'Occasion' => '',
]);
} catch (ConnectionException $e) {
throw new MpesaException("Daraja unreachable: {$e->getMessage()}", ambiguous: true);
}
$body = $res->json();
if ($res->successful() && (string) ($body['ResponseCode'] ?? '') === '0') {
return $body; // ['ConversationID' => ..., 'OriginatorConversationID' => ..., ...]
}
$code = is_array($body) ? (string) (($body['errorCode'] ?? '') ?: ($body['ResponseCode'] ?? '')) : '';
throw new MpesaException("B2C request failed: {$res->status()} {$code}", $res->status(), $res->serverError() || ! is_array($body), $code ?: null);
}
}
A network error, a timeout or a 5xx means the request may have reached M-Pesa, so the exception is ambiguous. A 4xx with an errorCode is a clear refusal: nothing was sent.
Never pay twice
<?php
// app/Services/B2cPayouts.php
namespace App\Services;
use App\Models\B2cPayout;
use Illuminate\Support\Str;
class B2cPayouts
{
public function __construct(private readonly MpesaB2C $mpesa)
{
}
/** Create the payout record. Its OriginatorConversationID is fixed for life. Nothing is sent yet. */
public function create(int $amount, string $phone, string $remarks): B2cPayout
{
if ($amount <= 0) {
throw new \InvalidArgumentException('B2C amounts are whole shillings, more than zero');
}
if (! preg_match('/^254[17]\d{8}$/', $phone)) {
throw new \InvalidArgumentException('Phone must look like 2547XXXXXXXX or 2541XXXXXXXX');
}
return B2cPayout::create(['originator_id' => (string) Str::uuid(), 'amount' => $amount, 'phone' => $phone, 'remarks' => $remarks, 'status' => 'new']);
}
/** Sends a payout once. Only "new" is ever sent, and only one process can claim it. */
public function send(B2cPayout $payout): B2cPayout
{
$claimed = B2cPayout::whereKey($payout->id)->where('status', 'new')->update(['status' => 'sending', 'updated_at' => now()]) === 1;
if (! $claimed) {
return $payout->fresh(); // sent, or maybe sent: never again
}
try {
$ack = $this->mpesa->pay($payout);
// Only from "sending": a result may already have arrived before the acknowledgement.
B2cPayout::whereKey($payout->id)->where('status', 'sending')->update(['status' => 'accepted', 'conversation_id' => $ack['ConversationID'] ?? null]);
B2cPayout::whereKey($payout->id)->whereNull('conversation_id')->update(['conversation_id' => $ack['ConversationID'] ?? null]);
} catch (MpesaException $e) {
B2cPayout::whereKey($payout->id)->where('status', 'sending')->update($e->ambiguous
? ['status' => 'unknown', 'reason' => 'NO_ACKNOWLEDGEMENT'] // may have gone through: wait for the result
: ['status' => 'failed', 'reason' => $e->errorCode ?? "HTTP_{$e->status}"]);
}
return $payout->fresh();
}
/** Handles the POST to your ResultURL. Returns the payout, or null when the result is ignored. */
public function handleResult(array $body): ?B2cPayout
{
$result = $body['Result'] ?? null;
$id = is_array($result) ? (string) ($result['OriginatorConversationID'] ?? '') : '';
$payout = Str::isUuid($id) ? B2cPayout::where('originator_id', $id)->first() : null;
if (! $payout) {
return null; // not ours
}
if ($payout->conversation_id && ($result['ConversationID'] ?? null) !== $payout->conversation_id) {
return null; // doesn't match what Safaricom gave us
}
$params = [];
$list = $result['ResultParameters']['ResultParameter'] ?? [];
foreach (array_is_list((array) $list) ? (array) $list : [$list] as $p) {
if (is_array($p) && isset($p['Key'])) {
$params[$p['Key']] = $p['Value'] ?? null;
}
}
$code = (string) ($result['ResultCode'] ?? '');
$changes = $code !== '0'
? ['status' => 'failed', 'result_code' => $code, 'reason' => mb_substr((string) ($result['ResultDesc'] ?? ''), 0, 255)]
: (is_numeric($params['TransactionAmount'] ?? null) && (int) round((float) $params['TransactionAmount']) === $payout->amount
? ['status' => 'paid', 'result_code' => '0', 'receipt' => $params['TransactionReceipt'] ?? $result['TransactionID'] ?? null, 'reason' => null]
: ['status' => 'review', 'result_code' => '0', 'receipt' => $params['TransactionReceipt'] ?? null, 'reason' => 'AMOUNT_MISMATCH '.($params['TransactionAmount'] ?? '?')]);
$changes['conversation_id'] = $payout->conversation_id ?: ($result['ConversationID'] ?? null);
// Only an open payout changes: a repeated result never undoes a final one.
B2cPayout::whereKey($payout->id)->whereIn('status', B2cPayout::OPEN)->update($changes);
return $payout->fresh();
}
/** Handles the POST to your QueueTimeOutURL: the outcome is unknown, NOT failed. */
public function handleTimeout(string $id): ?B2cPayout
{
$payout = Str::isUuid($id) ? B2cPayout::where('originator_id', $id)->first() : null;
$payout?->newQuery()->whereKey($payout->id)->whereIn('status', ['sending', 'accepted'])->update(['status' => 'unknown', 'reason' => 'QUEUE_TIMEOUT']);
return $payout?->fresh();
}
}
The rules:
- Create the ID once, with the payout, and save it before sending. It is the
OriginatorConversationID, so a result can always be matched to its payout, even if your server crashed mid-request. - Claim before sending. The conditional update (
newtosending) means only one request or worker ever sends a payout. - Never resend automatically. An
unknownpayout waits for its result, or for a person to confirm it. - Only matching results count. The
ConversationIDmust be the one Safaricom acknowledged, and the amount must match; a different amount goes toreview. - Final means final. Every update is limited to open payouts, so a repeated result or a late timeout never undoes
paid,failedorreview.
The result routes
<?php
// app/Http/Controllers/B2cController.php
namespace App\Http\Controllers;
use App\Services\B2cPayouts;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class B2cController extends Controller
{
public function __construct(private readonly B2cPayouts $payouts)
{
}
/** Only Safaricom knows the full URL, because only Safaricom is sent it. */
private static function secretOk(string $given): bool
{
$secret = (string) config('services.mpesa_b2c.callback_secret');
return strlen($secret) >= 32 && hash_equals($secret, $given);
}
public function result(Request $request, string $secret): JsonResponse
{
abort_unless(self::secretOk($secret), 404);
$body = json_decode($request->getContent(), true);
try {
$this->payouts->handleResult(is_array($body) ? $body : []);
} catch (\Throwable $e) {
Log::error('B2C result not saved: '.$request->getContent(), ['exception' => $e]); // the full result holds the receipt
return response()->json(['ResultCode' => 1, 'ResultDesc' => 'Not saved'], 500);
}
return response()->json(['ResultCode' => 0, 'ResultDesc' => 'Success']);
}
public function timeout(string $secret, string $id): JsonResponse
{
abort_unless(self::secretOk($secret), 404);
$this->payouts->handleTimeout($id);
return response()->json(['ResultCode' => 0, 'ResultDesc' => 'Success']);
}
}
<?php
// routes/web.php (add to the end of the file; it already imports Route)
use App\Http\Controllers\B2cController;
// CSRF is switched off for webhooks/b2c/* in bootstrap/app.php.
Route::post('/webhooks/b2c/{secret}/result', [B2cController::class, 'result']);
Route::post('/webhooks/b2c/{secret}/timeout/{id}', [B2cController::class, 'timeout']);
Safaricom can’t send a CSRF token, so exclude the routes in bootstrap/app.php:
<?php
// bootstrap/app.php, inside ->withMiddleware(...)
$middleware->validateCsrfTokens(except: ['webhooks/b2c/*']);
The secret in the URL stops strangers posting fake results: requests without it get a 404. The handler saves the result before answering, and if saving fails it logs the full result, which holds the receipt, and answers 500.
To pay someone, from your own authenticated admin code:
<?php
use App\Services\B2cPayouts;
$payouts = app(B2cPayouts::class);
$payouts->send($payouts->create(1500, '254712345678', 'Salary for October'));
Test it without calling Safaricom
This test fakes Daraja with Http::fake() and makes a throwaway certificate with PHP’s OpenSSL functions, so it can check the SecurityCredential decrypts back to the password. It also checks that a payout is sent only once, that the acknowledgement isn’t a payment, that a wrong secret, a forged ConversationID or a wrong amount changes nothing, that a timeout leads to unknown and never a second request, and that a later result still settles it.
<?php
// tests/Feature/B2cPayoutsTest.php
namespace Tests\Feature;
use App\Models\B2cPayout;
use App\Services\B2cPayouts;
use App\Services\MpesaB2C;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class B2cPayoutsTest extends TestCase
{
use RefreshDatabase;
private const SECRET = 'b2c-callback-secret-0123456789abcdef';
private string $privateKey = '';
private int $requests = 0; // B2C payment requests that reached the fake Daraja
/** @var array|int|null the next acknowledgement: an array, an HTTP status, or null for a timeout */
private array|int|null $ack = ['ConversationID' => 'AG_20261104_0001', 'OriginatorConversationID' => 'x', 'ResponseCode' => '0'];
protected function setUp(): void
{
parent::setUp();
// A throwaway certificate, so the SecurityCredential can be checked without Safaricom's.
$key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
$cert = openssl_csr_sign(openssl_csr_new(['commonName' => 'b2c-test'], $key), null, $key, 1);
openssl_x509_export($cert, $pem);
openssl_pkey_export($key, $this->privateKey);
$path = tempnam(sys_get_temp_dir(), 'b2c').'.cer';
file_put_contents($path, $pem);
config(['services.mpesa_b2c' => [
'base_url' => 'https://sandbox.safaricom.co.ke', 'consumer_key' => 'ck', 'consumer_secret' => 'cs',
'shortcode' => '600999', 'initiator_name' => 'testapi', 'initiator_password' => 'Safaricom999!*!', 'cert_path' => $path,
'command' => 'BusinessPayment', 'callback_secret' => self::SECRET, 'callback_base' => 'https://shop.example.com',
]]);
Http::preventStrayRequests();
Http::fake([
'*/oauth/v1/generate*' => Http::response(['access_token' => 'daraja-token', 'expires_in' => '3599']),
'*/mpesa/b2c/v3/paymentrequest' => function () {
$this->requests++;
if ($this->ack === null) {
throw new \Illuminate\Http\Client\ConnectionException('timed out');
}
if (is_int($this->ack)) {
return Http::response($this->ack < 500 ? ['errorCode' => '400.002.02'] : null, $this->ack);
}
return Http::response($this->ack);
},
]);
}
private function payout(int $amount = 1500): B2cPayout
{
return app(B2cPayouts::class)->create($amount, '254712345678', 'Salary');
}
private function b2cResult(B2cPayout $p, int $code = 0, mixed $amount = 1500, string $conv = 'AG_20261104_0001'): array
{
return ['Result' => [
'ResultType' => 0, 'ResultCode' => $code, 'ResultDesc' => $code === 0 ? 'The service request is processed successfully.' : 'The initiator information is invalid.',
'OriginatorConversationID' => $p->originator_id, 'ConversationID' => $conv, 'TransactionID' => 'NLJ41HAY6Q',
'ResultParameters' => ['ResultParameter' => [['Key' => 'TransactionAmount', 'Value' => $amount], ['Key' => 'TransactionReceipt', 'Value' => 'NLJ41HAY6Q']]],
]];
}
private function postResult(array $body, string $secret = self::SECRET)
{
return $this->postJson("/webhooks/b2c/{$secret}/result", $body);
}
public function test_security_credential_decrypts_with_the_private_key(): void
{
$cred = MpesaB2C::securityCredential('Safaricom999!*!', file_get_contents(config('services.mpesa_b2c.cert_path')));
openssl_private_decrypt(base64_decode($cred), $plain, $this->privateKey, OPENSSL_PKCS1_PADDING);
$this->assertSame('Safaricom999!*!', $plain);
}
public function test_sends_once_and_the_acknowledgement_is_not_a_payment(): void
{
$p = $this->payout();
$sent = app(B2cPayouts::class)->send($p);
$this->assertSame(['accepted', 'AG_20261104_0001'], [$sent->status, $sent->conversation_id]);
app(B2cPayouts::class)->send($p); // a double click with the old copy
$this->assertSame(1, $this->requests);
$body = Http::recorded(fn (Request $r) => str_contains($r->url(), 'paymentrequest'))->first()[0]->data();
$this->assertSame([$p->originator_id, 'testapi', 'BusinessPayment', 1500, '600999', '254712345678'],
[$body['OriginatorConversationID'], $body['InitiatorName'], $body['CommandID'], $body['Amount'], $body['PartyA'], $body['PartyB']]);
$this->assertSame('https://shop.example.com/webhooks/b2c/'.self::SECRET.'/result', $body['ResultURL']);
$this->assertSame('https://shop.example.com/webhooks/b2c/'.self::SECRET."/timeout/{$p->originator_id}", $body['QueueTimeOutURL']);
}
public function test_results_settle_and_forged_ones_change_nothing(): void
{
$p = app(B2cPayouts::class)->send($this->payout());
$this->postResult($this->b2cResult($p), 'wrong-secret')->assertNotFound();
$this->postResult($this->b2cResult($p, conv: 'AG_SOMEONE_ELSE'))->assertOk();
$this->assertSame('accepted', $p->fresh()->status);
$this->postResult($this->b2cResult($p))->assertOk()->assertJson(['ResultCode' => 0]);
$this->assertSame(['paid', 'NLJ41HAY6Q'], [$p->fresh()->status, $p->fresh()->receipt]);
$q = app(B2cPayouts::class)->send($this->payout());
$this->postResult($this->b2cResult($q, 2001));
$this->postResult($this->b2cResult($q)); // a later success can't undo a final failure
$this->assertSame(['failed', '2001'], [$q->fresh()->status, $q->fresh()->result_code]);
$r = app(B2cPayouts::class)->send($this->payout());
$this->postResult($this->b2cResult($r, amount: 150));
$this->assertSame('review', $r->fresh()->status);
}
public function test_no_acknowledgement_or_queue_timeout_is_unknown_and_never_resent(): void
{
$this->ack = null; // the request times out
$p = app(B2cPayouts::class)->send($this->payout());
$this->assertSame(['unknown', 'NO_ACKNOWLEDGEMENT'], [$p->status, $p->reason]);
app(B2cPayouts::class)->send($p);
$this->assertSame(1, $this->requests);
$this->postResult($this->b2cResult($p)); // the result can still arrive later
$this->assertSame('paid', $p->fresh()->status);
$this->ack = ['ConversationID' => 'AG_2', 'ResponseCode' => '0'];
$q = app(B2cPayouts::class)->send($this->payout());
$this->postJson('/webhooks/b2c/'.self::SECRET."/timeout/{$q->originator_id}")->assertOk();
$this->assertSame(['unknown', 'QUEUE_TIMEOUT'], [$q->fresh()->status, $q->fresh()->reason]);
}
public function test_a_server_error_is_unknown_and_a_clear_refusal_fails(): void
{
$this->ack = 503;
$this->assertSame('unknown', app(B2cPayouts::class)->send($this->payout())->status);
$this->ack = 400;
$p = app(B2cPayouts::class)->send($this->payout());
$this->assertSame(['failed', '400.002.02'], [$p->status, $p->reason]);
}
public function test_bad_input_is_refused(): void
{
$this->expectException(\InvalidArgumentException::class);
app(B2cPayouts::class)->create(1500, '0712345678', 'Salary');
}
}
php artisan test --filter B2cPayoutsTest
Settle unknown payouts
A payout ends up unknown when you got no acknowledgement or Safaricom called your QueueTimeOutURL. Most still get a result later, and handleResult() settles them. For the rest, check your M-Pesa organisation portal statement, or use the Transaction Status API (described in the Node.js guide). Only when you are sure the money did not move should you mark the payout failed and create a new one.
Common errors and fixes
These codes are reported by integration guides rather than Safaricom’s public pages, so treat the descriptions as likely causes.
| Code | Reported meaning | Fix |
|---|---|---|
2001 |
Initiator information is invalid | Check the initiator name and password, and that the credential was encrypted with the certificate for this environment |
17 |
PartyA is not permitted to make this transaction | Confirm B2C is enabled for your shortcode and the CommandID you use |
18 |
Invalid receiver | Check PartyB is a valid Kenyan M-Pesa number in 2547… or 2541… format |
2033 |
Duplicate OriginatorConversationID | Expected if you sent the same ID twice. The first request stands; never resend with a new ID |
HTTP 419 on the result |
CSRF protection on the routes | Exclude webhooks/b2c/* in bootstrap/app.php |
Could not encrypt the initiator password |
MPESA_CERT_PATH is wrong or unreadable |
Use the absolute path to the .cer file |
Going to production
- Apply for go-live with your B2C shortcode. Safaricom sets up the production initiator during go-live. Use the production certificate and set
MPESA_ENV=production. - Use a new random
MPESA_B2C_CALLBACK_SECRETin production, and serve the routes over HTTPS. - Protect the code that creates payouts. Anyone who can call it can send your money. Require an authenticated admin, set limits per payout and per day, and log who created each one.
- Alert on payouts that stay
acceptedorunknownfor more than an hour, and settle them from your statement. - Reconcile daily against your M-Pesa statement.
Comparing payout APIs? See mobile money payouts. Want this done for you, with MoMo, Flutterwave and Paystack payouts too? The Laravel starter kit includes them.
Using Django? See M-Pesa B2C in Django.
Using the Next.js App Router? See M-Pesa B2C payouts in Next.js: the same design in route handlers.
Frequently asked questions
How do I generate the M-Pesa SecurityCredential in PHP?
Encrypt the initiator password with the public key in Safaricom's certificate using openssl_public_encrypt with OPENSSL_PKCS1_PADDING, then base64_encode the result. Use the sandbox certificate in the sandbox and the production certificate in production.
Is ResponseCode 0 from the B2C request a completed payment?
No. ResponseCode 0 only means Safaricom accepted the request. The payment is complete only when your ResultURL receives a Result with ResultCode 0 and a TransactionReceipt.
Why does Laravel answer 419 to the M-Pesa B2C result?
CSRF protection. Safaricom can't send a CSRF token, so exclude the result routes in bootstrap/app.php, as this guide does with webhooks/b2c/*. A long random secret in the URL protects them instead.
My B2C request timed out. Should I send it again?
No. A timeout or a call to your QueueTimeOutURL does not mean the money stayed put. Mark the payout unknown and confirm it with the Transaction Status API or your M-Pesa statement before deciding anything.