Skip to content

Flutterwave · Laravel

Flutterwave Mobile Money Payouts in Laravel: MTN and Airtel Uganda

Send payouts to MTN and Airtel Money wallets in Uganda with the Flutterwave v3 Transfers API in Laravel, without ever paying twice. With feature tests.

Published

Skip the boilerplate → Paste a transfer.completed webhook into the Flutterwave Webhook Checker to see what it means.

This guide sends money from your Flutterwave balance to MTN Mobile Money and Airtel Money wallets in Uganda from a Laravel app, with Flutterwave’s v3 Transfers API: refunds, seller payouts, salaries. You’ll build:

  • a FlutterwaveTransfers service: create a transfer and look one up,
  • a flutterwave_payouts table and a FlutterwavePayouts service that never sends the same payout twice, even after a timeout, a crash or a double click,
  • a webhook controller that checks verif-hash and settles payouts only from the looked-up transfer,
  • a scheduled command that settles pending payouts and reports the ones that need a person,
  • 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 November 2026. The Node.js version of this guide has the same design. To receive payments, see Flutterwave mobile money in Laravel.

How a payout works

  1. You save the payout with your own reference, then call POST /v3/transfers.
  2. Flutterwave answers "Transfer Queued Successfully" with data.id and status NEW. That is not a payout yet.
  3. The status moves through PENDING to SUCCESSFUL or FAILED. Flutterwave sends a transfer.completed webhook either way.
  4. You confirm with GET /v3/transfers/{id}, the source of truth.
new ──send──▶ sending ──queued (id)──▶ pending ──lookup: SUCCESSFUL──▶ paid
                 │                         └────lookup: FAILED──▶ failed
                 ├──4xx (clear refusal)──▶ failed
                 └──timeout / 5xx──▶ unknown ──webhook brings the id──▶ pending / paid / failed

There’s one gap to design around. Flutterwave’s docs describe looking a transfer up by its id, which you only learn from the response. If the response is lost, you can’t check the transfer yourself. So a timed-out payout is never resent automatically: it waits for the webhook, which carries your reference and the id, or for a person to check the dashboard.

Before you start

  1. In your Flutterwave dashboard, switch to test mode, copy the test secret key, and fund the test balance you’ll pay out from.
  2. In the webhook settings, set your webhook URL and a long random secret hash (32+ characters), and turn on retries.
  3. Add both to .env and never commit it:
FLW_SECRET_KEY=your-test-secret-key
FLW_SECRET_HASH=the-same-secret-hash-as-your-dashboard

Map them in config/services.php (if you followed the mobile money guide, you already have this entry):

<?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'),
    ],
];

The payouts table

php artisan make:model FlutterwavePayout -m
<?php

// database/migrations/xxxx_xx_xx_create_flutterwave_payouts_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_payouts', function (Blueprint $table) {
            $table->id();
            $table->string('reference', 64)->unique();           // yours, fixed for life; the webhook carries it
            $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('name', 100);
            $table->string('narration', 100);
            $table->string('status', 10)->default('new');        // new | sending | pending | unknown | paid | failed | review
            $table->unsignedBigInteger('transfer_id')->nullable(); // Flutterwave's id: the only way to look it up
            $table->string('reason')->nullable();
            $table->timestamps();
            $table->index(['status', 'updated_at']);
        });
    }

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

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

use Illuminate\Database\Eloquent\Model;

class FlutterwavePayout extends Model
{
    /** Final: nothing changes them again. review = the transfer's amount or currency differs: a person must look. */
    public const FINAL = ['paid', 'failed', 'review'];

