siberfx / mpesa-payment
Modern M-Pesa payment gateway for PHP 8.4+ — Safaricom Daraja (Kenya) and Vodacom M-Pesa OpenAPI (Tanzania, DRC, Lesotho). Works standalone or with Laravel 12/13.
Requires
- php: ~8.4.0 || ~8.5.0
- ext-json: *
- ext-openssl: *
- guzzlehttp/guzzle: ^7.9
- psr/log: ^3.0
- psr/simple-cache: ^3.0
Requires (Dev)
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^11.5 || ^12.0
Suggests
- laravel/framework: Required for the Laravel integration (^12.0 || ^13.0).
Provides
None
Conflicts
None
Replaces
None
README
A modern, fully typed M-Pesa integration for PHP 8.4 / 8.5, usable standalone or with Laravel 12 / 13.
| Market | API | Driver |
|---|---|---|
| 🇰🇪 Kenya (Safaricom) | Daraja | daraja |
| 🇹🇿 Tanzania (Vodacom) | M-Pesa OpenAPI | openapi |
| 🇨🇩 DR Congo (Vodacom) | M-Pesa OpenAPI | openapi |
| 🇱🇸 Lesotho (Vodacom) | M-Pesa OpenAPI | openapi |
Contents
- Features
- Installation
- Standalone usage
- Laravel usage
- API reference (Daraja)
- API reference (Vodacom OpenAPI)
- Complete examples
- Pitfalls & best practices
- Error handling
- Testing
Features
Daraja (Kenya)
- M-Pesa Express (STK push) and STK status query — Pay Bill and Buy Goods
- C2B URL registration (v2) and sandbox simulation
- B2C payouts (v3) — business, salary and promotion payments
- B2B payments (pay bill, buy goods, transfers) and B2B Express Checkout (USSD push)
- Account balance, transaction status and reversals
- Dynamic QR code generation
- KRA tax remittance
- M-Pesa Ratiba standing orders (recurring payments)
Vodacom OpenAPI (Tanzania, DRC, Lesotho)
- C2B single stage, B2C, B2B, reversal and transaction status
- Automatic session-key creation, RSA encryption and renewal
Engineering
- Multiple named accounts across markets in one app
- Access tokens / session keys cached (any PSR-16 cache; Laravel cache in Laravel)
- Safe retries: money-moving requests are only retried if the connection was never established, so a payment is never sent twice
- Automatic token refresh on expiry
- Security credentials generated from the Safaricom certificate on the fly
- Phone number normalisation (
0712…,+254 712…,712…→254712…) - Typed callback objects (
StkCallback,C2BTransaction,ResultCallback) including balance parsing - Typed exceptions carrying Daraja error codes and request IDs
- PSR-3 logging with credentials redacted
- Laravel: auto-discovered provider and facade, callback routes that dispatch events, C2B validation hook, callback token + IP allow-list,
mpesa:register-urlscommand
Requirements
- PHP 8.4 or 8.5 with
ext-opensslandext-json - Laravel 12 or 13 (optional)
Installation
composer require siberfx/mpesa-payment
Standalone usage
use Siberfx\MpesaPayment\Mpesa; $mpesa = Mpesa::make([ 'environment' => 'sandbox', // or 'production' 'consumer_key' => 'your-consumer-key', 'consumer_secret' => 'your-consumer-secret', 'shortcode' => '174379', 'passkey' => 'your-passkey', 'callbacks' => [ 'stk' => 'https://example.com/payments/stk', ], ]); $response = $mpesa->stk()->push( amount: 100, phone: '0712 345 678', reference: 'INV-1001', description: 'Order #1001', ); if ($response->accepted()) { $checkoutId = $response->checkoutRequestId(); // store it, the result arrives on your callback }
Multiple accounts and markets
$mpesa = Mpesa::make([ 'default' => 'kenya', 'accounts' => [ 'kenya' => [ 'driver' => 'daraja', 'environment' => 'production', 'consumer_key' => '...', 'consumer_secret' => '...', 'shortcode' => '600000', 'passkey' => '...', 'initiator_name' => 'apiuser', 'initiator_password' => '...', 'certificate_path' => '/path/to/ProductionCertificate.cer', 'callbacks' => [ 'stk' => 'https://example.com/payments/stk', 'result' => 'https://example.com/payments/result', 'timeout' => 'https://example.com/payments/timeout', ], ], 'tanzania' => [ 'driver' => 'openapi', 'environment' => 'sandbox', 'market' => 'vodacomTZN', // vodacomTZN | vodacomDRC | vodacomLES 'api_key' => '...', 'public_key' => '...', // as issued by the OpenAPI portal 'service_provider_code' => '000000', ], ], 'http' => ['timeout' => 30, 'retries' => 2], ], cache: $psr16Cache, logger: $psr3Logger); $mpesa->stk()->push(...); // default account $mpesa->daraja('kenya')->b2c()->pay(...); // explicit Daraja account $mpesa->openApi('tanzania')->c2b(...); // Vodacom market
Pass any PSR-16 cache (Redis, APCu, filesystem…) so access tokens survive between requests. Without one, an in-memory cache is used for the lifetime of the object.
Laravel usage
The service provider and Mpesa facade are auto-discovered. Publish the config:
php artisan vendor:publish --tag=mpesa-config
Minimal .env for Kenya:
MPESA_ENVIRONMENT=sandbox MPESA_CONSUMER_KEY= MPESA_CONSUMER_SECRET= MPESA_SHORTCODE=174379 MPESA_PASSKEY= MPESA_INITIATOR_NAME= MPESA_INITIATOR_PASSWORD= MPESA_CERTIFICATE_PATH= MPESA_CALLBACK_TOKEN=a-long-random-string
use Siberfx\MpesaPayment\Laravel\Facades\Mpesa; Mpesa::stk()->push(amount: 250, phone: $user->phone, reference: 'ORDER-42'); Mpesa::account('tanzania')->c2b(5000, '0754123456', 'ORDER-42');
Or inject Siberfx\MpesaPayment\Mpesa anywhere the container resolves dependencies.
Callback routes and events
The package registers these POST routes (prefix configurable via MPESA_ROUTES_PREFIX, default payments/callbacks):
| Route | Event |
|---|---|
/payments/callbacks/stk |
StkCallbackReceived |
/payments/callbacks/c2b/validation |
C2BValidationReceived |
/payments/callbacks/c2b/confirmation |
C2BConfirmationReceived |
/payments/callbacks/result/{type?} |
ResultReceived |
/payments/callbacks/timeout/{type?} |
TimeoutReceived |
/payments/callbacks/b2b-checkout |
B2BCheckoutCallbackReceived |
/payments/callbacks/ratiba |
StandingOrderCallbackReceived |
Any callback URL you leave empty in the config is filled in automatically from these routes, using MPESA_CALLBACK_BASE_URL (or APP_URL), with ?account=<name>&token=<MPESA_CALLBACK_TOKEN> appended. Events live in Siberfx\MpesaPayment\Laravel\Events:
use Siberfx\MpesaPayment\Laravel\Events\StkCallbackReceived; Event::listen(function (StkCallbackReceived $event) { $callback = $event->callback; $order = Order::where('checkout_request_id', $callback->checkoutRequestId)->firstOrFail(); $callback->successful() ? $order->markPaid($callback->receiptNumber(), $callback->amount()) : $order->markFailed($callback->resultDescription); });
Note: Safaricom rejects C2B URLs containing words such as
mpesa,safaricom,exe,cmd,sqlorquery, which is why the default prefix ispayments/callbacks.
Validating C2B payments
use Siberfx\MpesaPayment\Daraja\Callbacks\C2BRejection; use Siberfx\MpesaPayment\Daraja\Callbacks\C2BTransaction; use Siberfx\MpesaPayment\Laravel\C2BValidation; // AppServiceProvider::boot() C2BValidation::using(function (C2BTransaction $payment, ?string $account) { return Invoice::where('number', $payment->billReference)->exists() ?: C2BRejection::InvalidAccountNumber; });
Then register your URLs with Safaricom:
php artisan mpesa:register-urls --account=kenya
Securing callbacks
MPESA_CALLBACK_TOKEN— callback requests without the matchingtokenquery parameter get a403.MPESA_VERIFY_CALLBACK_IP=true— only addresses inmpesa.security.allowed_ips(Safaricom's published callback IPs by default) are accepted. Behind a proxy or load balancer, configure Laravel's trusted proxies so the real client IP is seen.
API reference (Daraja)
All methods return an ApiResponse (accepted(), failed(), description(), get(), toArray(), array access, plus helpers such as checkoutRequestId() and conversationId()).
STK push (M-Pesa Express)
use Siberfx\MpesaPayment\Enums\TransactionType; $mpesa->stk()->push(100, '0712345678', 'INV-1'); // Pay Bill $mpesa->stk()->push(100, '0712345678', 'INV-1', type: TransactionType::BuyGoods); // Buy Goods (till_number) $mpesa->stk()->query($checkoutRequestId);
C2B
$mpesa->c2b()->registerUrls(); // uses configured confirmation/validation URLs $mpesa->c2b()->simulate(100, '254708374149', 'ACC-1'); // sandbox only
B2C
use Siberfx\MpesaPayment\Enums\CommandId; $mpesa->b2c()->pay( amount: 1500, phone: '0712345678', remarks: 'March salary', command: CommandId::SalaryPayment, originatorConversationId: $payout->uuid, // optional, recommended for traceability );
B2B and B2B Express Checkout
$mpesa->b2b()->pay(5000, receiver: '600000', accountReference: 'INV-7', command: CommandId::BusinessPayBill); $mpesa->b2b()->pay(5000, receiver: '123456', accountReference: 'INV-7', command: CommandId::BusinessBuyGoods); // Ask the merchant owning till 000001 to pay you via a USSD prompt $mpesa->b2b()->expressCheckout(primaryShortcode: '000001', amount: 5000, paymentReference: 'INV-7', partnerName: 'Acme Ltd');
Balance, status, reversal
$mpesa->balance()->balance(); $mpesa->balance()->transactionStatus(transactionId: 'OEI2AK4Q16'); $mpesa->balance()->transactionStatus(originatorConversationId: $uuid); $mpesa->balance()->reverse('OEI2AK4Q16', amount: 100);
The final results arrive on your result URL; parse them with ResultCallback::fromArray($payload). For balances, $result->balances() returns structured rows.
Dynamic QR
use Siberfx\MpesaPayment\Daraja\Services\DynamicQr; use Siberfx\MpesaPayment\Enums\QrTransactionCode; $response = $mpesa->qr()->generate(250, merchantName: 'My Shop', reference: 'INV-9', code: QrTransactionCode::BuyGoods); echo '<img src="'.DynamicQr::dataUri($response).'">';
KRA tax remittance
$mpesa->tax()->remit(amount: 12000, paymentRegistrationNumber: 'PRN1234XN');
M-Pesa Ratiba (standing orders)
use Siberfx\MpesaPayment\Enums\StandingOrderFrequency; $mpesa->ratiba()->create( name: 'Gym membership', amount: 2500, phone: '0712345678', startDate: new DateTimeImmutable('2026-11-01'), endDate: new DateTimeImmutable('2027-10-31'), frequency: StandingOrderFrequency::Monthly, accountReference: 'MEMBER-88', );
API reference (Vodacom OpenAPI)
These APIs are synchronous — the response already contains the final result (output_ResponseCode INS-0 on success).
$tz = $mpesa->openApi('tanzania'); $tz->c2b(10000, '0754123456', reference: 'ORDER-1', description: 'Shoes'); $tz->b2c(5000, '0754123456', reference: 'PAYOUT-1'); $tz->b2b(5000, receiverPartyCode: '000001', reference: 'INV-1'); $tz->reverse($transactionId, 10000); $tz->status($transactionId);
Known markets (vodacomTZN, vodacomDRC, vodacomLES) get their country and currency automatically. For other markets on the same platform, set country, currency and country_code explicitly. For DRC, set currency to CDF if you settle in Congolese francs.
Callback parsing without Laravel
use Siberfx\MpesaPayment\Daraja\Callbacks\{StkCallback, C2BTransaction, ResultCallback}; $payload = json_decode(file_get_contents('php://input'), true); $callback = StkCallback::fromArray($payload); $callback->successful(); $callback->receiptNumber(); $callback->amount(); $callback->phoneNumber(); $callback->transactionDate(); // C2B validation endpoint echo json_encode(C2BTransaction::accept());
Complete examples
1. Laravel checkout with STK push (end to end)
Migration — keep the identifiers M-Pesa gives you, you will need them to match callbacks:
Schema::create('payments', function (Blueprint $table) { $table->id(); $table->foreignId('order_id')->constrained(); $table->string('phone'); $table->unsignedInteger('amount'); $table->string('status')->default('pending'); // pending | paid | failed $table->string('checkout_request_id')->nullable()->unique(); $table->string('receipt_number')->nullable()->unique(); $table->string('failure_reason')->nullable(); $table->timestamps(); });
Controller — start the payment:
use Siberfx\MpesaPayment\Exceptions\MpesaException; use Siberfx\MpesaPayment\Exceptions\ValidationException; use Siberfx\MpesaPayment\Laravel\Facades\Mpesa; use Siberfx\MpesaPayment\Support\Phone; final class CheckoutController { public function pay(Request $request, Order $order) { $data = $request->validate(['phone' => ['required', 'string']]); if (! Phone::isValid($data['phone'])) { return back()->withErrors(['phone' => 'Enter a valid Safaricom number.']); } $payment = $order->payments()->create([ 'phone' => Phone::normalize($data['phone']), 'amount' => (int) ceil($order->total), // Daraja only accepts whole numbers ]); try { $response = Mpesa::stk()->push( amount: $payment->amount, phone: $payment->phone, reference: 'ORD'.$order->id, // max 12 characters description: 'Order '.$order->id, ); } catch (ValidationException $e) { $payment->update(['status' => 'failed', 'failure_reason' => $e->getMessage()]); return back()->withErrors(['phone' => $e->getMessage()]); } catch (MpesaException $e) { report($e); $payment->update(['status' => 'failed', 'failure_reason' => 'Gateway unavailable']); return back()->withErrors(['phone' => 'M-Pesa is unavailable, please try again.']); } $payment->update(['checkout_request_id' => $response->checkoutRequestId()]); // The customer now sees a PIN prompt. Nothing is paid yet. CheckStkPayment::dispatch($payment)->delay(now()->addSeconds(90)); return redirect()->route('orders.waiting', $order); } }
Listener — the callback is the source of truth:
use Illuminate\Contracts\Queue\ShouldQueue; use Siberfx\MpesaPayment\Laravel\Events\StkCallbackReceived; final class RecordStkPayment implements ShouldQueue { public function handle(StkCallbackReceived $event): void { $callback = $event->callback; DB::transaction(function () use ($callback) { $payment = Payment::where('checkout_request_id', $callback->checkoutRequestId) ->lockForUpdate() ->first(); // Unknown or already processed (callbacks can be delivered more than once). if ($payment === null || $payment->status !== 'pending') { return; } if (! $callback->successful()) { $payment->update(['status' => 'failed', 'failure_reason' => $callback->resultDescription]); return; } // Never trust that the amount matches: verify it. if ((int) $callback->amount() !== $payment->amount) { $payment->update(['status' => 'failed', 'failure_reason' => 'Amount mismatch']); report(new RuntimeException("STK amount mismatch for payment {$payment->id}")); return; } $payment->update(['status' => 'paid', 'receipt_number' => $callback->receiptNumber()]); $payment->order->markAsPaid(); }); } }
Fallback job — covers callbacks that never arrive:
use Siberfx\MpesaPayment\Exceptions\RequestException; use Siberfx\MpesaPayment\Laravel\Facades\Mpesa; final class CheckStkPayment implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 5; public function __construct(public Payment $payment) {} public function handle(): void { if ($this->payment->fresh()->status !== 'pending') { return; // the callback already settled it } try { $result = Mpesa::stk()->query($this->payment->checkout_request_id); } catch (RequestException $e) { // "The transaction is being processed" — ask again later. $this->release(60); return; } if ((string) $result->get('ResultCode') === '0') { // Paid, but the query does not return the receipt number: // mark it paid and reconcile the receipt from your M-Pesa statement if needed. $this->payment->update(['status' => 'paid']); } else { $this->payment->update(['status' => 'failed', 'failure_reason' => $result->get('ResultDesc')]); } } }
2. Paybill (C2B) payments with invoice validation
// AppServiceProvider::boot() C2BValidation::using(function (C2BTransaction $payment) { $invoice = Invoice::where('number', strtoupper(trim($payment->billReference)))->first(); return match (true) { $invoice === null => C2BRejection::InvalidAccountNumber, $payment->amount < $invoice->balance => C2BRejection::InvalidAmount, default => true, }; }); // Listener Event::listen(function (C2BConfirmationReceived $event) { $tx = $event->transaction; // TransID is unique: use it to make the handler idempotent. InvoicePayment::firstOrCreate( ['mpesa_transaction_id' => $tx->transactionId], [ 'invoice_number' => strtoupper(trim($tx->billReference)), 'amount' => $tx->amount, 'payer_name' => $tx->customerName(), 'paid_at' => $tx->transactedAt(), ], ); });
php artisan mpesa:register-urls --response-type=Cancelled
3. B2C payouts with traceable IDs
use Siberfx\MpesaPayment\Enums\CommandId; use Siberfx\MpesaPayment\Exceptions\ConnectionException; use Siberfx\MpesaPayment\Laravel\Events\ResultReceived; $payout = Payout::create(['uuid' => (string) Str::uuid(), 'phone' => $phone, 'amount' => 2500]); try { $response = Mpesa::b2c()->pay( amount: $payout->amount, phone: $payout->phone, remarks: 'Commission', command: CommandId::BusinessPayment, originatorConversationId: $payout->uuid, resultUrl: route('mpesa.result', ['type' => 'b2c']).'?token='.config('mpesa.security.token'), ); $payout->update(['conversation_id' => $response->conversationId(), 'status' => 'submitted']); } catch (ConnectionException $e) { // Unknown outcome: do NOT send again. Check the status first (see pitfalls below). $payout->update(['status' => 'unknown']); } Event::listen(function (ResultReceived $event) { if ($event->type !== 'b2c') { return; } $payout = Payout::where('uuid', $event->result->originatorConversationId)->first(); $payout?->update($event->result->successful() ? ['status' => 'paid', 'receipt' => $event->result->transactionId] : ['status' => 'failed', 'failure_reason' => $event->result->resultDescription]); });
4. Plain PHP (no framework) with a file cache
// bootstrap.php use Siberfx\MpesaPayment\Mpesa; use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Cache\Psr16Cache; $mpesa = Mpesa::make( require __DIR__.'/mpesa-config.php', cache: new Psr16Cache(new FilesystemAdapter('mpesa')), // any PSR-16 cache works logger: $monolog, // optional PSR-3 logger );
// callback.php — the URL you configured as the "stk" callback use Siberfx\MpesaPayment\Daraja\Callbacks\StkCallback; use Siberfx\MpesaPayment\Exceptions\ValidationException; if (! hash_equals(getenv('MPESA_CALLBACK_TOKEN'), $_GET['token'] ?? '')) { http_response_code(403); exit; } try { $callback = StkCallback::fromArray(json_decode(file_get_contents('php://input'), true) ?? []); } catch (ValidationException) { http_response_code(422); exit; } $pdo->prepare('UPDATE payments SET status = ?, receipt = ? WHERE checkout_request_id = ? AND status = "pending"') ->execute([$callback->successful() ? 'paid' : 'failed', $callback->receiptNumber(), $callback->checkoutRequestId]); header('Content-Type: application/json'); echo json_encode(['ResultCode' => 0, 'ResultDesc' => 'Accepted']);
5. Tanzania checkout (Vodacom OpenAPI)
use Siberfx\MpesaPayment\Exceptions\RequestException; $tz = Mpesa::openApi('tanzania'); try { $response = $tz->c2b( amount: 15000, phone: '0754 123 456', reference: 'ORDER-77', description: 'Order 77', conversationId: $order->uuid, ); $order->markAsPaid($response->transactionId()); } catch (RequestException $e) { // e.g. INS-2006 insufficient balance, INS-9 request timeout (customer did not enter the PIN) $order->markAsFailed($e->errorCode, $e->getMessage()); }
6. Daily balance check (scheduled)
// routes/console.php Schedule::call(fn () => Mpesa::balance()->balance(remarks: 'Daily balance'))->dailyAt('07:00'); Event::listen(function (ResultReceived $event) { foreach ($event->result->balances() as $row) { if ($row['account'] === 'Working Account' && $row['available'] < 50_000) { Notification::route('mail', 'finance@example.com')->notify(new LowFloatAlert($row)); } } });
Pitfalls & best practices
These are the mistakes that most often cost money or hours in M-Pesa integrations. Read them before going live.
"Accepted" is not "paid"
$response->accepted() on an STK push, B2C, B2B, balance or reversal request only means M-Pesa queued the request. The outcome arrives later on your callback. Mark an order as paid only when the callback (or an STK query) reports result code 0. Also check that the amount in the callback matches what you expected.
Callbacks are unreliable: design for it
- They can arrive late, twice, or not at all. Make every handler idempotent. Key on
CheckoutRequestID(STK),TransID(C2B) orOriginatorConversationID(B2C/B2B), and ignore records that are no longer pending. - Always schedule a fallback (
stk()->query()orbalance()->transactionStatus()) for payments that are still pending after 1–2 minutes. - Querying an STK push too early returns an error meaning "still being processed". Treat that as "retry later", not as a failure.
- Answer callbacks quickly. Do the heavy work in queued listeners (
ShouldQueue), otherwise M-Pesa may time out and retry.
Never blindly resend a payment
If a B2C/B2B/reversal request throws ConnectionException (for example a timeout), the money may already have moved. The package deliberately does not retry in that case. Before resending, check with transactionStatus(originatorConversationId: ...) using the ID you passed in. That is why passing your own originatorConversationId is strongly recommended.
Callback URLs
- They must be public HTTPS URLs.
localhostand private IPs never receive callbacks. In the sandbox, use a tunnel such as ngrok or Expose and setMPESA_CALLBACK_BASE_URLto the tunnel URL. - C2B URLs must not contain
mpesa,safaricom,exe,exec,cmd,sqlorqueryin any casing. Keep the defaultpayments/callbacksprefix, or choose one that avoids these words. - In production, C2B URL registration is effectively a one-time setup per shortcode. Get the URLs right first. Changing them later usually involves Safaricom support.
- The validation URL is only called if external validation is enabled on your shortcode, which you must request from Safaricom. Otherwise only the confirmation URL is hit.
- Keep the callback routes on the
apimiddleware group. If you move them toweb, Laravel's request-forgery (CSRF) protection will reject M-Pesa's POSTs. - The callback token travels in the URL and can end up in access logs. Treat it as a secret you can rotate: change
MPESA_CALLBACK_TOKENand re-register the URLs. - When enabling
MPESA_VERIFY_CALLBACK_IPbehind a load balancer or Cloudflare, configure Laravel's trusted proxies. Otherwise every request appears to come from the proxy and is rejected.
Credentials and environments
- Sandbox and production use different certificates. A security credential encrypted with the wrong one fails with result code
2001("initiator information is invalid"). The same happens with a wrong or expired initiator password. - Never commit consumer keys, passkeys, initiator passwords or certificates. Load them from environment variables or a secrets manager.
- In standalone mode, always pass a persistent PSR-16 cache. Without one, every PHP request fetches a new access token, which is slow and can hit Daraja rate limits.
- The STK password includes a timestamp in Nairobi time. The package generates it, but a server clock that is far off (no NTP) still causes "invalid timestamp" errors.
Data rules the API enforces
- Kenyan amounts must be whole numbers ≥ 1. The package throws
ValidationExceptionfor10.50instead of silently rounding. Round deliberately in your own code. - The STK
referenceis limited to 12 characters (validated). The description is cut to 13 characters. - In C2B v2 callbacks the customer's
MSISDNis masked or hashed. Do not use it to identify customers. Use the bill reference or your own account number instead. - Customer names in callbacks may be empty or partial. Never make them required.
Vodacom OpenAPI specifics
- C2B single-stage calls are synchronous and wait for the customer to enter their PIN. Raise the HTTP timeout (for example
'http' => ['timeout' => 120]), or the request will time out while the customer is still paying. - A newly created session key can take a short while to become active. The package renews the session once on
401. If the first call after deployment still fails, retry after a few seconds. - Currency is fixed per market (TZS, LSL, USD for DRC). If your DRC contract settles in CDF, set
currencyexplicitly.
Operational
C2BValidation::using()stores a static callback. Register it in a service provider'sboot()method, not per request (this matters under Octane).- Reconcile daily against your M-Pesa statement. Callbacks are a notification channel, not an accounting ledger.
- Log with a dedicated channel (
MPESA_LOG_CHANNEL). Sensitive fields are redacted, but phone numbers and amounts are not. Apply your own retention policy.
Error handling
| Exception | When |
|---|---|
ConfigurationException |
Missing credentials, callback URLs or unknown accounts |
ValidationException |
Invalid phone numbers, amounts or arguments |
AuthenticationException |
Token or session could not be obtained |
RequestException |
The API returned an HTTP error (statusCode, errorCode, requestId, body) |
ConnectionException |
The API could not be reached |
All extend Siberfx\MpesaPayment\Exceptions\MpesaException.
Testing
composer test
To test your own code, pass a Guzzle client with a MockHandler to Mpesa::make(..., http: $client), or bind a pre-configured Mpesa instance in the Laravel container.
Security
If you discover a security issue, please email info@siberfx.com instead of opening a public issue.
License
The MIT License (MIT). See LICENSE.md.