felixmuhoro/laravel-mpesa-webhooks

Advanced M-Pesa webhook handling for Laravel — signature verification, idempotency, retry logic, logging, and a webhook dashboard.

Maintainers

Package info

github.com/felixmuhoro/laravel-mpesa-webhooks

pkg:composer/felixmuhoro/laravel-mpesa-webhooks

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-06-04 15:57 UTC

This package is auto-updated.

Last update: 2026-06-06 23:39:06 UTC


README

Advanced M-Pesa webhook handling for Laravel. Signature verification, IP allowlisting, idempotency, automatic retry, structured logging, and a built-in dashboard — all in one package.

Requires felixmuhoro/laravel-mpesa for the underlying Daraja API client.

Requirements

Requirement Version
PHP 8.1+
Laravel 10 / 11 / 12 / 13
felixmuhoro/laravel-mpesa ^1.2

Installation

composer require felixmuhoro/laravel-mpesa-webhooks

Publish the config and run the migration:

php artisan vendor:publish --tag=mpesa-webhooks-config
php artisan vendor:publish --tag=mpesa-webhooks-migrations
php artisan migrate

Configuration

# Disable during sandbox development, enable in production
MPESA_WEBHOOK_VERIFY_IP=true

# Comma-separated list overrides the default Safaricom production IPs
MPESA_WEBHOOK_IP_ALLOWLIST=196.201.214.200,196.201.214.206

# Only needed if you are proxying and signing callbacks yourself
MPESA_WEBHOOK_VERIFY_SIGNATURE=false
MPESA_WEBHOOK_SECRET=your-shared-secret

# Retry configuration
MPESA_WEBHOOK_MAX_ATTEMPTS=3
MPESA_WEBHOOK_BACKOFF_BASE=60

# Auto-prune processed logs older than N days (null = never)
MPESA_WEBHOOK_PRUNE_DAYS=90

Callback URLs

Register these URLs in your Daraja portal:

Type URL
STK Push Result URL https://your-domain.com/mpesa/webhook/stk
C2B Confirmation URL https://your-domain.com/mpesa/webhook/c2b
B2C Result URL https://your-domain.com/mpesa/webhook/b2c

Listening to Events

STK Push

use FelixMuhoro\MpesaWebhooks\Events\StkCallbackReceived;

class HandleStkCallback
{
    public function handle(StkCallbackReceived $event): void
    {
        if (! $event->wasSuccessful()) {
            $resultCode = $event->stkCallback['ResultCode'];
            $resultDesc = $event->stkCallback['ResultDesc'];
            return;
        }

        $amount     = $event->amount();
        $receipt    = $event->receiptNumber();
        $phone      = $event->phoneNumber();
        $checkoutId = $event->stkCallback['CheckoutRequestID'];

        Order::where('checkout_request_id', $checkoutId)->update([
            'status'        => 'paid',
            'mpesa_receipt' => $receipt,
            'paid_amount'   => $amount,
        ]);
    }
}

C2B Confirmation

use FelixMuhoro\MpesaWebhooks\Events\C2bConfirmationReceived;

class HandleC2bConfirmation
{
    public function handle(C2bConfirmationReceived $event): void
    {
        $transId   = $event->transactionId();
        $amount    = $event->amount();
        $phone     = $event->msisdn();
        $reference = $event->accountReference();
    }
}

B2C Result

use FelixMuhoro\MpesaWebhooks\Events\B2cResultReceived;

class HandleB2cResult
{
    public function handle(B2cResultReceived $event): void
    {
        if (! $event->wasSuccessful()) {
            return;
        }

        $receipt      = $event->receiptNumber();
        $amount       = $event->amount();
        $originatorId = $event->originatorConversationId();
    }
}

Generic Event

WebhookReceived fires for every successfully verified and deduplicated webhook, regardless of type:

use FelixMuhoro\MpesaWebhooks\Events\WebhookReceived;

class AuditWebhooks
{
    public function handle(WebhookReceived $event): void
    {
        // $event->log         — WebhookLog model
        // $event->webhookType — 'stk_callback' | 'c2b_confirmation' | 'b2c_result' | 'unknown'
        // $event->payload
    }
}

Idempotency

Safaricom resends the same callback if your endpoint does not respond with HTTP 200 quickly enough. The package deduplicates by idempotency key:

  • STK: CheckoutRequestID
  • C2B: TransID
  • B2C: OriginatorConversationID:TransactionID

A duplicate webhook returns HTTP 200 immediately so Safaricom stops retrying, but does not re-fire events or re-process.

Retry Failed Webhooks

# Retry all retryable failures (respects back-off)
php artisan mpesa:retry-webhooks

# Retry a specific log entry
php artisan mpesa:retry-webhooks --id=42

# Filter by type
php artisan mpesa:retry-webhooks --type=stk_callback

# Override back-off and retry immediately
php artisan mpesa:retry-webhooks --force

# Limit batch size
php artisan mpesa:retry-webhooks --limit=10

Add to your scheduler for automatic recovery:

$schedule->command('mpesa:retry-webhooks')->everyFiveMinutes();

Dashboard

Visit /mpesa/webhook/dashboard (requires auth by default).

Shows all inbound webhooks with filtering by type and status, idempotency key, source IP, attempt count, and error messages.

// config/mpesa-webhooks.php
'dashboard' => [
    'enabled'    => true,
    'middleware' => ['web', 'auth:admin'],
    'per_page'   => 50,
],

WebhookLog Model

use FelixMuhoro\MpesaWebhooks\Models\WebhookLog;

WebhookLog::failed()->get();
WebhookLog::processed()->get();
WebhookLog::pending()->get();
WebhookLog::retryable()->get();
WebhookLog::byType('stk_callback')->latest()->get();

Security

  • IP allowlisting is enabled by default and checks against Safaricom's published egress IPs.
  • Signature verification is opt-in for proxy setups.
  • Rejected requests (wrong IP, bad signature, unparseable body) return HTTP 403.
  • IP checks respect Laravel's TrustProxies configuration.

Testing

composer test

Uses in-memory SQLite and Orchestra Testbench — no external services required.

License

MIT