Skip to content

Flutterwave · Laravel

Flutterwave Mobile Money Uganda in Laravel: MTN and Airtel

Accept MTN and Airtel mobile money in Uganda with Flutterwave in Laravel: charges, verif-hash webhooks, a verify job, scheduled checks and tests.

Published

Skip the boilerplate → Paste a webhook into the Flutterwave Webhook Checker to check its verif-hash and get the verify request.

This guide accepts MTN Mobile Money and Airtel Money in Uganda through Flutterwave’s v3 API, in a Laravel app. You’ll build:

  • a FlutterwaveClient service: phone formatting, network detection, the charge and verification by your own reference,
  • a payments table and a controller that saves each payment before charging,
  • a webhook route that checks Flutterwave’s verif-hash, and a queued job that settles payments only from verified data,
  • a scheduled check for webhooks that never arrive,
  • a feature test that runs every case without calling Flutterwave.

Every code block was run in a fresh Laravel 13 app, and the test at the end passes. API facts come from Flutterwave’s public v3 documentation, checked in October 2026. The Node.js version of this guide explains the flow in more detail.

Before you start

  1. In your Flutterwave dashboard, switch to test mode and copy the test secret key.
  2. In the webhook settings, set the URL to https://your-domain/api/webhooks/flutterwave and a long random secret hash. Generate one with php -r "echo bin2hex(random_bytes(24)), PHP_EOL;". Turn on webhook retries. For local development, use a tunnel such as cloudflared or ngrok.
  3. Make sure a queue worker can run (php artisan queue:work). The database queue driver is fine to start with.
  4. If your app has no routes/api.php yet, run php artisan install:api.

Configuration

Add your credentials to .env and never commit it:

FLW_SECRET_KEY=your-test-secret-key
FLW_SECRET_HASH=the-same-secret-hash-as-your-dashboard
FLW_REDIRECT_URL="https://your-domain/orders/thanks?ref={tx_ref}"

FLW_REDIRECT_URL is where Flutterwave sends the customer back to; the controller replaces {tx_ref} with the payment’s reference. Map the values in config/services.php, and always read them through config(). After php artisan config:cache, env() returns null outside config files.

<?php

// config/services.php
return [
    // ...your existing services

    'flutterwave' => [
        'base_url' => 'https://api.flutterwave.com',
        'secret_key' => env('FLW_SECRET_KEY'),
        'secret_hash' => env('FLW_SECRET_HASH'),
        'redirect_url' => env('FLW_REDIRECT_URL'),
    ],
];

Create the payments table

You choose the tx_ref and send it with the charge, so it gets its own unique column. A UUID is unique per attempt and you know it before calling Flutterwave.

php artisan make:model FlutterwavePayment -m
<?php

// database/migrations/xxxx_xx_xx_create_flutterwave_payments_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('flutterwave_payments', function (Blueprint $table) {
            $table->id();
            $table->uuid('tx_ref')->unique();               // our reference, sent to Flutterwave
            $table->string('order_id')->index();
            $table->unsignedBigInteger('amount');            // whole shillings
            $table->string('currency', 3)->default('UGX');
            $table->string('phone', 15);                     // 256772123456
            $table->string('network', 10);                   // MTN or AIRTEL
            $table->string('status')->default('pending');    // pending | paid | failed | review
            $table->string('flw_ref')->nullable();           // Flutterwave's reference once paid
            $table->string('failure_reason')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('flutterwave_payments');
    }
};
<?php

// app/Models/FlutterwavePayment.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class FlutterwavePayment extends Model
{
    protected $fillable = ['tx_ref', 'order_id', 'amount', 'currency', 'phone', 'network', 'status', 'flw_ref', 'failure_reason'];

    protected $casts = ['amount' => 'integer'];
}

Run php artisan migrate.

The Flutterwave service class

The client turns network problems and 5xx answers into an ambiguous error: the request may have reached Flutterwave, so the payment must stay pending and be checked. A 4xx is a clear refusal.

<?php

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

