Search by

siberfx / mpesa-payment

siberfx

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.

Package info

github.com/siberfx/mpesa-payment

pkg:composer/siberfx/mpesa-payment

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-09-30 19:14 UTC

This package is auto-updated.

Last update: 2026-09-30 19:16:03 UTC


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

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-urls command

Requirements

  • PHP 8.4 or 8.5 with ext-openssl and ext-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, sql or query, which is why the default prefix is payments/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 matching token query parameter get a 403.
  • MPESA_VERIFY_CALLBACK_IP=true — only addresses in mpesa.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) or OriginatorConversationID (B2C/B2B), and ignore records that are no longer pending.
  • Always schedule a fallback (stk()->query() or balance()->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. localhost and private IPs never receive callbacks. In the sandbox, use a tunnel such as ngrok or Expose and set MPESA_CALLBACK_BASE_URL to the tunnel URL.
  • C2B URLs must not contain mpesa, safaricom, exe, exec, cmd, sql or query in any casing. Keep the default payments/callbacks prefix, 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 api middleware group. If you move them to web, 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_TOKEN and re-register the URLs.
  • When enabling MPESA_VERIFY_CALLBACK_IP behind 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 ValidationException for 10.50 instead of silently rounding. Round deliberately in your own code.
  • The STK reference is limited to 12 characters (validated). The description is cut to 13 characters.
  • In C2B v2 callbacks the customer's MSISDN is 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 currency explicitly.

Operational

  • C2BValidation::using() stores a static callback. Register it in a service provider's boot() 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.