    protected $fillable = ['reference', 'amount', 'currency', 'phone', 'network', 'name', 'narration', 'status', 'transfer_id', 'reason'];

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

Run php artisan migrate.

The transfers service

Network failures, 5xx answers and bodies that aren’t JSON become an ambiguous exception: the transfer may have been created. Flutterwave’s docs say a timeout comes back as a 503 after about 28 seconds and the transfer may still be processing. A 4xx is a clear refusal.

<?php

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

use RuntimeException;

class FlutterwaveTransferException extends RuntimeException
{
    /** ambiguous: the transfer may exist. Never send the payout again automatically. */
    public function __construct(string $message, public readonly int $status = 0, public readonly bool $ambiguous = false, public readonly array $body = [])
    {
        parent::__construct($message);
    }
}
<?php

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

use App\Models\FlutterwavePayout;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;

class FlutterwaveTransfers
{
    private function call(callable $send): Response
    {
        $http = Http::baseUrl(rtrim((string) config('services.flutterwave.base_url'), '/'))
            ->withToken((string) config('services.flutterwave.secret_key'))
            ->acceptJson()
            ->timeout(60);
        try {
            $res = $send($http);
        } catch (ConnectionException $e) {
            throw new FlutterwaveTransferException("Flutterwave unreachable: {$e->getMessage()}", ambiguous: true);
        }
        if ($res->serverError() || ! is_array($res->json())) {
            throw new FlutterwaveTransferException("Flutterwave HTTP {$res->status()}", $res->status(), true, (array) $res->json());
        }

        return $res;
    }

    /** Queues a mobile money transfer. Returns Flutterwave's data: id, reference, status ("NEW"). Not paid yet. */
    public function create(FlutterwavePayout $payout): array
    {
        $res = $this->call(fn (PendingRequest $http) => $http->post('/v3/transfers', [
            'account_bank' => $payout->network,       // the operator code: "MTN" or "Airtel" for Uganda
            'account_number' => $payout->phone,       // with the country code: 256772123456
            'amount' => $payout->amount,
            'currency' => $payout->currency,
            'beneficiary_name' => $payout->name,
            'narration' => $payout->narration,
            'reference' => $payout->reference,        // saved before this call
        ]));
        if ($res->successful() && $res->json('status') === 'success' && $res->json('data.id')) {
            return $res->json('data');
        }
        throw new FlutterwaveTransferException('Transfer refused: '.($res->json('message') ?? $res->status()), $res->status(), body: (array) $res->json());
    }

    /** The source of truth for a transfer you have the id of: id, reference, status, amount, currency, complete_message. */
    public function get(int|string $id): array
    {
        $res = $this->call(fn (PendingRequest $http) => $http->get('/v3/transfers/'.rawurlencode((string) $id)));
        if ($res->successful() && $res->json('status') === 'success' && is_array($res->json('data'))) {
            return $res->json('data');
        }
        throw new FlutterwaveTransferException("Transfer lookup failed: {$res->status()}", $res->status(), true, (array) $res->json());
    }

    /** verif-hash must equal the secret hash you set in the dashboard. */
    public static function hashOk(?string $given, string $secretHash): bool
    {
        return strlen($secretHash) >= 32 && is_string($given) && hash_equals($secretHash, $given);
    }
}

The operator code. Flutterwave’s mobile money page lists Airtel, MPS and MTN as the options for Uganda (UGX). This guide sends MTN or Airtel in account_bank; confirm which code your account expects in test mode before going live. The African Phone Number Formatter shows the network for any Ugandan number, and account_number takes the country code: 256772123456.

Never pay twice

<?php

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

use App\Models\FlutterwavePayout;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use InvalidArgumentException;

class FlutterwavePayouts
{
    public function __construct(private readonly FlutterwaveTransfers $flutterwave)
    {
    }