use RuntimeException;

class FlutterwaveException extends RuntimeException
{
    /** $ambiguous = true: the request may have reached Flutterwave (timeout, 5xx). Check, don't assume it failed. */
    public function __construct(string $message, public readonly bool $ambiguous = false)
    {
        parent::__construct($message);
    }
}
<?php

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

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use InvalidArgumentException;

class FlutterwaveClient
{
    /**
     * Uganda mobile prefixes for MTN and Airtel, from Google libphonenumber's carrier data (October 2026).
     * The longest matching prefix wins: 798 and 799 are Africell, 7240 is Hamilton Telecom.
     */
    private const NETWORKS = [
        '70' => 'AIRTEL', '730' => 'AIRTEL', '731' => 'AIRTEL', '732' => 'AIRTEL', '74' => 'AIRTEL', '75' => 'AIRTEL',
        '724' => 'MTN', '7240' => null, '737' => 'MTN', '76' => 'MTN', '77' => 'MTN', '78' => 'MTN', '79' => 'MTN',
        '798' => null, '799' => null,
    ];

    private function http(): PendingRequest
    {
        return Http::baseUrl((string) config('services.flutterwave.base_url'))
            ->withToken((string) config('services.flutterwave.secret_key'))
            ->acceptJson()
            ->timeout(30);
    }

    /** "0772 123 456", "+256 772 123456" or "772123456" -> "256772123456". */
    public static function phone(string $raw): string
    {
        $digits = preg_replace('/\D/', '', $raw);
        if (str_starts_with($digits, '00')) {
            $digits = substr($digits, 2);
        }
        if (str_starts_with($digits, '2560')) {
            $digits = '256'.substr($digits, 4);
        } elseif (str_starts_with($digits, '0')) {
            $digits = '256'.substr($digits, 1);
        } elseif (strlen($digits) === 9) {
            $digits = '256'.$digits;
        }
        if (! preg_match('/^256\d{9}$/', $digits)) {
            throw new InvalidArgumentException('Enter a Ugandan mobile number, for example 0772 123 456.');
        }

        return $digits;
    }

    /** "MTN" or "AIRTEL" for a number like 256772123456, or null if it is neither. */
    public static function network(string $phone): ?string
    {
        $national = substr($phone, 3);
        $best = null;
        foreach (self::NETWORKS as $prefix => $network) {
            $prefix = (string) $prefix;
            if (str_starts_with($national, $prefix) && ($best === null || strlen($prefix) > strlen($best[0]))) {
                $best = [$prefix, $network];
            }
        }

        return $best[1] ?? null;
    }

    private function send(string $method, string $url, array $body = []): Response
    {
        try {
            $response = $method === 'GET' ? $this->http()->get($url) : $this->http()->post($url, $body);
        } catch (ConnectionException $e) {
            throw new FlutterwaveException('Flutterwave unreachable: '.$e->getMessage(), ambiguous: true);
        }
        if ($response->serverError()) {
            throw new FlutterwaveException("Flutterwave HTTP {$response->status()}", ambiguous: true);
        }

        return $response;
    }

    /** Starts a Uganda mobile money charge. Returns the URL to send the customer to (it may be null). */
    public function charge(string $txRef, int $amount, string $phone, string $network, string $email, ?string $redirectUrl): ?string
    {
        $response = $this->send('POST', '/v3/charges?type=mobile_money_uganda', array_filter([
            'tx_ref' => $txRef,
            'amount' => $amount,
            'currency' => 'UGX',
            'phone_number' => $phone,
            'network' => $network,
            'email' => $email,
            'redirect_url' => $redirectUrl,
        ]));

        if (! $response->successful() || $response->json('status') !== 'success') {
            throw new FlutterwaveException('Flutterwave refused the charge: '.$response->json('message', "HTTP {$response->status()}"));
        }

        return $response->json('meta.authorization.redirect');
    }

