This guide sends money from your Paystack balance to mobile money wallets in Ghana (MTN, Telecel, AT) and Kenya (M-Pesa) from a Laravel app, with Paystack’s Transfers API: refunds, seller payouts, salaries. You’ll build:
- a
PaystackTransfersservice: save a recipient, start a transfer, look one up by your reference, - a
paystack_payoutstable and aPaystackPayoutsservice that never pays twice, even after a timeout, a crash or a double click, and handles reversals, - a webhook controller that checks
x-paystack-signatureand settles payouts only from the looked-up transfer, - a scheduled command that settles payouts and safely resends only the ones Paystack never received,
- a feature test that runs every case without calling Paystack.
Every code block was run in a fresh Laravel 13 app, and the test at the end passes. API facts come from Paystack’s transfer documentation, checked in November 2026. The Node.js version of this guide has the same design. To receive mobile money, see Paystack mobile money in Laravel.
How a payout works
- Once per customer: create a transfer recipient for their wallet and store the
recipient_code(RCP_…). - You save the payout with your own
reference, then callPOST /transfer. - Paystack answers
"Transfer has been queued", with statuspending(orotpif transfers need an OTP). That is not a payout yet. - Paystack sends
transfer.success,transfer.failedortransfer.reversed. You confirm withGET /transfer/verify/:reference, the source of truth.
new ──send──▶ sending ──queued──▶ pending ──verify: success──▶ paid ──verify: reversed──▶ reversed
│ └──verify: failed / abandoned / blocked / rejected──▶ failed
├──refused, and verify finds nothing──▶ failed
└──timeout / 5xx──▶ unknown ──verify finds it──▶ pending / paid / failed
└──verify: never seen──▶ sent again, same reference
Two facts shape the design:
- You can look a transfer up by your own reference. So a lost response is recoverable: look it up, and if Paystack has never seen it, send it again with the same reference. Paystack’s docs say to retry with the same reference to avoid double crediting.
successisn’t the end. Paystack says a successful transfer “doesn’t equate to instant credit”, and a transfer can later bereversed, with the money returned to your balance. A paid payout must still be able to become reversed.
Before you start
- A registered business. Paystack says only registered businesses can use transfers. A starter business must be upgraded first.
- Turn off OTP for API transfers. In the dashboard’s Preferences, untick Confirm transfers before sending. Otherwise every transfer waits for an OTP and is abandoned after 30 minutes.
- Fund your balance. It must cover the amount plus Paystack’s transfer fee.
- Set your webhook URL under Settings → API Keys & Webhooks. Paystack signs webhooks with your secret key; there’s no separate webhook secret.
- Add your secret key to
.envand never commit it. In test mode, Paystack says transfers always succeed.
PAYSTACK_SECRET_KEY=sk_test_your_secret_key
Map it in config/services.php (if you followed the mobile money guide, you already have this entry):
<?php
// config/services.php
return [
// ...your existing services
'paystack' => [
'base_url' => 'https://api.paystack.co',
'secret_key' => env('PAYSTACK_SECRET_KEY'),
],
];
The payouts table
php artisan make:model PaystackPayout -m
<?php
// database/migrations/xxxx_xx_xx_create_paystack_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('paystack_payouts', function (Blueprint $table) {
$table->id();
$table->string('reference', 50)->unique(); // yours, fixed for life: Paystack treats a repeat as the same transfer
$table->unsignedBigInteger('amount'); // subunits: pesewas for GHS, cents for KES
$table->string('currency', 3);
$table->string('recipient_code', 64); // RCP_...
$table->string('reason', 100);
$table->string('status', 10)->default('new'); // new | sending | pending | unknown | paid | failed | reversed | review
$table->string('transfer_code')->nullable(); // TRF_...
$table->string('note')->nullable();
$table->timestamps();
$table->index(['status', 'updated_at']);
});
}
public function down(): void
{
Schema::dropIfExists('paystack_payouts');
}
};
<?php
// app/Models/PaystackPayout.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PaystackPayout extends Model
{
/** Final: nothing changes them again. paid is not final: Paystack can still reverse it. */
public const FINAL = ['failed', 'reversed', 'review'];
protected $fillable = ['reference', 'amount', 'currency', 'recipient_code', 'reason', 'status', 'transfer_code', 'note'];
protected $casts = ['amount' => '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. A 4xx is a refusal.
<?php
// app/Services/PaystackTransferException.php
namespace App\Services;
use RuntimeException;
class PaystackTransferException extends RuntimeException
{
/** ambiguous: the transfer may exist. Look it up; never send it 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/PaystackTransfers.php
namespace App\Services;
use App\Models\PaystackPayout;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
class PaystackTransfers
{
private function call(callable $send): Response
{
$http = Http::baseUrl(rtrim((string) config('services.paystack.base_url'), '/'))
->withToken((string) config('services.paystack.secret_key'))
->acceptJson()
->timeout(30);
try {
$res = $send($http);
} catch (ConnectionException $e) {
throw new PaystackTransferException("Paystack unreachable: {$e->getMessage()}", ambiguous: true);
}
if ($res->serverError() || ! is_array($res->json())) {
throw new PaystackTransferException("Paystack HTTP {$res->status()}", $res->status(), true, (array) $res->json());
}
return $res;
}
/** Saves a mobile money wallet as a transfer recipient. Store the recipient_code with the customer. */
public function createRecipient(string $name, string $phone, string $bankCode, string $currency): string
{
$res = $this->call(fn (PendingRequest $http) => $http->post('/transferrecipient', [
'type' => 'mobile_money', 'name' => $name, 'account_number' => $phone, 'bank_code' => $bankCode, 'currency' => $currency,
]));
if ($res->successful() && $res->json('status') === true && is_string($res->json('data.recipient_code'))) {
return $res->json('data.recipient_code');
}
throw new PaystackTransferException('Recipient refused: '.($res->json('message') ?? $res->status()), $res->status(), body: (array) $res->json());
}
/** Starts a transfer from your balance. Returns Paystack's data: transfer_code, reference, status. Not paid yet. */
public function initiate(PaystackPayout $payout): array
{
$res = $this->call(fn (PendingRequest $http) => $http->post('/transfer', [
'source' => 'balance',
'amount' => $payout->amount, // subunits
'currency' => $payout->currency,
'recipient' => $payout->recipient_code,
'reason' => $payout->reason,
'reference' => $payout->reference, // saved before this call
]));
if ($res->successful() && $res->json('status') === true && is_array($res->json('data'))) {
return $res->json('data');
}
throw new PaystackTransferException('Transfer refused: '.($res->json('message') ?? $res->status()), $res->status(), body: (array) $res->json());
}
/** The source of truth for a reference, or null if Paystack has no such transfer. */
public function verify(string $reference): ?array
{
$res = $this->call(fn (PendingRequest $http) => $http->get('/transfer/verify/'.rawurlencode($reference)));
if ($res->successful() && $res->json('status') === true && is_array($res->json('data'))) {
return $res->json('data');
}
if ($res->status() === 404) {
return null;
}
throw new PaystackTransferException("Transfer lookup failed: {$res->status()}", $res->status(), true, (array) $res->json());
}
/** x-paystack-signature is an HMAC SHA512 of the raw body, keyed with your secret key. */
public static function signatureOk(string $raw, ?string $signature, string $secret): bool
{
return $secret !== '' && is_string($signature) && hash_equals(hash_hmac('sha512', $raw, $secret), $signature);
}
}
Recipients. For a personal wallet, type is mobile_money, account_number is the phone number, and bank_code is the network: MTN, VOD (Telecel, formerly Vodafone) or ATL (AT, formerly AirtelTigo) in Ghana, and MPESA in Kenya. You can list the codes for your account with GET /bank?currency=GHS&type=mobile_money. Paystack’s example writes the Ghana number in local format (0551234987); the docs don’t state the format for Kenya, so this guide uses the local format there too. Confirm it with one small live payout.
Never pay twice
<?php
// app/Services/PaystackPayouts.php
namespace App\Services;
use App\Models\PaystackPayout;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use InvalidArgumentException;
class PaystackPayouts
{
/** Bank codes from Paystack's recipient docs. Prefixes from Google libphonenumber; the longest match wins. */
private const NETWORKS = [
'GHS' => ['cc' => '233', 'prefixes' => ['24' => 'MTN', '25' => 'MTN', '53' => 'MTN', '54' => 'MTN', '55' => 'MTN', '59' => 'MTN',
'20' => 'VOD', '50' => 'VOD', '23' => 'ATL', '26' => 'ATL', '27' => 'ATL', '56' => 'ATL', '57' => 'ATL']],
'KES' => ['cc' => '254', 'prefixes' => ['11' => 'MPESA', '14' => 'MPESA', '18' => 'MPESA', '70' => 'MPESA', '71' => 'MPESA',
'72' => 'MPESA', '74' => 'MPESA', '744' => null, '747' => null, '757' => 'MPESA', '758' => 'MPESA', '759' => 'MPESA',
'768' => 'MPESA', '769' => 'MPESA', '79' => 'MPESA']],
];
private const FAILED = ['failed', 'abandoned', 'blocked', 'rejected'];
public function __construct(private readonly PaystackTransfers $paystack)
{
}
/** '+233 55 123 4987' -> ['phone' => '0551234987', 'bank_code' => 'MTN']. */
public static function wallet(string $raw, string $currency): array
{
$net = self::NETWORKS[$currency] ?? throw new InvalidArgumentException('Paystack mobile money payouts are GHS or KES');
$digits = preg_replace('/\D/', '', $raw);
if (str_starts_with($digits, $net['cc'])) {
$digits = substr($digits, strlen($net['cc']));
}
if (str_starts_with($digits, '0')) {
$digits = substr($digits, 1);
}
if (! preg_match('/^\d{9}$/', $digits)) {
throw new InvalidArgumentException('That phone number has the wrong number of digits');
}
$best = null;
foreach (array_keys($net['prefixes']) as $prefix) {
$prefix = (string) $prefix;
if (str_starts_with($digits, $prefix) && ($best === null || strlen($prefix) > strlen($best))) {
$best = $prefix;
}
}
$bankCode = $best === null ? null : $net['prefixes'][$best];
if ($bankCode === null) {
throw new InvalidArgumentException($currency === 'GHS' ? 'Enter a Ghanaian MTN, Telecel or AT number' : 'Enter a Kenyan Safaricom (M-Pesa) number');
}
return ['phone' => '0'.$digits, 'bank_code' => $bankCode];
}
/** '25.50' -> 2550. Exact: no floating point. */
public static function toSubunits(string $amount): int
{
if (! preg_match('/^(\d{1,12})(?:\.(\d{1,2}))?$/', trim($amount), $m)) {
throw new InvalidArgumentException('Amounts have at most 2 decimal places');
}
return (int) $m[1] * 100 + (int) str_pad($m[2] ?? '', 2, '0');
}
/** Saves a customer's wallet with Paystack. Store the returned RCP_ code; you need it for every payout. */
public function saveRecipient(string $name, string $phone, string $currency): string
{
$wallet = self::wallet($phone, $currency);
return $this->paystack->createRecipient($name, $wallet['phone'], $wallet['bank_code'], $currency);
}
/** Create the payout record. Its reference is fixed for life. Nothing is sent yet. */
public function create(string $amount, string $currency, string $recipientCode, string $reason): PaystackPayout
{
if (! in_array($currency, ['GHS', 'KES'], true)) {
throw new InvalidArgumentException('Currency must be GHS or KES');
}
if (! preg_match('/^RCP_\w+$/', $recipientCode)) {
throw new InvalidArgumentException('Use the recipient_code (RCP_...), not a phone number');
}
$subunits = self::toSubunits($amount);
if ($subunits <= 0) {
throw new InvalidArgumentException('Amount must be more than zero');
}
return PaystackPayout::create([
'reference' => (string) Str::uuid(), // lowercase letters, digits and -, 36 characters: what Paystack asks for
'amount' => $subunits, 'currency' => $currency, 'recipient_code' => $recipientCode,
'reason' => mb_substr($reason, 0, 100), 'status' => 'new',
]);
}
/** Atomically move a payout between statuses. False when another process got there first. */
private function claim(PaystackPayout $payout, array $from, string $to): bool
{
return PaystackPayout::whereKey($payout->id)->whereIn('status', $from)->update(['status' => $to, 'updated_at' => now()]) === 1;
}
/** Only payouts that aren't final can change, whatever an old copy of the model says. */
private function open(PaystackPayout $payout): Builder
{
return PaystackPayout::whereKey($payout->id)->whereNotIn('status', PaystackPayout::FINAL);
}
/** Sends a payout once. Only "new" is ever sent, and only one process can claim it. */
public function send(PaystackPayout $payout): PaystackPayout
{
if (! $this->claim($payout, ['new'], 'sending')) { // saved BEFORE the network call
return $payout->fresh(); // sent, or maybe sent: never again from here
}
try {
$data = $this->paystack->initiate($payout);
PaystackPayout::whereKey($payout->id)->where('status', 'sending')->update([
'status' => 'pending', // queued is not paid: verify() decides
'transfer_code' => $data['transfer_code'] ?? null,
'note' => ($data['status'] ?? null) === 'otp' ? 'NEEDS_OTP' : null,
]);
} catch (PaystackTransferException $e) {
if ($e->ambiguous) {
$this->open($payout)->update(['status' => 'unknown', 'note' => 'NO_RESPONSE']); // check() looks it up
return $payout->fresh();
}
// A refusal. After an earlier lost response, Paystack may already have this reference, so look first.
try {
$transfer = $this->paystack->verify($payout->reference);
} catch (PaystackTransferException) {
$this->open($payout)->update(['status' => 'unknown', 'note' => 'NO_RESPONSE']);
return $payout->fresh();
}
if ($transfer !== null) {
return $this->settle($payout, $transfer);
}
$this->open($payout)->update(['status' => 'failed', 'note' => mb_substr((string) ($e->body['message'] ?? "HTTP_{$e->status}"), 0, 190)]);
}
return $payout->fresh();
}
/** Applies a transfer from verify() to its payout. */
public function settle(PaystackPayout $payout, array $transfer): PaystackPayout
{
if (($transfer['reference'] ?? null) !== $payout->reference) {
return $payout->fresh(); // not this payout
}
if ((int) ($transfer['amount'] ?? -1) !== $payout->amount || strtoupper((string) ($transfer['currency'] ?? '')) !== $payout->currency) {
$this->open($payout)->update(['status' => 'review', 'note' => 'TRANSFER '.($transfer['amount'] ?? '?').' '.($transfer['currency'] ?? '?')]);
return $payout->fresh();
}
$status = strtolower((string) ($transfer['status'] ?? ''));
$code = $transfer['transfer_code'] ?? $payout->transfer_code;
if ($status === 'reversed') { // can follow a success: the money is back in your balance
$this->open($payout)->update(['status' => 'reversed', 'transfer_code' => $code, 'note' => 'REVERSED']);
} elseif ($status === 'success') {
$this->open($payout)->where('status', '!=', 'paid')->update(['status' => 'paid', 'transfer_code' => $code, 'note' => null]);
} elseif (in_array($status, self::FAILED, true)) {
$this->open($payout)->where('status', '!=', 'paid')->update(['status' => 'failed', 'transfer_code' => $code, 'note' => strtoupper($status)]);
} else { // pending, received, or otp (waiting for someone to approve it in the dashboard)
PaystackPayout::whereKey($payout->id)->whereIn('status', ['sending', 'unknown', 'pending'])
->update(['status' => 'pending', 'transfer_code' => $code, 'note' => $status === 'otp' ? 'NEEDS_OTP' : null]);
}
return $payout->fresh();
}
/** Scheduled check for payouts in flight. A payout Paystack never saw is sent again, same reference. */
public function check(PaystackPayout $payout): PaystackPayout
{
if (! in_array($payout->status, ['sending', 'unknown', 'pending'], true)) {
return $payout; // a reversal of a paid payout arrives by webhook
}
try {
$transfer = $this->paystack->verify($payout->reference);
} catch (PaystackTransferException) {
return $payout; // Paystack unreachable: try again on the next run
}
if ($transfer !== null) {
return $this->settle($payout, $transfer);
}
if ($this->claim($payout, ['sending', 'unknown'], 'new')) {
return $this->send($payout->fresh());
}
return $payout->fresh();
}
/** Handles a transfer.* webhook whose signature you already checked. The body only says which transfer to look up. */
public function handleWebhook(array $body): ?PaystackPayout
{
$reference = $body['data']['reference'] ?? null;
if (! str_starts_with((string) ($body['event'] ?? ''), 'transfer.') || ! is_string($reference)) {
return null; // charge.* and other events are not payouts
}
$payout = PaystackPayout::where('reference', $reference)->first();
if ($payout === null) {
return null; // not ours
}
$transfer = $this->paystack->verify($payout->reference); // throws if Paystack is unreachable: answer 500 so it retries
return $transfer === null ? $payout : $this->settle($payout, $transfer);
}
}
The rules:
- Create the reference once, save it, then send. Paystack wants lowercase letters, digits,
-and_, 16 to 50 characters, and recommends a v4 UUID. - 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. - Look before you conclude. A timeout leaves the payout
unknown; the next check looks it up and only resends, with the same reference, if Paystack has never seen it. Even a refusal is checked first. - Only the looked-up transfer counts. The webhook body only says which reference to look up; the reference, amount and currency must match, or the payout goes to
review. failed,reversedandrevieware final.paidcan only becomereversed. Every update is limited to payouts that are still open, so a stale copy of the model can’t undo a result.
To pay someone, from your own authenticated admin code:
<?php
use App\Services\PaystackPayouts;
$payouts = app(PaystackPayouts::class);
$recipient = $payouts->saveRecipient('Ama Mensah', '0551234987', 'GHS'); // once: store it with the customer
$payouts->send($payouts->create('25.50', 'GHS', $recipient, 'Refund for order 7')); // pending, unknown or failed
The webhook
<?php
// app/Http/Controllers/PaystackTransferWebhookController.php
namespace App\Http\Controllers;
use App\Services\PaystackPayouts;
use App\Services\PaystackTransferException;
use App\Services\PaystackTransfers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
class PaystackTransferWebhookController extends Controller
{
public function __invoke(Request $request, PaystackPayouts $payouts): Response
{
$raw = $request->getContent(); // the signature covers these exact bytes
if (! PaystackTransfers::signatureOk($raw, $request->header('x-paystack-signature'), (string) config('services.paystack.secret_key'))) {
return response('', 401);
}
$body = json_decode($raw, true);
if (! is_array($body)) {
return response('', 200); // signed but not JSON: nothing to do
}
try {
$payouts->handleWebhook($body);
} catch (PaystackTransferException $e) {
Log::warning('Paystack transfer webhook not processed', ['reference' => $body['data']['reference'] ?? null, 'error' => $e->getMessage()]);
return response('', 500); // Paystack retries
}
return response('', 200);
}
}
<?php
// routes/web.php (add to the end of the file; it already imports Route)
use App\Http\Controllers\PaystackTransferWebhookController;
// CSRF is switched off for this route in bootstrap/app.php: the signature protects it.
Route::post('/webhooks/paystack', PaystackTransferWebhookController::class);
Paystack can’t send a CSRF token, so exclude the route in bootstrap/app.php:
<?php
// bootstrap/app.php, inside ->withMiddleware(...)
$middleware->validateCsrfTokens(except: ['webhooks/paystack']);
Paystack sends every event for your account to one URL. If you already receive charge webhooks (the mobile money guide uses /api/webhooks/paystack), don’t add a second route: in that route, after the signature check, also call app(PaystackPayouts::class)->handleWebhook($request->json()->all()). It ignores anything that isn’t transfer.*.
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\PaystackPayout;
use App\Services\PaystackPayouts;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
// Payouts in flight for over a minute: settle them, and resend (same reference) the ones Paystack never received.
Artisan::command('paystack-payouts:check', function (PaystackPayouts $payouts) {
PaystackPayout::whereIn('status', ['sending', 'unknown', 'pending'])->where('updated_at', '<=', now()->subMinute())
->orderBy('id')->limit(100)->get()
->each(fn (PaystackPayout $p) => $payouts->check($p));
})->purpose('Settle Paystack payouts in flight');
Schedule::command('paystack-payouts:check')->everyMinute()->withoutOverlapping();
The scheduler only runs if cron calls it: * * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1. Alert yourself about payouts with the note NEEDS_OTP: OTP is still on.
Test it without calling Paystack
This test fakes Paystack with Http::fake(). Like the real one, the fake treats a repeated reference as the same transfer. It checks that a payout is saved before it’s sent and sent only once, that the webhook signature is checked and its body never trusted, that a lost response is found by its reference, that a payout Paystack never saw is resent with the same reference, that a refusal is checked before failing, that a wrong amount goes to review, and that only a reversal changes a paid payout.
<?php
// tests/Feature/PaystackPayoutsTest.php
namespace Tests\Feature;
use App\Models\PaystackPayout;
use App\Services\PaystackPayouts;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use InvalidArgumentException;
use Tests\TestCase;
class PaystackPayoutsTest extends TestCase
{
use RefreshDatabase;
/** @var array<string, array> transfers by reference, the way Paystack keeps them */
private array $ps = [];
private int $posts = 0;
/** Next POST /transfer: 'ok', 'otp', 'timeout_before' (never reached Paystack), 'timeout_after' (it did) or 400. */
private string|int $next = 'ok';
/** The status each payout had in our database when Paystack received it. */
private array $statusAtSend = [];
private bool $down = false;
protected function setUp(): void
{
parent::setUp();
config(['services.paystack' => ['base_url' => 'https://paystack.test', 'secret_key' => 'sk_test_secret']]);
Http::preventStrayRequests();
Http::fake(function (Request $r) {
$path = parse_url($r->url(), PHP_URL_PATH);
if ($r->method() === 'POST' && $path === '/transferrecipient') {
return Http::response(['status' => true, 'message' => 'Transfer recipient created successfully', 'data' => ['recipient_code' => 'RCP_m7ljkv8leesep7p']], 201);
}
if ($r->method() === 'POST' && $path === '/transfer') {
$this->posts++;
$ref = $r['reference'];
$this->statusAtSend[] = PaystackPayout::where('reference', $ref)->value('status');
$mode = $this->next;
$this->next = 'ok';
if ($mode === 400) {
return Http::response(['status' => false, 'message' => 'Your balance is not enough to fulfil this request'], 400);
}
if ($mode === 'timeout_before') {
return Http::response(null, 503);
}
$this->ps[$ref] ??= ['reference' => $ref, 'amount' => $r['amount'], 'currency' => $r['currency'],
'status' => $mode === 'otp' ? 'otp' : 'pending', 'transfer_code' => 'TRF_'.(count($this->ps) + 1)];
return $mode === 'timeout_after' ? Http::response(null, 504) : Http::response(['status' => true, 'message' => 'Transfer has been queued', 'data' => $this->ps[$ref]]);
}
if (str_starts_with($path, '/transfer/verify/')) {
if ($this->down) {
return Http::response(null, 503);
}
$t = $this->ps[rawurldecode(basename($path))] ?? null;
return $t ? Http::response(['status' => true, 'message' => 'Transfer retrieved', 'data' => $t])
: Http::response(['status' => false, 'message' => 'Transfer not found'], 404);
}
return Http::response(null, 500);
});
}
private function payout(string $amount = '25.50', string $currency = 'GHS'): PaystackPayout
{
return app(PaystackPayouts::class)->create($amount, $currency, 'RCP_m7ljkv8leesep7p', 'Refund for order 7');
}
private function webhook(array $body, string $key = 'sk_test_secret')
{
$raw = json_encode($body);
return $this->call('POST', '/webhooks/paystack', [], [], [], [
'CONTENT_TYPE' => 'application/json', 'HTTP_X_PAYSTACK_SIGNATURE' => hash_hmac('sha512', $raw, $key),
], $raw);
}
public function test_wallets_amounts_recipients_and_input_checks(): void
{
$this->assertSame(['phone' => '0551234987', 'bank_code' => 'MTN'], PaystackPayouts::wallet('+233 55 123 4987', 'GHS'));
$this->assertSame('VOD', PaystackPayouts::wallet('0201234567', 'GHS')['bank_code']);
$this->assertSame(['phone' => '0710000000', 'bank_code' => 'MPESA'], PaystackPayouts::wallet('+254 710 000 000', 'KES'));
$this->assertSame('MPESA', PaystackPayouts::wallet('0757123456', 'KES')['bank_code']);
foreach ([['0744123456', 'KES'], ['0731234567', 'KES'], ['12345', 'GHS'], ['0551234987', 'NGN']] as [$phone, $currency]) {
try {
PaystackPayouts::wallet($phone, $currency);
$this->fail("$phone was accepted");
} catch (InvalidArgumentException) {
$this->addToAssertionCount(1);
}
}
$this->assertSame([2550, 10000, 29], [PaystackPayouts::toSubunits('25.5'), PaystackPayouts::toSubunits('100'), PaystackPayouts::toSubunits('0.29')]);
$this->assertSame('RCP_m7ljkv8leesep7p', app(PaystackPayouts::class)->saveRecipient('Ama Mensah', '+233 55 123 4987', 'GHS'));
$req = Http::recorded(fn ($r) => str_ends_with($r->url(), '/transferrecipient'))->first()[0];
$this->assertSame(['mobile_money', '0551234987', 'MTN', 'GHS'], [$req['type'], $req['account_number'], $req['bank_code'], $req['currency']]);
$this->assertSame('Bearer sk_test_secret', $req->header('Authorization')[0]);
$p = $this->payout();
$this->assertMatchesRegularExpression('/^[a-z0-9-]{16,50}$/', $p->reference);
$this->assertSame([2550, 'new'], [$p->amount, $p->status]);
foreach ([['1.234', 'GHS', 'RCP_x'], ['0', 'GHS', 'RCP_x'], ['5', 'NGN', 'RCP_x'], ['5', 'GHS', '0551234987']] as [$amount, $currency, $recipient]) {
try {
app(PaystackPayouts::class)->create($amount, $currency, $recipient, 'x');
$this->fail("$amount $currency $recipient was accepted");
} catch (InvalidArgumentException) {
$this->addToAssertionCount(1);
}
}
}
public function test_saved_before_sending_sent_once_and_paid_only_from_verify(): void
{
$payouts = app(PaystackPayouts::class);
$p = $this->payout();
$sent = $payouts->send($p);
$this->assertSame(['pending', 'TRF_1'], [$sent->status, $sent->transfer_code]);
$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) => str_ends_with($r->url(), '/transfer'))->first()[0];
$this->assertSame(['balance', 2550, 'GHS', 'RCP_m7ljkv8leesep7p', $p->reference], [$req['source'], $req['amount'], $req['currency'], $req['recipient'], $req['reference']]);
$this->ps[$p->reference]['status'] = 'success';
$this->webhook(['event' => 'transfer.success', 'data' => ['reference' => $p->reference]])->assertOk();
$this->assertSame('paid', $p->fresh()->status);
}
public function test_the_webhook_checks_the_signature_and_never_trusts_the_body(): void
{
$p = app(PaystackPayouts::class)->send($this->payout());
$body = ['event' => 'transfer.success', 'data' => ['reference' => $p->reference, 'status' => 'success', 'amount' => 2550]];
$this->webhook($body, 'sk_test_wrong')->assertStatus(401);
$this->webhook($body)->assertOk();
$this->assertSame('pending', $p->fresh()->status); // Paystack still says "pending"
$this->webhook(['event' => 'transfer.success', 'data' => ['reference' => 'not-ours']])->assertOk();
$this->ps[$p->reference]['status'] = 'success';
$this->webhook(['event' => 'charge.success', 'data' => ['reference' => $p->reference]])->assertOk();
$this->assertSame('pending', $p->fresh()->status); // charge events aren't payouts
}
public function test_a_different_amount_or_currency_goes_to_review(): void
{
$payouts = app(PaystackPayouts::class);
$p = $payouts->send($this->payout());
$this->ps[$p->reference] = ['status' => 'success', 'amount' => 255, 'currency' => 'GHS'] + $this->ps[$p->reference];
$this->assertSame('review', $payouts->check($p)->status);
$q = $payouts->send($this->payout());
$this->ps[$q->reference] = ['status' => 'success', 'currency' => 'KES'] + $this->ps[$q->reference];
$this->assertSame('review', $payouts->check($q)->status);
$this->ps[$q->reference]['currency'] = 'GHS';
$this->assertSame('review', $payouts->check($q->fresh())->status); // final
}
public function test_other_references_and_a_vanished_pending_transfer_change_nothing(): void
{
$payouts = app(PaystackPayouts::class);
$p = $payouts->send($this->payout());
$this->assertSame('pending', $payouts->settle($p, ['reference' => 'someone-else', 'status' => 'success'] + $this->ps[$p->reference])->status);
$this->ps[$p->reference]['status'] = 'otp';
$this->assertSame(['pending', 'NEEDS_OTP'], [($o = $payouts->check($p))->status, $o->note]);
unset($this->ps[$p->reference]); // a pending payout is never resent, even if a lookup misses it
$this->assertSame('pending', $payouts->check($p->fresh())->status);
$this->assertSame(1, $this->posts);
}
public function test_failures_fail_otp_waits_and_only_a_reversal_changes_a_paid_payout(): void
{
$payouts = app(PaystackPayouts::class);
foreach (['failed', 'abandoned', 'blocked', 'rejected'] as $status) {
$p = $payouts->send($this->payout());
$this->ps[$p->reference]['status'] = $status;
$this->assertSame(['failed', strtoupper($status)], [($f = $payouts->check($p))->status, $f->note]);
}
$this->next = 'otp';
$p = $payouts->send($this->payout());
$this->assertSame(['pending', 'NEEDS_OTP'], [$p->status, $p->note]);
$this->ps[$p->reference]['status'] = 'success';
$paid = $payouts->check($p);
$this->assertSame('paid', $paid->status);
$this->ps[$p->reference]['status'] = 'pending'; // a stale answer can't un-pay it
$this->assertSame('paid', $payouts->settle($p, $this->ps[$p->reference])->status);
$this->ps[$p->reference]['status'] = 'failed';
$this->assertSame('paid', $payouts->settle($p, $this->ps[$p->reference])->status);
$this->ps[$p->reference]['status'] = 'reversed'; // but a reversal can
$this->webhook(['event' => 'transfer.reversed', 'data' => ['reference' => $p->reference]])->assertOk();
$this->assertSame(['reversed', 'REVERSED'], [$p->fresh()->status, $p->fresh()->note]);
$this->ps[$p->reference]['status'] = 'success';
$this->webhook(['event' => 'transfer.success', 'data' => ['reference' => $p->reference]])->assertOk();
$this->assertSame('reversed', $p->fresh()->status);
}
public function test_a_timeout_after_paystack_created_it_is_found_by_reference_and_never_sent_twice(): void
{
$payouts = app(PaystackPayouts::class);
$this->next = 'timeout_after';
$p = $payouts->send($this->payout());
$this->assertSame(['unknown', 'NO_RESPONSE'], [$p->status, $p->note]);
$this->ps[$p->reference]['status'] = 'success';
$this->assertSame('paid', $payouts->check($p)->status);
$this->assertSame(1, $this->posts);
}
public function test_a_timeout_before_paystack_saw_it_resends_with_the_same_reference(): void
{
$payouts = app(PaystackPayouts::class);
$this->next = 'timeout_before';
$p = $payouts->send($this->payout());
$this->assertSame('unknown', $p->status);
$again = $payouts->check($p);
$this->assertSame(['pending', 2, [$p->reference]], [$again->status, $this->posts, array_keys($this->ps)]);
$payouts->check($again);
$this->assertSame([2, 1], [$this->posts, count($this->ps)]);
$crashed = $this->payout(); // the process died during the call
$crashed->update(['status' => 'sending']);
$this->assertSame('pending', $payouts->check($crashed)->status);
$this->assertSame(2, count($this->ps));
}
public function test_a_refusal_fails_only_when_paystack_has_no_transfer_under_the_reference(): void
{
$payouts = app(PaystackPayouts::class);
$this->next = 400;
$p = $payouts->send($this->payout());
$this->assertSame(['failed', 'Your balance is not enough to fulfil this request'], [$p->status, $p->note]);
$q = $this->payout(); // it went through on an earlier try whose answer we lost
$this->ps[$q->reference] = ['reference' => $q->reference, 'amount' => 2550, 'currency' => 'GHS', 'status' => 'success', 'transfer_code' => 'TRF_x'];
$this->next = 400;
$this->assertSame('paid', $payouts->send($q)->status);
$this->next = 400;
$this->down = true;
$this->assertSame('unknown', $payouts->send($this->payout())->status);
}
public function test_an_unreachable_paystack_changes_nothing_and_the_webhook_asks_for_a_retry(): void
{
$payouts = app(PaystackPayouts::class);
$p = $payouts->send($this->payout());
$this->down = true;
$this->assertSame('pending', $payouts->check($p)->status);
$this->webhook(['event' => 'transfer.success', 'data' => ['reference' => $p->reference]])->assertStatus(500);
$this->assertSame('pending', $p->fresh()->status);
}
public function test_the_command_settles_payouts_in_flight(): void
{
$payouts = app(PaystackPayouts::class);
$a = $payouts->send($this->payout());
$this->next = 'timeout_before';
$b = $payouts->send($this->payout());
$this->ps[$a->reference]['status'] = 'success';
PaystackPayout::query()->update(['updated_at' => now()->subMinutes(2)]);
$this->artisan('paystack-payouts:check')->assertSuccessful();
$this->assertSame(['paid', 'pending'], [$a->fresh()->status, $b->fresh()->status]);
}
}
php artisan test --filter PaystackPayoutsTest
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 Paystack error reference.
| Symptom | Likely cause | Fix |
|---|---|---|
| Every transfer is refused | The business isn’t registered, or transfers aren’t enabled on the account | Complete Paystack’s business verification |
Payouts stay pending with NEEDS_OTP |
OTP is still on | Untick Confirm transfers before sending in Preferences |
failed with a balance message |
The balance can’t cover the amount plus the fee | Fund the balance before a payout run |
| Recipient refused | Wrong bank_code for the number, or wrong currency |
Check the network; list codes with GET /bank?currency=GHS&type=mobile_money |
419 on the webhook |
CSRF is still on for the route | Add webhooks/paystack to validateCsrfTokens(except: ...) |
401 on the webhook |
The secret key is from the other mode, or a proxy changed the body | Use the secret key of the same mode (test or live); sign-check getContent() |
review |
The transfer’s amount or currency differs from the payout | Look at it in the dashboard |
A paid payout becomes reversed |
The wallet couldn’t receive the money | The money is back in your balance; contact the recipient, then create a new payout |
Going to production
- Switch to your live secret key (
sk_live_…). It also signs the live webhooks. - 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.
- Keep OTP off only if those controls exist. Paystack also offers a transfer approval URL that your server answers to approve or reject each transfer; if it doesn’t answer in time, the transfer is
blocked. - Send one small live payout per network you’ll use, to confirm the phone format, especially in Kenya.
- Reconcile daily against your Paystack 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 Flutterwave payouts too? The Laravel starter kit includes them.
Using Django? See Paystack payouts in Django.
Frequently asked questions
How do I send money to a mobile money wallet with Paystack in Laravel?
Create a transfer recipient once (POST /transferrecipient with type mobile_money, the phone number as account_number, and bank_code MTN, VOD or ATL in Ghana, or MPESA in Kenya) and store its recipient_code. Then POST /transfer with source balance, the amount in subunits, the recipient_code, the currency and your own reference, saved in your database first.
How do I stop Laravel from sending the same Paystack transfer twice?
Save the payout with its reference before calling Paystack, then claim it with a conditional update (status new to sending). Only the process whose update changed a row sends it. After a timeout, look the transfer up by its reference, and only send again, with the same reference, if Paystack has never seen it.
Does the Paystack transfer webhook need its own URL?
No. Paystack sends every event for your account to one webhook URL, signed with your secret key. If you already receive charge webhooks, call the payouts webhook handler from that route after the signature check; it ignores anything that is not a transfer event.
Why does my Paystack payout stay pending with NEEDS_OTP?
Transfers need an OTP by default. Until you untick "Confirm transfers before sending" in your dashboard preferences, each transfer waits for an OTP and is abandoned after 30 minutes.