    /** Create the payout record. Its reference is fixed for life and never reused. Nothing is sent yet. */
    public function create(int $amount, string $phone, string $network, string $name, string $narration): FlutterwavePayout
    {
        if ($amount <= 0) {
            throw new InvalidArgumentException('Payout amounts are whole shillings, more than zero');
        }
        if (! preg_match('/^256\d{9}$/', $phone)) {
            throw new InvalidArgumentException('Phone must look like 256772123456');
        }
        if (! in_array($network, ['MTN', 'Airtel'], true)) {
            throw new InvalidArgumentException('Network must be MTN or Airtel');
        }

        return FlutterwavePayout::create([
            'reference' => 'payout-'.Str::uuid(), 'amount' => $amount, 'currency' => 'UGX', 'phone' => $phone, 'network' => $network,
            'name' => mb_substr($name, 0, 100), 'narration' => mb_substr($narration, 0, 100), 'status' => 'new',
        ]);
    }

    /** Only payouts that aren't final can change, whatever an old copy of the model says. */
    private function open(FlutterwavePayout $payout): Builder
    {
        return FlutterwavePayout::whereKey($payout->id)->whereNotIn('status', FlutterwavePayout::FINAL);
    }

    /** Sends a payout once. Only "new" is ever sent, and only one process can claim it. */
    public function send(FlutterwavePayout $payout): FlutterwavePayout
    {
        $claimed = FlutterwavePayout::whereKey($payout->id)->where('status', 'new')->update(['status' => 'sending', 'updated_at' => now()]);
        if ($claimed !== 1) { // saved BEFORE the network call
            return $payout->fresh(); // sent, or maybe sent: never again
        }
        $sending = FlutterwavePayout::whereKey($payout->id)->where('status', 'sending');
        try {
            $data = $this->flutterwave->create($payout);
            $sending->update(['status' => 'pending', 'transfer_id' => (int) $data['id']]); // queued is not paid
        } catch (FlutterwaveTransferException $e) {
            $sending->update($e->ambiguous
                ? ['status' => 'unknown', 'reason' => 'NO_RESPONSE'] // may exist, and we have no id: wait for the webhook
                : ['status' => 'failed', 'reason' => mb_substr((string) ($e->body['message'] ?? "HTTP_{$e->status}"), 0, 190)]);
        }

        return $payout->fresh();
    }

    /** Applies a transfer fetched from Flutterwave to its payout. */
    public function settle(FlutterwavePayout $payout, array $transfer): FlutterwavePayout
    {
        if (($transfer['reference'] ?? null) !== $payout->reference || ! isset($transfer['id'])) {
            return $payout->fresh(); // not this payout
        }
        $id = (int) $transfer['id'];
        $amount = $transfer['amount'] ?? null;
        if (! is_numeric($amount) || (float) $amount !== (float) $payout->amount || ($transfer['currency'] ?? null) !== $payout->currency) {
            $this->open($payout)->update(['status' => 'review', 'transfer_id' => $id, 'reason' => 'TRANSFER '.($transfer['amount'] ?? '?').' '.($transfer['currency'] ?? '?')]);

            return $payout->fresh();
        }
        $status = strtoupper((string) ($transfer['status'] ?? ''));
        if ($status === 'SUCCESSFUL') {
            $this->open($payout)->update(['status' => 'paid', 'transfer_id' => $id, 'reason' => null]);
        } elseif ($status === 'FAILED') {
            $this->open($payout)->update(['status' => 'failed', 'transfer_id' => $id, 'reason' => mb_substr((string) ($transfer['complete_message'] ?? '') ?: 'FAILED', 0, 190)]);
        } else { // NEW or PENDING
            $this->open($payout)->update(['status' => 'pending', 'transfer_id' => $id]);
        }

        return $payout->fresh();
    }

    /** Scheduled check. Only payouts with a transfer id can be looked up; the rest wait for the webhook or a person. */
    public function check(FlutterwavePayout $payout): FlutterwavePayout
    {
        if (! in_array($payout->status, ['pending', 'unknown'], true) || ! $payout->transfer_id) {
            return $payout;
        }
        try {
            $transfer = $this->flutterwave->get($payout->transfer_id);
        } catch (FlutterwaveTransferException) {
            return $payout; // Flutterwave unreachable: try again on the next run
        }

        return $this->settle($payout, $transfer);
    }

