Blog · Kenya & Africa · 7 min read · Published Nov 12, 2026 · Updated Nov 13, 2026

How to Send M-Pesa Payment Confirmations on WhatsApp (Laravel)

When a Daraja STK callback succeeds, queue one WhatsApp receipt. The send uses recipients, type, and text — and it only happens once per M-Pesa receipt.

In brief

When a Daraja STK callback succeeds, queue one WhatsApp receipt. The send uses recipients, type, and text — and it only happens once per M-Pesa receipt.

How to send M-Pesa payment confirmations on WhatsApp (Laravel)

Quick answer: When Safaricom's Daraja API calls your STK push callback with ResultCode 0, read the amount, M-Pesa receipt number and phone number from CallbackMetadata, save the payment, then dispatch a queued job that calls POST https://api.flaresend.com/send-message with the customer's number and a short receipt. Send once per receipt number so a retried callback never produces a duplicate message.

Updated 12 November 2026 · 8 min read · Laravel 10/11, PHP 8.2+

What you will build

A customer pays KES 2,500 by M-Pesa on your site. Within seconds they get this on WhatsApp:

Payment received: KES 2,500 for order #1042. M-Pesa ref QH12XYZ. Your order is confirmed and will be dispatched today. Reply HELP if anything looks wrong.

Customers stop asking "did my payment go through?", and your support team stops checking the M-Pesa statement by hand.

What you need

  • A Laravel app that already starts STK push payments through Daraja (sandbox is fine).
  • A FlareSend account. The 4-day free trial includes 1 WhatsApp instance and 200 API calls, enough to finish this guide.
  • A WhatsApp number linked to your FlareSend instance by QR code or pairing code, and that instance's API key.
  • Customers who agreed at checkout to receive order updates on WhatsApp (see "Consent" below).

Step 1: Store your FlareSend key

FlareSend authenticates each WhatsApp instance with a Bearer API key. Keep it on the server only.

# .env
FLARESEND_API_KEY=your_instance_api_key
FLARESEND_BASE_URL=https://api.flaresend.com
// config/services.php
'flaresend' => [
    'key' => env('FLARESEND_API_KEY'),
    'url' => env('FLARESEND_BASE_URL', 'https://api.flaresend.com'),
],

Step 2: A tiny WhatsApp client

// app/Services/FlareSend.php
namespace App\Services;

use Illuminate\Support\Facades\Http;

class FlareSend
{
    public function sendText(string $phone, string $text): array
    {
        $response = Http::withToken(config('services.flaresend.key'))
            ->acceptJson()
            ->timeout(15)
            ->retry(2, 500, throw: false)
            ->post(config('services.flaresend.url') . '/send-message', [
                'recipients' => [$phone],   // international format, e.g. 254712345678
                'type'       => 'text',
                'text'       => $text,
            ]);

        $response->throw(); // 4xx/5xx -> exception, so the queued job retries

        return $response->json();
    }
}

A successful call returns JSON with success: true and lists successful_recipients, failed_recipients and invalid_recipients (numbers that are not on WhatsApp). A 425 response means the instance is not connected yet: relink it from the dashboard.

Step 3: Normalise Kenyan phone numbers

Daraja returns PhoneNumber as 2547XXXXXXXX, but numbers typed at checkout often look like 0712 345 678 or +254 712 345 678.

// app/Support/Phone.php
function ke_msisdn(string $raw): ?string
{
    $digits = preg_replace('/\D+/', '', $raw);
    if (str_starts_with($digits, '0'))   $digits = '254' . substr($digits, 1);
    if (strlen($digits) === 9)           $digits = '254' . $digits;      // 712345678
    return preg_match('/^254(7|1)\d{8}$/', $digits) ? $digits : null;
}

Step 4: Handle the Daraja STK callback

Daraja posts the result to the CallBackURL you set when starting the STK push. A successful payment has ResultCode 0 and a CallbackMetadata.Item list containing Amount, MpesaReceiptNumber, TransactionDate and PhoneNumber.

// routes/api.php
Route::post('/mpesa/stk-callback', MpesaCallbackController::class);
// app/Http/Controllers/MpesaCallbackController.php
namespace App\Http\Controllers;

use App\Jobs\SendPaymentReceiptOnWhatsApp;
use App\Models\Payment;
use Illuminate\Http\Request;

