This guide sends money to MTN MoMo wallets from a Laravel app with the Disbursements API: refunds, payouts to sellers or drivers, salaries. You’ll build:
- a
MomoDisbursementsservice: a cached token, transfers, transfer status and balance, - a
payoutstable and aPayoutsservice that never sends the same payout twice, even after a timeout, a crash or a double click, - a scheduled
payouts:checkcommand that settles payouts and safely resends only the ones MTN never received, - a feature test that runs every case without calling MTN.
Every code block was run in a fresh Laravel 13 app, and the test at the end passes. The Node.js version of this guide explains how Disbursements differs from Collections in more detail.
How a payout works
Disbursements has no approval step: there is no PIN prompt, so the money leaves your disbursement account as soon as MTN accepts the request. A duplicate request sends the money twice. So:
- You save the payout with its own
X-Reference-Id(a UUID v4) before calling MTN. POST /disbursement/v1_0/transferanswers202 Accepted. That is not a payout yet.GET /disbursement/v1_0/transfer/{X-Reference-Id}saysSUCCESSFUL,FAILEDorPENDING. That is the source of truth.- After a timeout, you look the payout up by your own reference. Only a
404proves MTN never received it; then you send it again with the same reference.
new ──send──▶ sending ──202──▶ pending ──status──▶ paid / failed / review
│
├──400 (clear refusal)──▶ failed
└──timeout / 5xx──▶ unknown ──status 404──▶ sent again, same reference
└──status found──▶ pending / paid / failed
Before you start
- On momodeveloper.mtn.com, subscribe to the Disbursements product and copy its primary key.
- Create a sandbox API user and key with that Disbursements key. The MoMo Request Builder generates the two requests; switch it to Disbursements first.
- Add the values to
.envand never commit it:
MOMO_BASE_URL=https://sandbox.momodeveloper.mtn.com
MOMO_TARGET_ENV=sandbox
MOMO_CURRENCY=EUR
MOMO_DISBURSEMENT_SUBSCRIPTION_KEY=your-disbursements-subscription-key
MOMO_DISBURSEMENT_API_USER=your-api-user-uuid
MOMO_DISBURSEMENT_API_KEY=your-api-key
Map them in config/services.php, and always read them through config():
<?php
// config/services.php
return [
// ...your existing services
'momo_disbursement' => [
'base_url' => env('MOMO_BASE_URL', 'https://sandbox.momodeveloper.mtn.com'),
'target_env' => env('MOMO_TARGET_ENV', 'sandbox'),
'currency' => env('MOMO_CURRENCY', 'EUR'), // the sandbox only accepts EUR
'subscription_key' => env('MOMO_DISBURSEMENT_SUBSCRIPTION_KEY'),
'api_user' => env('MOMO_DISBURSEMENT_API_USER'),
'api_key' => env('MOMO_DISBURSEMENT_API_KEY'),
],
];
The payouts table
php artisan make:model Payout -m
<?php
// database/migrations/xxxx_xx_xx_create_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('payouts', function (Blueprint $table) {
$table->id();
$table->uuid('reference_id')->unique(); // MTN's X-Reference-Id, fixed for life
$table->decimal('amount', 12, 2);
$table->string('currency', 3);
$table->string('phone', 15); // 256772123456
$table->string('external_id', 64); // your own id: an order, a seller, a refund
$table->string('note', 160);
$table->string('status', 10)->default('new'); // new | sending | pending | unknown | paid | failed | review
$table->string('reason')->nullable();
$table->string('financial_transaction_id')->nullable();
$table->timestamps();
$table->index(['status', 'updated_at']);
});
}
public function down(): void
{
Schema::dropIfExists('payouts');
}
};
<?php
// app/Models/Payout.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Payout extends Model
{
/** Final: nothing changes them again. review = MTN reported a different amount: a person must look. */
public const FINAL = ['paid', 'failed', 'review'];
protected $fillable = ['reference_id', 'amount', 'currency', 'phone', 'external_id', 'note', 'status', 'reason', 'financial_transaction_id'];
protected $casts = ['amount' => 'decimal:2'];
}
Run php artisan migrate.
The Disbursements service
The service turns network failures and 5xx answers into an ambiguous MomoException: the transfer may have been created. A 400 is a clear refusal, so nothing was sent.
<?php
// app/Services/MomoException.php
namespace App\Services;
use RuntimeException;
class MomoException extends RuntimeException
{
/** ambiguous: the transfer may exist. Look it up; never resend with a new reference. */
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/MomoDisbursements.php
namespace App\Services;
use App\Models\Payout;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class MomoDisbursements
{
private function cfg(string $key): string
{
return (string) config("services.momo_disbursement.{$key}");
}
private function url(string $path): string
{
return rtrim($this->cfg('base_url'), '/').$path;
}
/** Network errors become ambiguous MomoExceptions: the request may have reached MTN. */
private function call(callable $send): Response
{
try {
return $send();
} catch (ConnectionException $e) {
throw new MomoException("MoMo unreachable: {$e->getMessage()}", ambiguous: true);
}
}
public function token(): string
{
return Cache::remember('momo_disbursement_token', 3000, function () {
$res = $this->call(fn () => Http::timeout(30)
->withBasicAuth($this->cfg('api_user'), $this->cfg('api_key'))
->withHeaders(['Ocp-Apim-Subscription-Key' => $this->cfg('subscription_key')])
->withBody('', 'application/json') // Content-Length: 0, or MoMo answers 411
->post($this->url('/disbursement/token/')));
if (! $res->successful() || ! $res->json('access_token')) {
throw new MomoException("Disbursement token failed: {$res->status()}", $res->status());
}
return (string) $res->json('access_token');
});
}
private function http(): PendingRequest
{
return Http::timeout(30)->withToken($this->token())->withHeaders([
'Ocp-Apim-Subscription-Key' => $this->cfg('subscription_key'),
'X-Target-Environment' => $this->cfg('target_env'),
]);
}
/** Sends money to a MoMo wallet. Returns on 202: accepted, NOT paid yet. */
public function transfer(Payout $payout): void
{
$res = $this->call(fn () => $this->http()->withHeaders(['X-Reference-Id' => $payout->reference_id])
->post($this->url('/disbursement/v1_0/transfer'), [
'amount' => rtrim(rtrim(number_format((float) $payout->amount, 2, '.', ''), '0'), '.'), // "5000", "12.5"
'currency' => $payout->currency,
'externalId' => $payout->external_id,
'payee' => ['partyIdType' => 'MSISDN', 'partyId' => $payout->phone],
'payerMessage' => mb_substr($payout->note, 0, 160),
'payeeNote' => mb_substr($payout->note, 0, 160),
]));
if (in_array($res->status(), [202, 409], true)) {
return; // 409: this X-Reference-Id was already received, so the first request stands
}
$body = (array) $res->json();
throw new MomoException("Transfer failed: {$res->status()} ".($body['code'] ?? ''), $res->status(), $res->serverError(), $body);
}
/** The source of truth, or null if MTN never received this reference. */
public function getTransfer(string $referenceId): ?array
{
$res = $this->call(fn () => $this->http()->get($this->url("/disbursement/v1_0/transfer/{$referenceId}")));
if ($res->status() === 404) {
return null;
}
if (! $res->successful()) {
throw new MomoException("Transfer status failed: {$res->status()}", $res->status(), ambiguous: true);
}
return (array) $res->json();
}
/** ['availableBalance' => '1000', 'currency' => 'EUR'] for your disbursement account. */
public function balance(): array
{
$res = $this->call(fn () => $this->http()->get($this->url('/disbursement/v1_0/account/balance')));
if (! $res->successful()) {
throw new MomoException("Balance failed: {$res->status()}", $res->status());
}
return (array) $res->json();
}
}
Phone numbers go in international format without the +, for example 256772123456. The amount goes as a string without trailing zeros: "5000" or "12.5". The African Phone Number Formatter shows the right format for each country.
Never pay twice
<?php
// app/Services/Payouts.php
namespace App\Services;
use App\Models\Payout;
use Illuminate\Support\Str;
class Payouts
{
public function __construct(private readonly MomoDisbursements $momo)
{
}
/** Create the payout record. Its reference is fixed for life. Nothing is sent yet. */
public function create(string $amount, string $phone, string $externalId, string $note): Payout
{
if (! preg_match('/^\d+(\.\d{1,2})?$/', $amount) || (float) $amount <= 0) {
throw new \InvalidArgumentException('Amount must be positive, with at most 2 decimals');
}
if (! preg_match('/^\d{9,15}$/', $phone)) {
throw new \InvalidArgumentException('Phone must be international without +, e.g. 256772123456');
}
return Payout::create([
'reference_id' => (string) Str::uuid(), 'amount' => $amount, 'currency' => config('services.momo_disbursement.currency'),
'phone' => $phone, 'external_id' => $externalId, 'note' => mb_substr($note, 0, 160), 'status' => 'new',
]);
}
/** Atomically move a payout between statuses. False when another process got there first. */
private function claim(Payout $payout, array $from, string $to): bool
{
return Payout::whereKey($payout->id)->whereIn('status', $from)->update(['status' => $to, 'updated_at' => now()]) === 1;
}
/** Sends a payout once. Only "new" is ever sent, and only one process can claim it. */
public function send(Payout $payout): Payout
{
if (! $this->claim($payout, ['new'], 'sending')) { // saved BEFORE the network call
return $payout->fresh(); // sent, or maybe sent: never again
}
try {
$this->momo->transfer($payout);
$payout->update(['status' => 'pending']);
} catch (MomoException $e) {
$payout->update($e->ambiguous
? ['status' => 'unknown', 'reason' => 'NO_RESPONSE'] // may have gone through: check() decides
: ['status' => 'failed', 'reason' => $e->body['code'] ?? "HTTP_{$e->status}"]);
}
return $payout->fresh();
}
/** Settles a payout from MTN's answer. Safe to run as often as you like. */
public function check(Payout $payout): Payout
{
if (! in_array($payout->status, ['sending', 'pending', 'unknown'], true)) {
return $payout;
}
try {
$result = $this->momo->getTransfer($payout->reference_id);
} catch (MomoException) {
return $payout; // MTN unreachable: try again on the next run
}
if ($result === null) {
// MTN never received it. Only now is it safe to send again, with the SAME reference.
if ($this->claim($payout, ['sending', 'unknown'], 'new')) {
return $this->send($payout->fresh());
}
return $payout->fresh();
}
$status = strtoupper((string) ($result['status'] ?? ''));
// Only an open payout can be settled: a late or repeated answer never undoes a final one.
$open = Payout::whereKey($payout->id)->whereNotIn('status', Payout::FINAL);
if ($status === 'SUCCESSFUL') {
$sameAmount = round((float) ($result['amount'] ?? -1), 2) === round((float) $payout->amount, 2)
&& ($result['currency'] ?? '') === $payout->currency;
$open->update($sameAmount
? ['status' => 'paid', 'financial_transaction_id' => $result['financialTransactionId'] ?? null]
: ['status' => 'review', 'reason' => 'AMOUNT_MISMATCH '.($result['amount'] ?? '?').' '.($result['currency'] ?? '?')]);
} elseif ($status === 'FAILED') {
$reason = is_array($result['reason'] ?? null) ? ($result['reason']['code'] ?? null) : ($result['reason'] ?? null);
$open->update(['status' => 'failed', 'reason' => $reason ?: 'FAILED']);
} else {
$open->update(['status' => 'pending']); // still PENDING at MTN
}
return $payout->fresh();
}
/** Sends new payouts one by one, and stops before the disbursement account runs dry. */
public function run(iterable $payouts): void
{
$left = (float) ($this->momo->balance()['availableBalance'] ?? 0);
foreach ($payouts as $payout) {
if ($payout->status !== 'new' || (float) $payout->amount > $left) {
continue; // too big for what's left: stays "new" for the next run, after you top up
}
if ($this->send($payout)->status !== 'failed') {
$left -= (float) $payout->amount;
}
}
}
}
The rules:
- Create the
X-Reference-Idonce, with the payout, and save it before sending. It is the transfer’s identity at MTN, and every retry reuses it. - Claim before sending.
claim()is a conditionalUPDATE ... WHERE status IN (...). If two requests or queue workers race, only one changes the row, and only that one sends. - After a timeout, ask before resending. If MTN has the transfer, wait for its result. Only a
404lets you send again, with the same reference. If MTN already has it after all, it answers409, and the first request stands. - Final means final.
paid,failedandreviewnever change, even through an old copy of the model: every update is limited to payouts that are still open. - Check the amount. A
SUCCESSFULtransfer for a different amount or currency goes toreviewfor a person, never topaid.
To pay someone:
<?php
// In your own authenticated admin controller or job
use App\Services\Payouts;
$payouts = app(Payouts::class);
$payout = $payouts->create('5000', '256772123456', 'order-7', 'Refund for order 7');
$payouts->send($payout); // pending, unknown or failed. The scheduled check settles the rest.
Settle payouts on a schedule
<?php
// routes/console.php
use App\Models\Payout;
use App\Services\Payouts;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
// Settle payouts in flight. A minute old, so we never look up a payout that is still being sent.
Artisan::command('payouts:check', function (Payouts $payouts) {
Payout::whereIn('status', ['sending', 'pending', 'unknown'])->where('updated_at', '<=', now()->subMinute())
->orderBy('id')->limit(100)->get()
->each(fn (Payout $p) => $payouts->check($p));
// Payouts that check() found MTN never received were resent, with their own reference.
})->purpose('Settle MoMo payouts in flight');
Schedule::command('payouts:check')->everyMinute()->withoutOverlapping();
The scheduler only runs if cron calls it, so add * * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1. A payout that MTN never received is resent by check() itself, with its original reference.
To send a batch, run() reads the balance first and leaves payouts it can’t afford as new for the next run, after you top up.
Test it without calling MTN
This test fakes MTN with Http::fake(). The fake remembers transfers by X-Reference-Id, the way MTN does, so it can check that a payout is saved before it’s sent and sent only once, that a timeout leads to a lookup and never a second transfer, that a resend uses the same reference, that a different amount goes to review, and that final results never change.
<?php
// tests/Feature/PayoutsTest.php
namespace Tests\Feature;
use App\Models\Payout;
use App\Services\Payouts;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class PayoutsTest extends TestCase
{
use RefreshDatabase;
/** @var array<string, array> transfers by X-Reference-Id, the way MTN keeps them */
private array $mtn = [];
private int $posts = 0;
/** Next POST /transfer: 'ok', 'timeout_before' (MTN never got it), 'timeout_after' (MTN got it) or 400. */
private string|int $next = 'ok';
/** Next GET status answers 404 even if MTN has it (it can take a moment to register). */
private bool $hideOnce = false;
protected function setUp(): void
{
parent::setUp();
config(['services.momo_disbursement' => [
'base_url' => 'https://momo.test', 'target_env' => 'sandbox', 'currency' => 'EUR',
'subscription_key' => 'disb-key', 'api_user' => 'user', 'api_key' => 'secret',
]]);
Http::preventStrayRequests();
Http::fake(function (Request $r) {
$path = parse_url($r->url(), PHP_URL_PATH);
if ($path === '/disbursement/token/') {
return Http::response(['access_token' => 'disb-token', 'expires_in' => 3600]);
}
if ($path === '/disbursement/v1_0/account/balance') {
return Http::response(['availableBalance' => '7000', 'currency' => 'EUR']);
}
if ($r->method() === 'POST' && $path === '/disbursement/v1_0/transfer') {
$this->posts++;
$ref = $r->header('X-Reference-Id')[0];
$mode = $this->next;
$this->next = 'ok';
if ($mode === 400) {
return Http::response(['code' => 'PAYEE_NOT_FOUND'], 400);
}
if ($mode === 'timeout_before') {
return Http::response(null, 503);
}
if (isset($this->mtn[$ref])) {
return Http::response(['code' => 'RESOURCE_ALREADY_EXIST'], 409);
}
$this->mtn[$ref] = ['status' => 'PENDING', 'amount' => $r['amount'], 'currency' => $r['currency']];
return $mode === 'timeout_after' ? Http::response(null, 504) : Http::response(null, 202);
}
$t = $this->mtn[basename($path)] ?? null;
if ($this->hideOnce) {
$this->hideOnce = false;
$t = null;
}
return $t ? Http::response($t) : Http::response(['code' => 'RESOURCE_NOT_FOUND'], 404);
});
}
private function payout(string $amount = '5000'): Payout
{
return app(Payouts::class)->create($amount, '256772123456', 'order-7', 'Refund for order 7');
}
public function test_saved_before_sending_and_sent_once(): void
{
$payout = $this->payout();
$this->assertSame('new', $payout->status);
$sent = app(Payouts::class)->send($payout);
$this->assertSame('pending', $sent->status);
app(Payouts::class)->send($payout); // a double click, with the old copy
app(Payouts::class)->send($sent);
$this->assertSame(1, $this->posts);
$req = Http::recorded(fn ($r) => str_ends_with($r->url(), '/transfer'))->first()[0];
$this->assertSame($payout->reference_id, $req->header('X-Reference-Id')[0]);
$this->assertSame('Bearer disb-token', $req->header('Authorization')[0]);
$this->assertSame(['5000', 'EUR', ['partyIdType' => 'MSISDN', 'partyId' => '256772123456']], [$req['amount'], $req['currency'], $req['payee']]);
}
public function test_paid_and_failed_come_only_from_the_status_lookup_and_stay_final(): void
{
$payouts = app(Payouts::class);
$p = $payouts->send($this->payout());
$this->assertSame('pending', $payouts->check($p)->status);
$this->mtn[$p->reference_id] += ['financialTransactionId' => '987654321'];
$this->mtn[$p->reference_id]['status'] = 'SUCCESSFUL';
$paid = $payouts->check($p->fresh());
$this->assertSame(['paid', '987654321'], [$paid->status, $paid->financial_transaction_id]);
$this->mtn[$p->reference_id]['status'] = 'FAILED';
$this->assertSame('paid', $payouts->check($paid)->status); // final: never undone
$this->assertSame('paid', $payouts->check($p)->status); // not even through an old copy that still says "pending"
$q = $payouts->send($this->payout());
$this->mtn[$q->reference_id] = ['status' => 'FAILED', 'reason' => 'PAYEE_NOT_FOUND'];
$this->assertSame(['failed', 'PAYEE_NOT_FOUND'], [($f = $payouts->check($q))->status, $f->reason]);
}
public function test_a_different_amount_goes_to_review(): void
{
$payouts = app(Payouts::class);
$p = $payouts->send($this->payout());
$this->mtn[$p->reference_id] = ['status' => 'SUCCESSFUL', 'amount' => '500', 'currency' => 'EUR'];
$this->assertSame('review', $payouts->check($p)->status);
}
public function test_timeout_before_mtn_saw_it_resends_with_the_same_reference(): void
{
$payouts = app(Payouts::class);
$this->next = 'timeout_before';
$p = $payouts->send($this->payout());
$this->assertSame('unknown', $p->status);
$this->assertSame('pending', $payouts->check($p)->status); // 404, so sent again
$this->assertSame(2, $this->posts);
$this->assertSame([$p->reference_id], array_keys($this->mtn));
}
public function test_timeout_after_mtn_created_it_is_never_sent_twice(): void
{
$payouts = app(Payouts::class);
$this->next = 'timeout_after';
$p = $payouts->send($this->payout());
$this->assertSame('unknown', $p->status);
$this->mtn[$p->reference_id]['status'] = 'SUCCESSFUL';
$this->assertSame('paid', $payouts->check($p)->status);
$this->assertSame(1, $this->posts);
}
public function test_a_resend_of_a_transfer_mtn_already_has_is_not_a_failure(): void
{
$payouts = app(Payouts::class);
$this->next = 'timeout_after';
$p = $payouts->send($this->payout());
$this->hideOnce = true; // the lookup misses it, so it is sent again with the same reference
$this->assertSame('pending', $payouts->check($p)->status); // MTN answers 409: the first request stands
$this->assertSame([2, 1], [$this->posts, count($this->mtn)]);
}
public function test_a_clear_refusal_fails_at_once(): void
{
$this->next = 400;
$p = app(Payouts::class)->send($this->payout());
$this->assertSame(['failed', 'PAYEE_NOT_FOUND'], [$p->status, $p->reason]);
}
public function test_run_stops_before_the_balance_runs_out_and_the_command_checks(): void
{
$payouts = app(Payouts::class);
$a = $this->payout('5000');
$b = $this->payout('5000'); // the balance is 7000: this one waits
$c = $this->payout('2000');
$payouts->run([$a, $b, $c]);
$this->assertSame(['pending', 'new', 'pending'], [$a->fresh()->status, $b->fresh()->status, $c->fresh()->status]);
$this->mtn[$a->reference_id]['status'] = 'SUCCESSFUL';
Payout::query()->update(['updated_at' => now()->subMinutes(2)]);
$this->artisan('payouts:check')->assertSuccessful();
$this->assertSame('paid', $a->fresh()->status);
}
public function test_bad_input_is_refused(): void
{
$this->expectException(\InvalidArgumentException::class);
app(Payouts::class)->create('50.5.5', '256772123456', 'x', 'y');
}
}
php artisan test --filter PayoutsTest
Test against the sandbox
Send a real sandbox transfer of 1 EUR to a test number and run php artisan payouts:check a minute later. MTN’s sandbox test numbers simulate outcomes for transfers too: 46733123450 fails, 46733123451 is rejected, 46733123452 times out, 46733123453 stays pending, and numbers not on MTN’s list succeed. The full list is on the Request Builder page.
Common errors and fixes
| Error | Likely cause | Fix |
|---|---|---|
401 on the token or transfer |
Collection keys or token used for Disbursements | Use the Disbursements subscription key and an API user created with it |
411 on the token |
The token POST had no body | Keep withBody('', 'application/json') so Laravel sends Content-Length: 0 |
400 with a reason code |
A field is invalid: currency, amount format or phone number | In the sandbox use EUR, send the amount as a string, and the phone without + |
409 Conflict |
The same X-Reference-Id was sent twice |
Expected on a retry. The first transfer stands; check its status |
FAILED with NOT_ENOUGH_FUNDS |
Your disbursement account is short | Check balance() and top up before the next run |
FAILED with PAYEE_NOT_FOUND |
The receiving number has no active MoMo wallet | Ask the receiver to confirm their MoMo number |
Every MoMo failure reason is explained in the MTN MoMo error code reference.
Going to production
- Disbursements needs its own production approval from MTN. Use the production Disbursements subscription key, API user and key from the MTN MoMo Partner Portal.
- Set
MOMO_BASE_URLto the production host,MOMO_TARGET_ENVto your country (for examplemtnuganda) andMOMO_CURRENCYto the local currency (for exampleUGX). - 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.
- Use a cache store all your servers share (Redis or the database) so they share the token.
- Reconcile daily against your MoMo statement, especially payouts that went through
unknown.
Comparing payout APIs? See mobile money payouts. Want this done for you, with M-Pesa B2C, Flutterwave and Paystack payouts too? The Laravel starter kit includes them.
Using Django? See MTN MoMo Disbursements in Django.
Frequently asked questions
Can I use my MoMo Collections keys for Disbursements in Laravel?
No. Disbursements is a separate MoMo product with its own subscription key, API user and key, and its own token endpoint (/disbursement/token/). Keep them in their own config entry, as this guide does with services.momo_disbursement.
How do I stop Laravel from sending the same MoMo payout twice?
Save the payout with its X-Reference-Id before sending, then claim it with a conditional update (status new to sending). Only the process whose update changed one row sends it. A double click or a second queue worker finds the payout already claimed.
My MoMo transfer request timed out. Should I send it again?
Not with a new reference. Look it up with GET /disbursement/v1_0/transfer/{X-Reference-Id}. If MTN has it, wait for its result. Only a 404 proves it was never created; then send it again with the same X-Reference-Id, and MTN refuses a real duplicate with 409.
Is a 202 Accepted a completed MoMo payout?
No. 202 means MTN accepted the request. The payout is complete only when the transfer status is SUCCESSFUL. This guide checks it from a scheduled command.