    /** The source of truth: the transaction for our tx_ref, or null if Flutterwave has none (yet). */
    public function verify(string $txRef): ?array
    {
        $response = $this->send('GET', '/v3/transactions/verify_by_reference?tx_ref='.rawurlencode($txRef));
        $tx = $response->json('data');

        return $response->successful() && $response->json('status') === 'success' && is_array($tx) ? $tx : null;
    }
}

Phone number format. Flutterwave’s Uganda page doesn’t say which format phone_number takes, and its examples disagree. Its mobile money transfer docs use the country code without a plus, so this guide sends 256772123456. Test mode approves payments automatically, so it can’t tell you if the format is wrong: confirm with one small live payment on MTN and one on Airtel before launch. The African Phone Number Formatter shows the network for any Ugandan number.

Start a payment

The controller saves the payment before calling Flutterwave. If the request times out, Flutterwave may still have created the charge; because you already know the tx_ref, the payment stays pending and the scheduled check settles it.

<?php

// app/Http/Controllers/FlutterwavePaymentController.php
namespace App\Http\Controllers;

use App\Models\FlutterwavePayment;
use App\Services\FlutterwaveClient;
use App\Services\FlutterwaveException;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use InvalidArgumentException;

class FlutterwavePaymentController extends Controller
{
    public function store(Request $request, FlutterwaveClient $flutterwave)
    {
        $data = $request->validate([
            'order_id' => ['required', 'string', 'max:64'],
            'amount' => ['required', 'integer', 'min:1'], // take it from your order record in a real app
            'phone' => ['required', 'string', 'max:20'],
            'email' => ['required', 'email'],             // Flutterwave requires one
        ]);

        try {
            $phone = FlutterwaveClient::phone($data['phone']);
        } catch (InvalidArgumentException $e) {
            return response()->json(['errors' => ['phone' => [$e->getMessage()]]], 422);
        }
        $network = FlutterwaveClient::network($phone);
        if ($network === null) {
            return response()->json(['errors' => ['phone' => ['Enter an MTN or Airtel number.']]], 422);
        }

        // Saved BEFORE the call: if the response is lost, the scheduled check still finds it by tx_ref.
        $payment = FlutterwavePayment::create([
            'tx_ref' => (string) Str::uuid(),
            'order_id' => $data['order_id'],
            'amount' => $data['amount'],
            'phone' => $phone,
            'network' => $network,
        ]);

        $returnTo = str_replace('{tx_ref}', $payment->tx_ref, (string) config('services.flutterwave.redirect_url')) ?: null;

        try {
            $redirect = $flutterwave->charge($payment->tx_ref, $payment->amount, $phone, $network, $data['email'], $returnTo);
        } catch (FlutterwaveException $e) {
            report($e);
            if (! $e->ambiguous) {
                $payment->update(['status' => 'failed', 'failure_reason' => mb_substr($e->getMessage(), 0, 190)]);

                return response()->json(['message' => 'The payment could not be started. Please try again.'], 502);
            }
            $redirect = null; // it may have gone through: leave it pending, the check settles it
        }

        return response()->json(['tx_ref' => $payment->tx_ref, 'redirect_url' => $redirect], 201);
    }

    /** Your order page polls this, not Flutterwave. */
    public function show(string $txRef)
    {
        $payment = FlutterwavePayment::where('tx_ref', $txRef)->firstOrFail();

        return response()->json(['status' => $payment->status]);
    }
}

The response carries redirect_url. Send the customer there to approve the payment; Flutterwave sends them back to your FLW_REDIRECT_URL, where your page polls GET /api/payments/flutterwave/{tx_ref} until the status is final.

Handle the webhook

Flutterwave can’t send a CSRF token, so the webhook lives in routes/api.php, which has no CSRF check and adds an /api prefix. Keep one copy of each use line if the file already has some.

The route checks verif-hash with hash_equals, reads only data.tx_ref, and hands it to a job. A forged body can’t mark anything paid: it can only make the job ask Flutterwave.

<?php