    /** Handles a transfer.completed webhook whose verif-hash you already checked. The body only says which transfer to fetch. */
    public function handleWebhook(array $body): ?FlutterwavePayout
    {
        $data = is_array($body['data'] ?? null) ? $body['data'] : [];
        if (($body['event'] ?? null) !== 'transfer.completed' || empty($data['id']) || ! is_string($data['reference'] ?? null)) {
            return null;
        }
        $payout = FlutterwavePayout::where('reference', $data['reference'])->first();
        if ($payout === null) {
            return null; // not ours
        }
        if ($payout->transfer_id && $payout->transfer_id !== (int) $data['id']) {
            return null; // not the transfer Flutterwave gave us for this payout
        }
        $transfer = $this->flutterwave->get($data['id']); // throws if Flutterwave is unreachable: answer 500 so it retries

        return $this->settle($payout, $transfer);
    }
}

The rules:

  1. Create the reference once, and save it before sending. It identifies the payout in the webhook.
  2. Claim before sending. The conditional UPDATE ... WHERE status = 'new' lets only one request or queue worker send a payout.
  3. Never resend automatically. An unknown or stuck sending payout waits for its webhook, or for a person to check the dashboard. If it really never happened, create a new payout with a new reference, on purpose.
  4. Only the looked-up transfer counts. The webhook body only says which transfer to fetch; its reference, amount and currency must match the payout, and once a payout has a transfer id, no other id can settle it.
  5. Final means final. paid, failed and review never change: every update is limited to payouts that are still open.

To pay someone, from your own authenticated admin code:

<?php

use App\Services\FlutterwavePayouts;

$payouts = app(FlutterwavePayouts::class);
$payouts->send($payouts->create(50000, '256772123456', 'MTN', 'Jane Namuli', 'Seller payout 77')); // pending, unknown or failed

The webhook

<?php

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

use App\Services\FlutterwavePayouts;
use App\Services\FlutterwaveTransferException;
use App\Services\FlutterwaveTransfers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;

class FlutterwaveTransferWebhookController extends Controller
{
    public function __invoke(Request $request, FlutterwavePayouts $payouts): Response
    {
        if (! FlutterwaveTransfers::hashOk($request->header('verif-hash'), (string) config('services.flutterwave.secret_hash'))) {
            return response('', 401);
        }
        $body = $request->json()->all();
        try {
            $payouts->handleWebhook($body);
        } catch (FlutterwaveTransferException $e) {
            Log::warning('Flutterwave transfer webhook not processed', ['reference' => $body['data']['reference'] ?? null, 'error' => $e->getMessage()]);

            return response('', 500); // Flutterwave retries, if retries are on in your dashboard
        }

        return response('', 200);
    }
}
<?php

// routes/web.php (add to the end of the file; it already imports Route)
use App\Http\Controllers\FlutterwaveTransferWebhookController;

// CSRF is switched off for this route in bootstrap/app.php: verif-hash protects it.
Route::post('/webhooks/flutterwave', FlutterwaveTransferWebhookController::class);

Flutterwave can’t send a CSRF token, so exclude the route in bootstrap/app.php:

<?php

// bootstrap/app.php, inside ->withMiddleware(...)
$middleware->validateCsrfTokens(except: ['webhooks/flutterwave']);

Flutterwave sends every event to the one webhook URL in your dashboard. If you already receive charge.completed webhooks (the mobile money guide uses /api/webhooks/flutterwave), don’t add a second route: in that route, after the verif-hash check, also call app(FlutterwavePayouts::class)->handleWebhook($request->json()->all()). It ignores anything that isn’t transfer.completed.

Settle payouts on a schedule

<?php

// routes/console.php (the whole file; keep any commands you already have, and don't repeat a use line)
use App\Models\FlutterwavePayout;
use App\Services\FlutterwavePayouts;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;

Artisan::command('flutterwave-payouts:check', function (FlutterwavePayouts $payouts) {
    // Payouts with a transfer id, in flight for over a minute: ask Flutterwave.
    FlutterwavePayout::whereIn('status', ['pending', 'unknown'])->whereNotNull('transfer_id')
        ->where('updated_at', '<=', now()->subMinute())->orderBy('id')->limit(100)->get()
        ->each(fn (FlutterwavePayout $p) => $payouts->check($p));
    // No id and no webhook after an hour: a person must check the dashboard. Never resend these automatically.
    $stuck = FlutterwavePayout::whereIn('status', ['sending', 'unknown'])->whereNull('transfer_id')
        ->where('updated_at', '<=', now()->subHour())->pluck('reference');
    foreach ($stuck as $reference) {
        $this->warn("Check {$reference} in the Flutterwave dashboard: no answer and no webhook.");
    }
})->purpose('Settle Flutterwave payouts in flight');

Schedule::command('flutterwave-payouts:check')->everyFiveMinutes()->withoutOverlapping();

The scheduler only runs if cron calls it: * * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1. Send the warnings somewhere you’ll see them, such as a log channel that alerts you.

Test it without calling Flutterwave

This test fakes Flutterwave with Http::fake(). It checks that a payout is saved before it’s sent and sent only once, that the webhook’s verif-hash is checked and its body never trusted, that a lost response leads to unknown and never a second transfer, that another payout’s transfer can’t settle it, that a wrong amount goes to review, and that final results never change.

<?php

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

use App\Models\FlutterwavePayout;
use App\Services\FlutterwavePayouts;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use InvalidArgumentException;
use Tests\TestCase;

class FlutterwavePayoutsTest extends TestCase
{
    use RefreshDatabase;