class MpesaCallbackController
{
    public function __invoke(Request $request)
    {
        $cb = $request->input('Body.stkCallback');
        $payment = Payment::where('checkout_request_id', $cb['CheckoutRequestID'] ?? null)->first();

        if (! $payment) {
            return response()->json(['ResultCode' => 0, 'ResultDesc' => 'Accepted']);
        }

        if ((int) ($cb['ResultCode'] ?? 1) !== 0) {
            $payment->update(['status' => 'failed', 'result_desc' => $cb['ResultDesc'] ?? null]);
            return response()->json(['ResultCode' => 0, 'ResultDesc' => 'Accepted']);
        }

        $meta = collect($cb['CallbackMetadata']['Item'] ?? [])
            ->mapWithKeys(fn ($i) => [$i['Name'] => $i['Value'] ?? null]);

        $payment->update([
            'status'       => 'paid',
            'amount'       => $meta['Amount'],
            'mpesa_receipt'=> $meta['MpesaReceiptNumber'],
            'paid_phone'   => (string) $meta['PhoneNumber'],
        ]);

        SendPaymentReceiptOnWhatsApp::dispatch($payment->id);

        // Answer Daraja quickly; the WhatsApp call happens in the queue.
        return response()->json(['ResultCode' => 0, 'ResultDesc' => 'Accepted']);
    }
}

Production notes: protect the callback URL (a long random path segment and, if your host allows it, an allow-list of Safaricom's callback IPs), and confirm the amount matches the order before marking it paid.

Step 5: The queued job (idempotent)

Daraja can call back more than once. Use the M-Pesa receipt number as the idempotency key.

// app/Jobs/SendPaymentReceiptOnWhatsApp.php
namespace App\Jobs;

use App\Models\Payment;
use App\Services\FlareSend;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Cache;

class SendPaymentReceiptOnWhatsApp implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public int $tries = 5;
    public array $backoff = [10, 30, 120, 600];

    public function __construct(public int $paymentId) {}

    public function handle(FlareSend $whatsapp): void
    {
        $p = Payment::with('order')->findOrFail($this->paymentId);

        if ($p->whatsapp_receipt_sent_at || ! $p->order->whatsapp_opt_in) {
            return;
        }

        // one send per receipt, even if two workers race
        $lock = Cache::lock('wa-receipt:' . $p->mpesa_receipt, 120);
        if (! $lock->get()) return;

        try {
            $phone = ke_msisdn($p->order->customer_phone) ?? ke_msisdn($p->paid_phone);
            if (! $phone) return;

            $text = sprintf(
                "Payment received: KES %s for order #%s. M-Pesa ref %s. %s Reply HELP if anything looks wrong.",
                number_format($p->amount),
                $p->order->number,
                $p->mpesa_receipt,
                $p->order->next_step_text ?? 'Your order is confirmed.'
            );

            $result = $whatsapp->sendText($phone, $text);

            if (! empty($result['invalid_recipients'])) {
                // not on WhatsApp: fall back to SMS or email here
            }

            $p->update(['whatsapp_receipt_sent_at' => now()]);
        } finally {
            $lock->release();
        }
    }
}

Run a worker: php artisan queue:work --tries=5.

Step 6: Test it end to end

  1. In the Daraja sandbox, start an STK push to a test number and approve it.
  2. Watch storage/logs/laravel.log for the callback.
  3. Check that the WhatsApp message arrives on the customer phone and that a second identical callback does not send a second message.

Message templates that work

Situation Message
Paid in full Payment received: KES {amount} for order #{n}. M-Pesa ref {ref}. {next step}.
Partial payment We received KES {amount} for invoice #{n}. Balance: KES {balance}. Pay to Paybill {paybill}, account {n}.
Failed or cancelled STK Your M-Pesa payment for order #{n} did not complete ({reason}). Tap {link} to try again.
School fees Fee payment received for {student}: KES {amount}, ref {ref}. Balance for Term {t}: KES {balance}.
  • Ask for WhatsApp updates at checkout ("Send my receipt and delivery updates on WhatsApp") and store the answer.
  • Send only transactional messages to these customers unless they opted in to more, and honour "STOP".
  • FlareSend links your number as a device (like WhatsApp Web). It is not Meta's official WhatsApp Business Platform, so there is no green tick and WhatsApp's own rules still apply; receipts to paying customers who asked for them are the safest kind of message to send.

FAQ

Do I need Meta approval or message templates for this? No. FlareSend sends from your linked number without Meta verification or template approval. If you need a verified green-tick sender, use the official platform through a Meta partner instead.

How much does it cost? FlareSend Basic is KES 500 per 30 days for one WhatsApp number with no per-message fees, so 50 or 5,000 receipts cost the same.

What if the customer is not on WhatsApp? The API reports them in invalid_recipients; fall back to SMS or email.

Can I attach a PDF receipt? Yes. /send-message also accepts document messages by URL or file upload; see the API docs.

Start the 4-day free trial · API docs · Pricing

Try FlareSend

Connect WhatsApp in minutes and send OTPs, notifications, or bulk messages from one API.

Start free 4-day trial View pricing