This guide accepts mobile money in Ghana and Kenya through Paystack’s Charge API, in a Laravel app: MTN, Telecel and AT Money in Ghana, and M-Pesa and Airtel Money in Kenya. You’ll build:
- a
PaystackClientservice: amounts in subunits, phone formatting, the charge, verification and the webhook signature check, - a payments table and a controller that saves each payment before charging,
- a webhook route that checks
x-paystack-signature, and a queued job that settles payments only from verified data, - a scheduled check, which is how failed payments are found, since Paystack sends webhooks only for successful ones,
- 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 public documentation, checked in November 2026. The Node.js version of this guide explains the flow in more detail.
Before you start
- In your Paystack dashboard, copy the test secret key from Settings → API Keys & Webhooks, and set the webhook URL to
https://your-domain/api/webhooks/paystack. Paystack signs webhooks with the same secret key. - Make sure a queue worker can run (
php artisan queue:work). The database queue driver is fine to start with. - If your app has no
routes/api.phpyet, runphp artisan install:api.
Paystack mobile money is available to Paystack businesses in Ghana, Kenya and Côte d’Ivoire. The provider codes are:
| Currency | Provider codes |
|---|---|
GHS (subunit: pesewa) |
mtn (MTN), vod (Telecel), atl (AT Money) |
KES (subunit: cent) |
mpesa (M-Pesa), atl (Airtel Money) |
Configuration
Add your key to .env and never commit it:
PAYSTACK_SECRET_KEY=sk_test_your_test_secret_key
Map it in config/services.php, and always read it through config():
<?php
// config/services.php
return [
// ...your existing services
'paystack' => [
'base_url' => 'https://api.paystack.co',
'secret_key' => env('PAYSTACK_SECRET_KEY'),
],
];
Create the payments table
You choose the reference and send it with the charge, so a lost response never means a lost payment. The amount is stored in the subunit, exactly as Paystack reports it back.
php artisan make:model PaystackPayment -m
<?php
// database/migrations/xxxx_xx_xx_create_paystack_payments_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('paystack_payments', function (Blueprint $table) {
$table->id();
$table->string('reference', 100)->unique(); // our reference, sent to Paystack
$table->string('order_id')->index();
$table->unsignedBigInteger('amount'); // in the subunit: pesewas (GHS) or cents (KES)
$table->string('currency', 3); // GHS or KES
$table->string('phone', 15); // local format, e.g. 0551234987
$table->string('provider', 10); // mtn, vod, atl, mpesa
$table->string('status')->default('pending'); // pending | paid | failed | review
$table->string('failure_reason')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('paystack_payments');
}
};
<?php
// app/Models/PaystackPayment.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PaystackPayment extends Model
{
protected $fillable = ['reference', 'order_id', 'amount', 'currency', 'phone', 'provider', 'status', 'failure_reason'];
protected $casts = ['amount' => 'integer'];
}
Run php artisan migrate.
The Paystack service class
The client turns network problems and 5xx answers into an ambiguous error: the request may have reached Paystack, so the payment must stay pending and be checked. A 4xx is a clear refusal.
<?php
// app/Services/PaystackException.php
namespace App\Services;
use RuntimeException;
class PaystackException extends RuntimeException
{
/** $ambiguous = true: the request may have reached Paystack (timeout, 5xx). Check, don't assume it failed. */
public function __construct(string $message, public readonly bool $ambiguous = false)
{
parent::__construct($message);
}
}
<?php
// app/Services/PaystackClient.php
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use InvalidArgumentException;
class PaystackClient
{
/** Mobile money provider codes Paystack documents, per currency. */
public const PROVIDERS = ['GHS' => ['mtn', 'vod', 'atl'], 'KES' => ['mpesa', 'atl']];
private const COUNTRY_CODES = ['GHS' => '233', 'KES' => '254'];
private function http(): PendingRequest
{
return Http::baseUrl((string) config('services.paystack.base_url'))
->withToken((string) config('services.paystack.secret_key'))
->acceptJson()
->timeout(30);
}
/** "25.50" -> 2550. Paystack amounts are in the subunit: pesewas (GHS) or cents (KES). */
public static function subunits(string $amount): int
{
if (! preg_match('/^(\d{1,9})(?:\.(\d{1,2}))?$/', trim($amount), $m)) {
throw new InvalidArgumentException('Enter an amount like 25 or 25.50.');
}
return (int) $m[1] * 100 + (int) str_pad($m[2] ?? '', 2, '0');
}
/** "+233 55 123 4987" -> "0551234987": the local format with a leading 0. */
public static function localPhone(string $raw, string $currency): string
{
$cc = self::COUNTRY_CODES[$currency] ?? throw new InvalidArgumentException('Currency must be GHS or KES.');
$digits = preg_replace('/\D/', '', $raw);
if (str_starts_with($digits, '00')) {
$digits = substr($digits, 2);
}
if (str_starts_with($digits, $cc)) {
$digits = '0'.substr($digits, strlen($cc));
}
if (! str_starts_with($digits, '0')) {
$digits = '0'.$digits;
}
if (! preg_match('/^0\d{9}$/', $digits)) {
throw new InvalidArgumentException('Enter a mobile number like 055 123 4987.');
}
return $digits;
}
private function send(string $method, string $url, array $body = []): Response
{
try {
$response = $method === 'GET' ? $this->http()->get($url) : $this->http()->post($url, $body);
} catch (ConnectionException $e) {
throw new PaystackException('Paystack unreachable: '.$e->getMessage(), ambiguous: true);
}
if ($response->serverError() || ! is_array($response->json())) {
throw new PaystackException("Paystack HTTP {$response->status()}", ambiguous: true);
}
return $response;
}
/** Starts a mobile money charge. Returns Paystack's data: status ("pay_offline"), display_text. */
public function charge(string $reference, string $email, int $amount, string $currency, string $phone, string $provider): array
{
$response = $this->send('POST', '/charge', [
'reference' => $reference,
'email' => $email,
'amount' => $amount,
'currency' => $currency,
'mobile_money' => ['phone' => $phone, 'provider' => $provider],
]);
if (! $response->successful() || $response->json('status') !== true) {
throw new PaystackException('Paystack refused the charge: '.$response->json('message', "HTTP {$response->status()}"));
}
return (array) $response->json('data');
}
/** The source of truth: the transaction for our reference, or null if Paystack has none (yet). */
public function verify(string $reference): ?array
{
$response = $this->send('GET', '/transaction/verify/'.rawurlencode($reference));
$tx = $response->json('data');
return $response->successful() && $response->json('status') === true && is_array($tx) ? $tx : null;
}
/** x-paystack-signature is an HMAC SHA512 of the raw request body, keyed with your secret key. */
public static function signatureOk(string $rawBody, ?string $signature): bool
{
$secret = (string) config('services.paystack.secret_key');
if ($secret === '' || ! is_string($signature) || $signature === '') {
return false;
}
return hash_equals(hash_hmac('sha512', $rawBody, $secret), $signature);
}
}
Three details matter:
- Amounts are in the subunit. GHS 25.50 is
2550pesewas; KES 100 is10000cents.subunits()parses the decimal string, so there’s no floating-point rounding. - Check
data.status, notstatus. The top-levelstatus: trueonly says the API call worked. - Phone numbers go in the local format with a leading 0, as in Paystack’s Ghana example (
0551234987). Paystack’s docs show the Kenyan test number as+254 710 000 000; this guide sends Kenyan numbers the same local way, so confirm with the test number before going live.
Start a payment
The controller validates the provider against the currency, saves the payment, then charges. If the request times out, the payment stays pending and the scheduled check settles it.
<?php
// app/Http/Controllers/PaystackPaymentController.php
namespace App\Http\Controllers;
use App\Models\PaystackPayment;
use App\Services\PaystackClient;
use App\Services\PaystackException;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use InvalidArgumentException;
class PaystackPaymentController extends Controller
{
public function store(Request $request, PaystackClient $paystack)
{
$data = $request->validate([
'order_id' => ['required', 'string', 'max:64'],
'amount' => ['required'], // take it from your order record in a real app
'currency' => ['required', Rule::in(array_keys(PaystackClient::PROVIDERS))],
'provider' => ['required', 'string'],
'phone' => ['required', 'string', 'max:20'],
'email' => ['required', 'email'],
]);
if (! in_array($data['provider'], PaystackClient::PROVIDERS[$data['currency']], true)) {
return response()->json(['errors' => ['provider' => ['That provider is not available for '.$data['currency'].'.']]], 422);
}
try {
$amount = PaystackClient::subunits((string) $data['amount']);
$phone = PaystackClient::localPhone($data['phone'], $data['currency']);
} catch (InvalidArgumentException $e) {
return response()->json(['errors' => ['amount_or_phone' => [$e->getMessage()]]], 422);
}
// Saved BEFORE the call: if the response is lost, the scheduled check still finds it by reference.
$payment = PaystackPayment::create([
'reference' => 'order-'.$data['order_id'].'-'.Str::uuid(),
'order_id' => $data['order_id'],
'amount' => $amount,
'currency' => $data['currency'],
'phone' => $phone,
'provider' => $data['provider'],
]);
try {
$charge = $paystack->charge($payment->reference, $data['email'], $amount, $payment->currency, $phone, $payment->provider);
} catch (PaystackException $e) {
report($e);
if (! $e->ambiguous) {
$payment->update(['status' => 'failed', 'failure_reason' => mb_substr($e->getMessage(), 0, 190)]);
return response()->json(['message' => 'The payment could not be started. Please try again.'], 502);
}
$charge = []; // it may have gone through: leave it pending, the check settles it
}
if (($charge['status'] ?? null) === 'failed') {
$payment->update(['status' => 'failed', 'failure_reason' => mb_substr((string) ($charge['message'] ?? 'failed'), 0, 190)]);
return response()->json(['message' => 'The payment was declined. Please try again.'], 502);
}
return response()->json([
'reference' => $payment->reference,
'message' => $charge['display_text'] ?? 'Approve the payment on your phone.',
], 202);
}
/** Your order page polls this, not Paystack. */
public function show(string $reference)
{
$payment = PaystackPayment::where('reference', $reference)->firstOrFail();
return response()->json(['status' => $payment->status]);
}
}
Show the customer the returned message (Paystack’s display_text); they approve on their phone. Paystack says they have 180 seconds. Your order page then polls GET /api/payments/paystack/{reference}.
Handle the webhook
The webhook lives in routes/api.php, which has no CSRF check. The signature is an HMAC SHA512 of the raw body, so the route checks $request->getContent(), never re-encoded JSON. Keep one copy of each use line if the file already has some.
<?php
// routes/api.php
use App\Http\Controllers\PaystackPaymentController;
use App\Jobs\ConfirmPaystackPayment;
use App\Models\PaystackPayment;
use App\Services\PaystackClient;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/payments/paystack', [PaystackPaymentController::class, 'store']);
Route::get('/payments/paystack/{reference}', [PaystackPaymentController::class, 'show']);
Route::post('/webhooks/paystack', function (Request $request) {
// The signature covers the RAW body, so check it before trusting anything in the JSON.
if (! PaystackClient::signatureOk($request->getContent(), $request->header('x-paystack-signature'))) {
return response()->json(['error' => 'Invalid signature'], 401);
}
$reference = $request->json('data.reference');
if (is_string($reference) && PaystackPayment::where('reference', $reference)->exists()) {
ConfirmPaystackPayment::dispatch($reference); // never trust the body; the job asks Paystack
}
return response()->json(['ok' => true]);
});
The job asks Paystack for the real status, then updates the row inside a locked transaction, so a repeated webhook and the scheduled check can’t settle a payment twice.
<?php
// app/Jobs/ConfirmPaystackPayment.php
namespace App\Jobs;
use App\Models\PaystackPayment;
use App\Services\PaystackClient;
use App\Services\PaystackException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
class ConfirmPaystackPayment implements ShouldQueue
{
use Queueable;
public int $tries = 6;
public function __construct(public string $reference)
{
}
public function handle(PaystackClient $paystack): void
{
try {
$tx = $paystack->verify($this->reference); // network call outside the lock
} catch (PaystackException) {
$this->release(60 * $this->attempts()); // Paystack unreachable: proves nothing, ask again
return;
}
DB::transaction(function () use ($tx) {
$payment = PaystackPayment::where('reference', $this->reference)->lockForUpdate()->first();
if (! $payment || $payment->status !== 'pending') {
return; // unknown, or already settled by another webhook or check
}
if ($tx === null || ($tx['reference'] ?? null) !== $payment->reference) {
// No transaction yet. After an hour there never will be: nothing was charged.
if ($payment->created_at->lt(now()->subHour())) {
$payment->update(['status' => 'failed', 'failure_reason' => 'not_found']);
}
return;
}
$status = strtolower((string) ($tx['status'] ?? ''));
if ($status === 'success') {
$exact = ($tx['currency'] ?? '') === $payment->currency && (int) ($tx['amount'] ?? -1) === $payment->amount;
$payment->update([
'status' => $exact ? 'paid' : 'review', // review: paid, but another amount or currency
'failure_reason' => $exact ? null : "paid {$tx['amount']} {$tx['currency']}",
]);
// if ($exact) event(new OrderPaid($payment->order_id));
} elseif (in_array($status, ['failed', 'abandoned', 'reversed'], true)) {
$payment->update(['status' => 'failed', 'failure_reason' => mb_substr((string) ($tx['gateway_response'] ?? $status), 0, 190)]);
}
// ongoing, pending, processing, queued: leave it; a later check settles it
});
}
}
| Paystack says | What the code does |
|---|---|
success, same amount and currency |
Marks it paid |
success, another amount or currency |
Marks it review for a person to look at |
failed, abandoned or reversed |
Marks it failed with the gateway’s message |
ongoing, pending and the rest |
Leaves it; a later check settles it |
| No transaction, less than an hour old | Leaves it pending |
| No transaction after an hour | Marks it failed (not_found): nothing was charged |
| 5xx or no answer | Tries again later. A server error proves nothing |
Paystack retries a webhook until it gets a 200: in live mode every 3 minutes for the first 4 tries, then hourly for 72 hours.
Check pending payments
Paystack’s webhooks only cover successful payments, so this scheduled check is how failures and lost webhooks are found:
<?php
// routes/console.php
use App\Jobs\ConfirmPaystackPayment;
use App\Models\PaystackPayment;
use Illuminate\Support\Facades\Schedule;
// Paystack only sends webhooks for successful payments, so failures are found here.
Schedule::call(function () {
PaystackPayment::where('status', 'pending')
->where('created_at', '<', now()->subMinutes(3)) // the customer has 180 seconds to approve
->where('created_at', '>', now()->subDay())
->pluck('reference')
->each(fn (string $reference) => ConfirmPaystackPayment::dispatch($reference));
})->everyFiveMinutes();
This needs Laravel’s scheduler running: a cron entry that calls php artisan schedule:run every minute.
Test it without calling Paystack
Http::fake() replaces Paystack, so this test runs offline and in CI. It checks subunits and phone formats, that the webhook body is never trusted, that a bad signature is refused, that wrong amounts, currencies and references don’t pay, that failed and abandoned payments are final, that a paid payment never changes, and that timeouts and server errors leave payments pending.
<?php
// tests/Feature/PaystackPaymentTest.php
namespace Tests\Feature;
use App\Jobs\ConfirmPaystackPayment;
use App\Models\PaystackPayment;
use App\Services\PaystackClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class PaystackPaymentTest extends TestCase
{
use RefreshDatabase;
private const SECRET = 'sk_test_secret';
/** What Paystack's verify endpoint says. null = no such transaction. */
private ?array $tx = null;
private int $verifyHttpStatus = 200;
/** What the charge endpoint answers. */
private array $charge = [200, ['status' => true, 'message' => 'Charge attempted',
'data' => ['status' => 'pay_offline', 'display_text' => 'Please complete authorization process on your mobile phone']]];
protected function setUp(): void
{
parent::setUp();
config([
'queue.default' => 'sync',
'services.paystack.base_url' => 'https://api.paystack.co',
'services.paystack.secret_key' => self::SECRET,
]);
Http::fake([
'*/charge' => fn () => Http::response($this->charge[1], $this->charge[0]),
'*/transaction/verify/*' => fn () => $this->verifyHttpStatus >= 500
? Http::response([], $this->verifyHttpStatus)
: ($this->tx === null
? Http::response(['status' => false, 'message' => 'Transaction reference not found'], 400)
: Http::response(['status' => true, 'message' => 'Verification successful', 'data' => $this->tx])),
]);
}
private function start(array $overrides = []): string
{
return $this->postJson('/api/payments/paystack', $overrides + [
'order_id' => '1001', 'amount' => '25.50', 'currency' => 'GHS', 'provider' => 'mtn',
'phone' => '+233 55 123 4987', 'email' => 'buyer@example.com',
])->assertStatus(202)->assertJsonPath('message', 'Please complete authorization process on your mobile phone')->json('reference');
}
private function webhook(string $reference, ?string $signature = null)
{
$body = json_encode(['event' => 'charge.success', 'data' => ['reference' => $reference, 'status' => 'success']]);
$signature ??= hash_hmac('sha512', $body, self::SECRET);
return $this->call('POST', '/api/webhooks/paystack', [], [], [],
['CONTENT_TYPE' => 'application/json', 'HTTP_X_PAYSTACK_SIGNATURE' => $signature], $body);
}
private function successful(string $reference, array $overrides = []): array
{
return $overrides + ['reference' => $reference, 'amount' => 2550, 'currency' => 'GHS', 'status' => 'success', 'gateway_response' => 'Approved'];
}
public function test_charge_is_sent_in_subunits_and_settled_from_verify_not_the_webhook(): void
{
$reference = $this->start();
Http::assertSent(fn (Request $request) => str_ends_with($request->url(), '/charge')
&& $request->header('Authorization') === ['Bearer sk_test_secret']
&& $request['reference'] === $reference
&& $request['amount'] === 2550
&& $request['currency'] === 'GHS'
&& $request['mobile_money'] === ['phone' => '0551234987', 'provider' => 'mtn']);
// The body says "success", but Paystack still says ongoing.
$this->tx = $this->successful($reference, ['status' => 'ongoing']);
$this->webhook($reference)->assertOk();
$this->assertSame('pending', PaystackPayment::first()->status);
$this->tx = $this->successful($reference);
$this->webhook($reference)->assertOk();
$this->assertSame('paid', PaystackPayment::first()->status);
$this->getJson("/api/payments/paystack/$reference")->assertJson(['status' => 'paid']);
}
public function test_webhook_with_a_bad_signature_is_refused(): void
{
$reference = $this->start();
$this->tx = $this->successful($reference);
$this->webhook($reference, 'deadbeef')->assertUnauthorized();
$this->webhook($reference, hash_hmac('sha512', 'something else', self::SECRET))->assertUnauthorized();
Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/transaction/verify/'));
$this->assertSame('pending', PaystackPayment::first()->status);
}
public function test_signature_is_never_accepted_without_a_secret(): void
{
config(['services.paystack.secret_key' => '']);
$this->assertFalse(PaystackClient::signatureOk('{}', hash_hmac('sha512', '{}', '')));
}
public function test_kenya_mpesa_and_bad_input(): void
{
$this->start(['currency' => 'KES', 'provider' => 'mpesa', 'phone' => '+254 710 000 000', 'amount' => 100]);
Http::assertSent(fn (Request $request) => ($request['amount'] ?? null) === 10000
&& ($request['mobile_money'] ?? null) === ['phone' => '0710000000', 'provider' => 'mpesa']);
$this->assertSame([750, 2550, 10000], [PaystackClient::subunits('7.5'), PaystackClient::subunits('25.50'), PaystackClient::subunits('100')]);
$base = ['order_id' => '1', 'amount' => '10', 'currency' => 'GHS', 'provider' => 'mtn', 'phone' => '0551234987', 'email' => 'a@b.co'];
$this->postJson('/api/payments/paystack', ['provider' => 'mpesa'] + $base)->assertStatus(422); // M-Pesa is Kenya only
$this->postJson('/api/payments/paystack', ['amount' => '1.234'] + $base)->assertStatus(422);
$this->postJson('/api/payments/paystack', ['phone' => '123'] + $base)->assertStatus(422);
$this->postJson('/api/payments/paystack', ['currency' => 'NGN'] + $base)->assertStatus(422);
}
public function test_another_amount_or_currency_goes_to_review(): void
{
$reference = $this->start();
$this->tx = $this->successful($reference, ['amount' => 255]);
$this->webhook($reference);
$this->assertSame('review', PaystackPayment::first()->status);
$other = $this->start();
$this->tx = $this->successful($other, ['currency' => 'KES']);
$this->webhook($other);
$this->assertSame('review', PaystackPayment::where('reference', $other)->first()->status);
}
public function test_a_transaction_for_another_reference_is_ignored(): void
{
$reference = $this->start();
$this->tx = $this->successful('someone-else');
$this->webhook($reference);
$this->assertSame('pending', PaystackPayment::first()->status);
}
public function test_failed_and_abandoned_are_final_and_paid_never_changes(): void
{
foreach (['failed', 'abandoned'] as $status) {
$reference = $this->start();
$this->tx = $this->successful($reference, ['status' => $status, 'gateway_response' => 'Declined']);
ConfirmPaystackPayment::dispatch($reference);
$this->assertSame('failed', PaystackPayment::where('reference', $reference)->first()->status);
}
$paid = $this->start();
$this->tx = $this->successful($paid);
$this->webhook($paid);
$this->tx = $this->successful($paid, ['status' => 'reversed']); // a later answer can't undo it
ConfirmPaystackPayment::dispatch($paid);
$this->assertSame('paid', PaystackPayment::where('reference', $paid)->first()->status);
}
public function test_missing_transaction_waits_then_fails_after_an_hour(): void
{
$reference = $this->start();
ConfirmPaystackPayment::dispatch($reference);
$this->assertSame('pending', PaystackPayment::first()->status);
PaystackPayment::query()->update(['created_at' => now()->subMinutes(61)]);
ConfirmPaystackPayment::dispatch($reference);
$this->assertDatabaseHas('paystack_payments', ['status' => 'failed', 'failure_reason' => 'not_found']);
}
public function test_server_error_on_verify_proves_nothing(): void
{
$reference = $this->start();
PaystackPayment::query()->update(['created_at' => now()->subHours(2)]);
$this->verifyHttpStatus = 502;
ConfirmPaystackPayment::dispatch($reference);
$this->assertSame('pending', PaystackPayment::first()->status);
}
public function test_charge_timeout_stays_pending_and_the_check_settles_it(): void
{
$this->charge = [503, []];
$reference = $this->postJson('/api/payments/paystack', [
'order_id' => '1', 'amount' => '25.50', 'currency' => 'GHS', 'provider' => 'mtn', 'phone' => '0551234987', 'email' => 'a@b.co',
])->assertStatus(202)->json('reference');
$this->assertSame('pending', PaystackPayment::first()->status);
$this->tx = $this->successful($reference);
ConfirmPaystackPayment::dispatch($reference);
$this->assertSame('paid', PaystackPayment::first()->status);
}
public function test_a_clear_refusal_or_a_failed_charge_fails_at_once(): void
{
$base = ['order_id' => '1', 'amount' => '25.50', 'currency' => 'GHS', 'provider' => 'mtn', 'phone' => '0551234987', 'email' => 'a@b.co'];
$this->charge = [400, ['status' => false, 'message' => 'Invalid phone number']];
$this->postJson('/api/payments/paystack', $base)->assertStatus(502);
$this->charge = [200, ['status' => true, 'data' => ['status' => 'failed', 'message' => 'Declined']]];
$this->postJson('/api/payments/paystack', $base)->assertStatus(502);
$this->assertSame(['failed', 'failed'], PaystackPayment::pluck('status')->all());
}
}
Run it with php artisan test --filter PaystackPaymentTest.
Test in test mode
Paystack’s test-payments page lists these mobile money test numbers, neither needing a PIN or OTP: MTN 055 123 4987 and M-Pesa +254 710 000 000. Start a payment with each, run a queue worker and the scheduler, and watch the payment settle.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
Webhooks get 401 |
Signature computed over re-encoded JSON, or the wrong mode’s key | Use $request->getContent() and the key for the same mode |
Webhooks get 419 |
The route is in routes/web.php |
Move it to routes/api.php, or exclude it from CSRF |
| Amount 100 times too small | Sent in cedis or shillings | Send the subunit, as subunits() does |
Failed payments stay pending |
No scheduler running | Run schedule:run from cron and a queue worker |
review |
Paid amount or currency differs from the order | Check the transaction in the dashboard and decide |
Going to production
- Complete Paystack’s business activation for Ghana or Kenya, then switch to your live secret key and run
php artisan config:cache. - Load the amount from your order on the server. Never take it from the request, as the example does for brevity.
- Run a queue worker and the scheduler under a process manager.
- Send one small live payment on each provider you support before launch.
See also: mobile money in Ghana, mobile money in Kenya, and the M-Pesa STK push guide for Laravel if you’d rather go direct to Safaricom.
Sending money back out to wallets? See Paystack mobile money payouts.
Frequently asked questions
How do I verify a Paystack webhook in Laravel?
Compute hash_hmac('sha512', $request->getContent(), your secret key) over the raw body and compare it with the x-paystack-signature header using hash_equals. Answer 401 if they differ. Then confirm the payment with GET /transaction/verify/:reference.
Why does my Paystack webhook signature never match in Laravel?
Usually because the signature is computed over re-encoded JSON, for example json_encode($request->all()), instead of the raw body. Use $request->getContent(). Also check you use the secret key for the same mode (test or live) that sent the webhook.
Does the Paystack webhook route need a CSRF token?
It can't have one. Put the route in routes/api.php, which has no CSRF check, or exclude it in bootstrap/app.php. The signature check protects it instead.
Why do failed Paystack payments stay pending?
Paystack says webhooks currently fire only for successful transactions. The scheduled check in this guide verifies pending payments, which is how failed and abandoned ones are found.