    private const HASH = 'test-secret-hash-of-at-least-32-characters';

    /** @var array<int, array> transfers by id, the way Flutterwave keeps them */
    private array $flw = [];

    private int $nextId = 85623;

    private int $posts = 0;

    /** The status each payout had in our database when Flutterwave received it. */
    private array $statusAtSend = [];

    /** Next POST /v3/transfers: 'ok', 'timeout' (created, but the answer is lost) or 400. */
    private string|int $next = 'ok';

    private bool $down = false;

    protected function setUp(): void
    {
        parent::setUp();
        config(['services.flutterwave' => ['base_url' => 'https://flw.test', 'secret_key' => 'FLWSECK_TEST-x', 'secret_hash' => self::HASH]]);
        Http::preventStrayRequests();
        Http::fake(function (Request $r) {
            $path = parse_url($r->url(), PHP_URL_PATH);
            if ($r->method() === 'POST' && $path === '/v3/transfers') {
                $this->posts++;
                $this->statusAtSend[] = FlutterwavePayout::where('reference', $r['reference'])->value('status');
                $mode = $this->next;
                $this->next = 'ok';
                if ($mode === 400) {
                    return Http::response(['status' => 'error', 'message' => 'Insufficient balance', 'data' => null], 400);
                }
                $t = ['id' => $this->nextId++, 'reference' => $r['reference'], 'amount' => $r['amount'], 'currency' => $r['currency'],
                    'status' => 'NEW', 'complete_message' => ''];
                $this->flw[$t['id']] = $t;

                return $mode === 'timeout' ? Http::response(null, 503) : Http::response(['status' => 'success', 'message' => 'Transfer Queued Successfully', 'data' => $t]);
            }
            if ($r->method() === 'GET' && str_starts_with($path, '/v3/transfers/')) {
                $t = $this->flw[(int) basename($path)] ?? null;
                if ($this->down) {
                    return Http::response(null, 503);
                }

                return $t ? Http::response(['status' => 'success', 'message' => 'Transfer fetched', 'data' => $t])
                    : Http::response(['status' => 'error', 'message' => 'No transfer found', 'data' => null], 404);
            }

            return Http::response(null, 500);
        });
    }