// routes/api.php
use App\Http\Controllers\FlutterwavePaymentController;
use App\Jobs\ConfirmFlutterwavePayment;
use App\Models\FlutterwavePayment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/payments/flutterwave', [FlutterwavePaymentController::class, 'store']);
Route::get('/payments/flutterwave/{txRef}', [FlutterwavePaymentController::class, 'show']);

Route::post('/webhooks/flutterwave', function (Request $request) {
    $expected = (string) config('services.flutterwave.secret_hash');
    if ($expected === '' || ! hash_equals($expected, (string) $request->header('verif-hash'))) {
        return response()->json(['error' => 'Invalid verif-hash'], 401); // not from Flutterwave
    }

    $txRef = $request->json('data.tx_ref');
    if (is_string($txRef) && FlutterwavePayment::where('tx_ref', $txRef)->exists()) {
        ConfirmFlutterwavePayment::dispatch($txRef); // never trust the body; the job asks Flutterwave
    }

    return response()->json(['ok' => true]);
});

The job asks Flutterwave for the real status, then updates the row inside a locked transaction, so a repeated webhook or the scheduled check running at the same moment can’t settle the payment twice.

<?php

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

use App\Models\FlutterwavePayment;
use App\Services\FlutterwaveClient;
use App\Services\FlutterwaveException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;

class ConfirmFlutterwavePayment implements ShouldQueue
{
    use Queueable;

    public int $tries = 6;

    public function __construct(public string $txRef)
    {
    }

    public function handle(FlutterwaveClient $flutterwave): void
    {
        try {
            $tx = $flutterwave->verify($this->txRef); // network call outside the lock
        } catch (FlutterwaveException) {
            $this->release(60 * $this->attempts()); // Flutterwave unreachable: proves nothing, ask again

            return;
        }

        DB::transaction(function () use ($tx) {
            $payment = FlutterwavePayment::where('tx_ref', $this->txRef)->lockForUpdate()->first();
            if (! $payment || $payment->status !== 'pending') {
                return; // unknown, or already settled by another webhook or check
            }

            if ($tx === null || ($tx['tx_ref'] ?? null) !== $payment->tx_ref) {
                // No transaction yet. After an hour there never will be: nothing was charged.
                if ($payment->created_at->lt(now()->subHour())) {
                    $payment->update(['status' => 'failed', 'failure_reason' => 'not_found']);
                }

                return;
            }

            $status = strtolower((string) ($tx['status'] ?? ''));
            if ($status === 'successful') {
                $enough = ($tx['currency'] ?? '') === $payment->currency && (float) ($tx['amount'] ?? 0) >= $payment->amount;
                $payment->update([
                    'status' => $enough ? 'paid' : 'review', // review: paid, but less or another currency
                    'flw_ref' => $tx['flw_ref'] ?? null,
                    'failure_reason' => $enough ? null : "paid {$tx['amount']} {$tx['currency']}",
                ]);
                // if ($enough) event(new OrderPaid($payment->order_id));
            } elseif ($status === 'failed') {
                $payment->update(['status' => 'failed', 'failure_reason' => mb_substr((string) ($tx['processor_response'] ?? 'failed'), 0, 190)]);
            }
            // pending: leave it; a later webhook or the scheduled check settles it
        });
    }
}

What the job does with each answer:

Flutterwave says What the code does
successful, right currency, amount at least the order Marks it paid and stores flw_ref
successful, but less or another currency Marks it review for a person to look at
failed Marks it failed with the network’s message
pending Leaves it; a later webhook or check settles it
No transaction, less than an hour old Leaves it pending
No transaction after an hour Marks it failed (not_found): nothing was charged
5xx or no answer Tries again later. A server error proves nothing

Got a webhook you want to inspect? Paste it into the Flutterwave Webhook Checker.

Catch webhooks that never arrive

Flutterwave recommends not relying on webhooks alone. A scheduled check in routes/console.php re-checks every payment that’s been pending for more than two minutes:

<?php

// routes/console.php
use App\Jobs\ConfirmFlutterwavePayment;
use App\Models\FlutterwavePayment;
use Illuminate\Support\Facades\Schedule;