    private function payout(): FlutterwavePayout
    {
        return app(FlutterwavePayouts::class)->create(50000, '256772123456', 'MTN', 'Jane Namuli', 'Seller payout 77');
    }

    private function byRef(string $reference): array
    {
        return collect($this->flw)->firstWhere('reference', $reference);
    }

    private function webhook(array $data, string $hash = self::HASH)
    {
        return $this->postJson('/webhooks/flutterwave', ['event' => 'transfer.completed', 'data' => $data], ['verif-hash' => $hash]);
    }

    public function test_saved_before_sending_sent_once_and_paid_from_the_looked_up_transfer(): void
    {
        $payouts = app(FlutterwavePayouts::class);
        $p = $this->payout();
        $sent = $payouts->send($p);
        $this->assertSame(['pending', 85623], [$sent->status, $sent->transfer_id]);
        $payouts->send($p); // a double click, with the old copy
        $payouts->send($sent);
        $this->assertSame([1, ['sending']], [$this->posts, $this->statusAtSend]);
        $req = Http::recorded(fn ($r) => $r->method() === 'POST')->first()[0];
        $this->assertSame(['MTN', '256772123456', 50000, 'UGX', $p->reference], [$req['account_bank'], $req['account_number'], $req['amount'], $req['currency'], $req['reference']]);
        $this->assertSame('Bearer FLWSECK_TEST-x', $req->header('Authorization')[0]);

        $this->flw[85623]['status'] = 'SUCCESSFUL';
        $this->webhook(['id' => 85623, 'reference' => $p->reference, 'status' => 'SUCCESSFUL'])->assertOk();
        $this->assertSame('paid', $p->fresh()->status);
    }

    public function test_the_webhook_checks_verif_hash_and_never_trusts_the_body(): void
    {
        $p = app(FlutterwavePayouts::class)->send($this->payout());
        $data = ['id' => $p->transfer_id, 'reference' => $p->reference, 'status' => 'SUCCESSFUL', 'amount' => 50000];
        $this->webhook($data, 'wrong-hash-wrong-hash-wrong-hash-xx')->assertStatus(401);
        $this->postJson('/webhooks/flutterwave', ['event' => 'transfer.completed', 'data' => $data])->assertStatus(401);
        $this->webhook($data)->assertOk();
        $this->assertSame('pending', $p->fresh()->status); // Flutterwave still says NEW
        $this->flw[$p->transfer_id]['status'] = 'SUCCESSFUL';
        $this->postJson('/webhooks/flutterwave', ['event' => 'charge.completed', 'data' => $data], ['verif-hash' => self::HASH])->assertOk();
        $this->webhook(['id' => $p->transfer_id, 'reference' => 'not-ours'])->assertOk();
        $this->assertSame('pending', $p->fresh()->status);
        config(['services.flutterwave.secret_hash' => 'short']); // a weak hash refuses everything
        $this->webhook($data, 'short')->assertStatus(401);
    }

    public function test_no_response_means_unknown_never_a_second_transfer_and_the_webhook_settles_it(): void
    {
        $payouts = app(FlutterwavePayouts::class);
        $this->next = 'timeout';
        $p = $payouts->send($this->payout());
        $this->assertSame(['unknown', null, 'NO_RESPONSE'], [$p->status, $p->transfer_id, $p->reason]);
        $payouts->send($p);
        $this->assertSame('unknown', $payouts->check($p)->status); // no id: nothing to look up, nothing resent
        $this->assertSame(1, $this->posts);

        $t = $this->byRef($p->reference);
        $this->flw[$t['id']]['status'] = 'SUCCESSFUL';
        $this->webhook(['id' => $t['id'], 'reference' => $p->reference])->assertOk(); // the webhook carries the id
        $this->assertSame(['paid', $t['id']], [$p->fresh()->status, $p->fresh()->transfer_id]);
    }

    public function test_another_payouts_transfer_cannot_settle_a_payout(): void
    {
        $payouts = app(FlutterwavePayouts::class);
        $other = $payouts->send($this->payout());
        $this->flw[$other->transfer_id]['status'] = 'SUCCESSFUL';
        $this->next = 'timeout';
        $unknown = $payouts->send($this->payout());
        // A body naming the unknown payout's reference but the other payout's transfer id:
        $this->webhook(['id' => $other->transfer_id, 'reference' => $unknown->reference])->assertOk();
        $this->assertSame('unknown', $unknown->fresh()->status);
        // And a payout that has an id is only settled by that id:
        $mine = $payouts->send($this->payout());
        $this->webhook(['id' => $other->transfer_id, 'reference' => $mine->reference])->assertOk();
        $this->assertSame('pending', $mine->fresh()->status);
    }

    public function test_failed_keeps_the_reason_and_final_means_final(): void
    {
        $payouts = app(FlutterwavePayouts::class);
        $p = $payouts->send($this->payout());
        $this->assertSame('pending', $payouts->check($p)->status); // still NEW
        $this->flw[$p->transfer_id] = ['status' => 'FAILED', 'complete_message' => 'Recipient wallet not found'] + $this->flw[$p->transfer_id];
        $failed = $payouts->check($p);
        $this->assertSame(['failed', 'Recipient wallet not found'], [$failed->status, $failed->reason]);
        $this->flw[$p->transfer_id]['status'] = 'SUCCESSFUL'; // even if a later lookup disagreed...
        $this->webhook(['id' => $p->transfer_id, 'reference' => $p->reference])->assertOk();
        $this->assertSame('failed', $payouts->settle($p, $this->flw[$p->transfer_id])->status); // ...through an old copy too

        $q = $payouts->send($this->payout());
        $this->flw[$q->transfer_id]['status'] = 'SUCCESSFUL';
        $this->assertSame('paid', $payouts->check($q)->status);
        $this->flw[$q->transfer_id]['status'] = 'FAILED';
        $this->assertSame('paid', $payouts->settle($q, $this->flw[$q->transfer_id])->status);
    }

    public function test_a_different_amount_or_currency_goes_to_review(): void
    {
        $payouts = app(FlutterwavePayouts::class);
        $p = $payouts->send($this->payout());
        $this->flw[$p->transfer_id] = ['status' => 'SUCCESSFUL', 'amount' => 5000] + $this->flw[$p->transfer_id];
        $this->assertSame('review', $payouts->check($p)->status);
        $q = $payouts->send($this->payout());
        $this->flw[$q->transfer_id] = ['status' => 'SUCCESSFUL', 'currency' => 'KES'] + $this->flw[$q->transfer_id];
        $this->assertSame('review', $payouts->check($q)->status);
        $r = $payouts->send($this->payout());
        $this->flw[$r->transfer_id] = ['status' => 'SUCCESSFUL', 'amount' => 50000.5] + $this->flw[$r->transfer_id];
        $this->assertSame('review', $payouts->check($r)->status);
    }

    public function test_a_clear_refusal_fails_at_once(): void
    {
        $this->next = 400;
        $p = app(FlutterwavePayouts::class)->send($this->payout());
        $this->assertSame(['failed', 'Insufficient balance'], [$p->status, $p->reason]);
    }

    public function test_an_unreachable_flutterwave_changes_nothing_and_the_webhook_asks_for_a_retry(): void
    {
        $payouts = app(FlutterwavePayouts::class);
        $p = $payouts->send($this->payout());
        $this->down = true;
        $this->assertSame('pending', $payouts->check($p)->status);
        $this->webhook(['id' => $p->transfer_id, 'reference' => $p->reference])->assertStatus(500);
    }