Schedule::call(function () {
    FlutterwavePayment::where('status', 'pending')
        ->where('created_at', '<', now()->subMinutes(2))
        ->where('created_at', '>', now()->subDay())
        ->pluck('tx_ref')
        ->each(fn (string $txRef) => ConfirmFlutterwavePayment::dispatch($txRef));
})->everyFiveMinutes();

This needs Laravel’s scheduler running: a cron entry that calls php artisan schedule:run every minute.

Test it without calling Flutterwave

Http::fake() replaces Flutterwave, so this test runs offline and in CI. It checks the charge format, that the webhook body is never trusted, that a wrong verif-hash is refused, that short or wrong-currency payments go to review, that a transaction for another tx_ref is ignored, that a settled payment never changes, and that timeouts and server errors leave payments pending.

<?php

// tests/Feature/FlutterwavePaymentTest.php
namespace Tests\Feature;

use App\Jobs\ConfirmFlutterwavePayment;
use App\Models\FlutterwavePayment;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class FlutterwavePaymentTest extends TestCase
{
    use RefreshDatabase;

    /** What Flutterwave's verify endpoint says. null = no such transaction. */
    private ?array $tx = null;

    private int $verifyHttpStatus = 200;

    /** What the charge endpoint answers. */
    private array $charge = [200, [
        'status' => 'success', 'message' => 'Charge initiated',
        'meta' => ['authorization' => ['mode' => 'redirect', 'redirect' => 'https://checkout.flutterwave.example/approve']],
    ]];

    protected function setUp(): void
    {
        parent::setUp();
        config([
            'queue.default' => 'sync',
            'services.flutterwave.base_url' => 'https://api.flutterwave.com',
            'services.flutterwave.secret_key' => 'FLWSECK_TEST-secret',
            'services.flutterwave.secret_hash' => 'test-secret-hash-0123456789',
            'services.flutterwave.redirect_url' => 'https://shop.example.com/orders/thanks?ref={tx_ref}',
        ]);

        Http::fake([
            '*/v3/charges*' => fn () => Http::response($this->charge[1], $this->charge[0]),
            '*/v3/transactions/verify_by_reference*' => fn (Request $request) => $this->tx === null
                ? Http::response(['status' => 'error', 'message' => 'No transaction was found', 'data' => null], $this->verifyHttpStatus === 200 ? 400 : $this->verifyHttpStatus)
                : Http::response(['status' => 'success', 'data' => $this->tx], $this->verifyHttpStatus),
        ]);
    }

    private function start(string $phone = '0772 123 456', int $amount = 15000): string
    {
        return $this->postJson('/api/payments/flutterwave', [
            'order_id' => '1001', 'amount' => $amount, 'phone' => $phone, 'email' => 'buyer@example.com',
        ])->assertCreated()->assertJsonPath('redirect_url', 'https://checkout.flutterwave.example/approve')->json('tx_ref');
    }

    private function webhook(string $txRef, string $hash = 'test-secret-hash-0123456789')
    {
        return $this->postJson('/api/webhooks/flutterwave',
            ['event' => 'charge.completed', 'data' => ['tx_ref' => $txRef, 'status' => 'successful']],
            ['verif-hash' => $hash]);
    }

    private function successful(string $txRef, array $overrides = []): array
    {
        return $overrides + ['id' => 285959875, 'tx_ref' => $txRef, 'flw_ref' => 'FLW-MOCK-1', 'amount' => 15000, 'currency' => 'UGX', 'status' => 'successful'];
    }

    public function test_charge_is_sent_correctly_and_settled_from_verify_not_the_webhook(): void
    {
        $txRef = $this->start();

        Http::assertSent(fn (Request $request) => str_contains($request->url(), '/v3/charges?type=mobile_money_uganda')
            && $request->header('Authorization') === ['Bearer FLWSECK_TEST-secret']
            && $request['tx_ref'] === $txRef
            && $request['amount'] === 15000
            && $request['currency'] === 'UGX'
            && $request['phone_number'] === '256772123456'
            && $request['network'] === 'MTN'
            && $request['email'] === 'buyer@example.com'
            && $request['redirect_url'] === "https://shop.example.com/orders/thanks?ref=$txRef");

        // The body says "successful", but Flutterwave still says pending.
        $this->tx = $this->successful($txRef, ['status' => 'pending']);
        $this->webhook($txRef)->assertOk();
        $this->assertSame('pending', FlutterwavePayment::first()->status);

        $this->tx = $this->successful($txRef);
        $this->webhook($txRef)->assertOk();
        $this->assertDatabaseHas('flutterwave_payments', ['tx_ref' => $txRef, 'status' => 'paid', 'flw_ref' => 'FLW-MOCK-1']);
        $this->getJson("/api/payments/flutterwave/$txRef")->assertJson(['status' => 'paid']);
    }

    public function test_webhook_without_the_right_verif_hash_is_refused(): void
    {
        $txRef = $this->start();
        $this->tx = $this->successful($txRef);
        $this->webhook($txRef, 'wrong')->assertUnauthorized();
        $this->postJson('/api/webhooks/flutterwave', ['data' => ['tx_ref' => $txRef]])->assertUnauthorized();
        Http::assertNotSent(fn (Request $request) => str_contains($request->url(), 'verify_by_reference'));
        $this->assertSame('pending', FlutterwavePayment::first()->status);
    }

    public function test_airtel_numbers_and_bad_numbers(): void
    {
        $this->start('0752 123 456');
        Http::assertSent(fn (Request $request) => ($request['network'] ?? null) === 'AIRTEL');
        $this->postJson('/api/payments/flutterwave', ['order_id' => '1', 'amount' => 500, 'phone' => '0798 123 456', 'email' => 'a@b.co'])
            ->assertStatus(422); // Africell, not MTN or Airtel
        $this->postJson('/api/payments/flutterwave', ['order_id' => '1', 'amount' => 500, 'phone' => '12', 'email' => 'a@b.co'])
            ->assertStatus(422);
    }

    public function test_short_payment_or_wrong_currency_goes_to_review(): void
    {
        $txRef = $this->start();
        $this->tx = $this->successful($txRef, ['amount' => 1500]);
        $this->webhook($txRef);
        $this->assertSame('review', FlutterwavePayment::first()->status);

        $other = $this->start();
        $this->tx = $this->successful($other, ['currency' => 'KES']);
        $this->webhook($other);
        $this->assertSame('review', FlutterwavePayment::where('tx_ref', $other)->first()->status);
    }

    public function test_a_transaction_for_another_tx_ref_is_ignored(): void
    {
        $txRef = $this->start();
        $this->tx = $this->successful('someone-else');
        $this->webhook($txRef);
        $this->assertSame('pending', FlutterwavePayment::first()->status);
    }

    public function test_failed_then_never_changed_again(): void
    {
        $txRef = $this->start();
        $this->tx = $this->successful($txRef, ['status' => 'failed', 'processor_response' => 'Insufficient funds']);
        $this->webhook($txRef);
        $this->assertDatabaseHas('flutterwave_payments', ['status' => 'failed', 'failure_reason' => 'Insufficient funds']);

        $paid = $this->start();
        $this->tx = $this->successful($paid);
        $this->webhook($paid);
        $this->tx = $this->successful($paid, ['status' => 'failed']); // a late duplicate with another answer
        $this->webhook($paid);
        $this->assertSame('paid', FlutterwavePayment::where('tx_ref', $paid)->first()->status);
    }

    public function test_missing_transaction_waits_then_fails_after_an_hour(): void
    {
        $txRef = $this->start();
        ConfirmFlutterwavePayment::dispatch($txRef);
        $this->assertSame('pending', FlutterwavePayment::first()->status);

        FlutterwavePayment::query()->update(['created_at' => now()->subMinutes(61)]);
        ConfirmFlutterwavePayment::dispatch($txRef);
        $this->assertDatabaseHas('flutterwave_payments', ['status' => 'failed', 'failure_reason' => 'not_found']);
    }

    public function test_server_error_on_verify_proves_nothing(): void
    {
        $txRef = $this->start();
        FlutterwavePayment::query()->update(['created_at' => now()->subHours(2)]);
        $this->verifyHttpStatus = 502;
        ConfirmFlutterwavePayment::dispatch($txRef);
        $this->assertSame('pending', FlutterwavePayment::first()->status);
    }

    public function test_charge_timeout_stays_pending_and_the_check_settles_it(): void
    {
        $this->charge = [503, []];
        $txRef = $this->postJson('/api/payments/flutterwave', ['order_id' => '1', 'amount' => 15000, 'phone' => '0772123456', 'email' => 'a@b.co'])
            ->assertCreated()->assertJsonPath('redirect_url', null)->json('tx_ref');
        $this->assertSame('pending', FlutterwavePayment::first()->status);

        $this->tx = $this->successful($txRef);
        ConfirmFlutterwavePayment::dispatch($txRef);
        $this->assertSame('paid', FlutterwavePayment::first()->status);
    }

    public function test_a_clear_refusal_fails_at_once(): void
    {
        $this->charge = [400, ['status' => 'error', 'message' => 'Invalid phone number']];
        $this->postJson('/api/payments/flutterwave', ['order_id' => '1', 'amount' => 15000, 'phone' => '0772123456', 'email' => 'a@b.co'])
            ->assertStatus(502);
        $this->assertSame('failed', FlutterwavePayment::first()->status);
    }
}

Run it with php artisan test --filter FlutterwavePaymentTest.

Common errors and fixes

Symptom Likely cause Fix
Webhooks get 401 FLW_SECRET_HASH differs from the dashboard, or config is cached with an old value Use exactly the same value, then run php artisan config:clear
Webhooks get 419 The route is in routes/web.php, so CSRF blocks it Move it to routes/api.php, or exclude it from CSRF
401 from Flutterwave’s API Test key in live mode, or the reverse Use the secret key for the mode you’re in
Payments stay pending No queue worker, or no scheduler Run php artisan queue:work and the schedule:run cron
review Paid amount or currency differs from the order Check the transaction in the dashboard and decide: give value or refund

Going to production

  • Complete Flutterwave’s business verification, switch to your live secret key, and set a new live secret hash in both places.
  • Load the amount from your order on the server. Never take it from the request, as the example does for brevity.
  • Run a queue worker and the scheduler under a process manager, and run php artisan config:cache after changing .env.
  • Send one small live payment on MTN and one on Airtel before launch, to confirm the phone format and your webhook.

Prefer to go direct? See MTN MoMo Collections in Laravel and Airtel Money collections in Laravel.

Frequently asked questions

How do I verify a Flutterwave webhook in Laravel?

Compare the verif-hash header with the secret hash from your dashboard using hash_equals, and answer 401 if they differ. Then confirm the payment with the verify endpoint (this guide uses verify_by_reference with your tx_ref) and use that answer, not the webhook body.

Does the Flutterwave webhook route need a CSRF token?

It can't have one: Flutterwave can't send a Laravel CSRF token. Put the route in routes/api.php, which has no CSRF check, or exclude it in bootstrap/app.php. The verif-hash check replaces CSRF protection for this route.

Why use a queued job instead of updating the order in the webhook?

The job does the network call and the locked database update in one place, so the webhook, your polling endpoint and the scheduled check all settle a payment the same way, exactly once. It also lets the webhook answer quickly.

How do I test Flutterwave mobile money in Laravel without real money?

Use Http::fake() in your tests, as this guide does, and your test mode secret key for manual runs. Flutterwave says Uganda mobile money payments in test mode are authorised automatically after a few seconds.

Need this built for you?

I integrate MTN MoMo, M-Pesa and Airtel Money into Laravel / PHP, Python / Django, Node.js / Next.js apps: payments, callbacks, payouts and go-live, with tests.