    public function test_the_command_checks_pending_payouts_and_reports_stuck_ones(): void
    {
        $payouts = app(FlutterwavePayouts::class);
        $a = $payouts->send($this->payout());
        $this->next = 'timeout';
        $b = $payouts->send($this->payout());
        $this->flw[$a->transfer_id]['status'] = 'SUCCESSFUL';
        FlutterwavePayout::query()->update(['updated_at' => now()->subHours(2)]);
        $this->artisan('flutterwave-payouts:check')
            ->expectsOutputToContain("Check {$b->reference} in the Flutterwave dashboard")
            ->assertSuccessful();
        $this->assertSame(['paid', 'unknown', 2], [$a->fresh()->status, $b->fresh()->status, $this->posts]); // b was not resent
    }

    public function test_bad_input_is_refused(): void
    {
        $payouts = app(FlutterwavePayouts::class);
        foreach ([[0, '256772123456', 'MTN'], [500, '0772123456', 'MTN'], [500, '256772123456', 'MPESA']] as [$amount, $phone, $network]) {
            try {
                $payouts->create($amount, $phone, $network, 'x', 'x');
                $this->fail("$amount $phone $network was accepted");
            } catch (InvalidArgumentException) {
                $this->addToAssertionCount(1);
            }
        }
        $this->assertNotSame($this->payout()->reference, $this->payout()->reference);
    }
}
php artisan test --filter FlutterwavePayoutsTest

To try the requests by hand first, use the Payout Request Builder.

Common errors and fixes

Every status and error type, with what your code should do, is in the Flutterwave error reference.

Symptom Likely cause Fix
Transfer refused with a balance message Your Flutterwave balance is too low Fund the balance before a payout run
Payout failed with a wallet message The number isn’t an active wallet on that network, or the wrong operator code Check the number and account_bank
419 on the webhook CSRF is still on for the route Add webhooks/flutterwave to validateCsrfTokens(except: ...)
401 on the webhook FLW_SECRET_HASH differs from the dashboard, or is shorter than 32 characters Use exactly the same long value in both places
Payout stays unknown No response, and no webhook yet Wait for the webhook; check the dashboard. Never resend with a new reference
review The transfer’s amount or currency differs from the payout Look at it in the dashboard

Going to production

  • Complete Flutterwave’s business verification, switch to your live secret key, and set a new live secret hash.
  • Protect the code that creates payouts. Anyone who can call it can send your money. Require an authenticated admin, set limits per payout and per day, and log who created each one.
  • Send one small live payout to an MTN number and one to an Airtel number, to confirm the operator codes.
  • Reconcile daily against your Flutterwave balance history, especially payouts that went through unknown.

Comparing payout APIs? See mobile money payouts. Want this done for you, with MTN MoMo, M-Pesa B2C and Paystack payouts too? The Laravel starter kit includes them.

Using Django? See Flutterwave payouts in Django.

Frequently asked questions

How do I send money to an MTN or Airtel wallet with Flutterwave in Laravel?

POST /v3/transfers with account_bank set to the mobile money operator code, account_number set to the number with its country code (256772123456), amount, currency UGX, beneficiary_name and your own reference, saved in your database first. Flutterwave answers "Transfer Queued Successfully" with status NEW; the result comes later.

My Flutterwave transfer request timed out. Should Laravel send it again?

No. Flutterwave may have created it, and you don't have its id to check. Mark the payout unknown and wait for the transfer.completed webhook, which carries your reference and the transfer id, or check the dashboard before deciding anything.

Why does my Flutterwave webhook return 419 in Laravel?

CSRF protection. Flutterwave can't send a CSRF token, so exclude the webhook route in bootstrap/app.php with validateCsrfTokens(except: [...]). The verif-hash header protects it instead.

Is the Flutterwave transfer webhook only sent for successful transfers?

No. Flutterwave sends transfer.completed both when a transfer succeeds and when it fails. Look the transfer up with GET /v3/transfers/{id} and settle from that.

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.