This guide accepts payments through Pesapal API 3.0 from a Laravel app. Pesapal hosts the payment page, so your customer chooses how to pay there (Pesapal’s status responses name methods such as M-Pesa, MTN, Tigo and cards). You’ll build:
- a
PesapalClientservice: a cached token, IPN registration, orders and transaction status, - a
pesapal_paymentstable and aPesapalPaymentsservice that saves each order before sending it, settles only fromGetTransactionStatus, and never settles a payment twice, - routes for starting a payment, the customer’s return and the IPN,
- Artisan commands to register the IPN and to reconcile pending payments,
- a feature test that runs every case without calling Pesapal.
Every code block was run in a fresh Laravel 13 app, and the test at the end passes. API facts come from Pesapal’s API 3.0 documentation, checked in November 2026. The Node.js version of this guide has the same design and explains each step in more detail.
How it works
- Token:
POST /api/Auth/RequestTokenwith your consumer key and secret. It lasts at most 5 minutes. - Register your IPN URL once per environment with
POST /api/URLSetup/RegisterIPN, and keep theipn_id. - Submit an order with
POST /api/Transactions/SubmitOrderRequest. Pesapal answers with anorder_tracking_idand aredirect_url; send the customer there. - Pesapal sends the customer back to your
callback_urland calls your IPN URL, both withOrderTrackingId,OrderMerchantReferenceandOrderNotificationType. Neither includes the status. - Check the status with
GET /api/Transactions/GetTransactionStatus?orderTrackingId=...:status_code1 COMPLETED, 2 FAILED, 3 REVERSED, 0 INVALID.
new ──start──▶ pending ──status 1, same amount & currency──▶ paid ──status 3──▶ reversed
├──status 1, other amount or currency──▶ review
├──status 2──▶ failed
└──no completed payment after an hour──▶ expired
Before you start
- Get sandbox keys: Pesapal publishes demo keys, linked from the authentication page of its developer docs. Live keys are emailed when your Pesapal business account opens.
- Your IPN URL must be public. For local testing, use a tunnel.
- Add the settings to
.envand never commit it:
PESAPAL_BASE_URL=https://cybqa.pesapal.com/pesapalv3
PESAPAL_CONSUMER_KEY=your-consumer-key
PESAPAL_CONSUMER_SECRET=your-consumer-secret
PESAPAL_IPN_ID=
Map them in config/services.php, and always read them through config():
<?php
// config/services.php
return [
// ...your existing services
'pesapal' => [
'base_url' => env('PESAPAL_BASE_URL', 'https://cybqa.pesapal.com/pesapalv3'), // live: https://pay.pesapal.com/v3
'consumer_key' => env('PESAPAL_CONSUMER_KEY'),
'consumer_secret' => env('PESAPAL_CONSUMER_SECRET'),
'ipn_id' => env('PESAPAL_IPN_ID'),
],
];
The payments table
php artisan make:model PesapalPayment -m
<?php
// database/migrations/xxxx_xx_xx_create_pesapal_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('pesapal_payments', function (Blueprint $table) {
$table->id();
$table->string('reference', 50)->unique(); // Pesapal's "id": yours, unique for every order request
$table->string('order_id', 64); // your own order
$table->unsignedBigInteger('amount_cents'); // 1500.50 is 150050, so amounts compare exactly
$table->string('currency', 3);
$table->string('email')->nullable();
$table->string('phone', 20)->nullable();
$table->string('description', 100);
$table->string('status', 10)->default('new'); // new | pending | paid | failed | review | reversed | expired
$table->string('tracking_id', 64)->nullable()->unique(); // Pesapal's order_tracking_id
$table->string('note')->nullable();
$table->timestamps();
$table->index(['status', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('pesapal_payments');
}
};
<?php
// app/Models/PesapalPayment.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PesapalPayment extends Model
{
/** Final: nothing changes them again. paid is not final: Pesapal can still report it REVERSED. */
public const FINAL = ['failed', 'review', 'reversed', 'expired'];
protected $fillable = ['reference', 'order_id', 'amount_cents', 'currency', 'email', 'phone', 'description', 'status', 'tracking_id', 'note'];
protected $casts = ['amount_cents' => 'integer'];
/** The amount as Pesapal takes it: 1500.5 */
public function amount(): float
{
return $this->amount_cents / 100;
}
}
Run php artisan migrate.
The Pesapal client
<?php
// app/Services/PesapalException.php
namespace App\Services;
use RuntimeException;
class PesapalException extends RuntimeException
{
/** ambiguous: the request may have worked. Look it up before doing anything else. */
public function __construct(string $message, public readonly int $status = 0, public readonly bool $ambiguous = false, public readonly array $error = [])
{
parent::__construct($message);
}
}
<?php
// app/Services/PesapalClient.php
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class PesapalClient
{
private function call(string $method, string $path, array $body = [], bool $auth = true): array
{
$http = Http::acceptJson()->asJson()->timeout(30);
if ($auth) {
$http = $http->withToken($this->token());
}
$url = rtrim((string) config('services.pesapal.base_url'), '/').$path;
try {
$res = $method === 'GET' ? $http->get($url) : $http->post($url, $body);
} catch (ConnectionException $e) {
throw new PesapalException("Pesapal unreachable: {$e->getMessage()}", ambiguous: true);
}
$data = $res->json();
if ($res->serverError() || ! is_array($data)) {
throw new PesapalException("Pesapal HTTP {$res->status()}", $res->status(), true);
}
// Successful answers can carry an error object whose fields are all null: only a code or message is an error.
$error = is_array($data['error'] ?? null) && (($data['error']['code'] ?? null) || ($data['error']['message'] ?? null)) ? $data['error'] : null;
if (! $res->successful() || $error) {
throw new PesapalException('Pesapal error: '.($error['code'] ?? $res->status()).' '.($error['message'] ?? $data['message'] ?? ''), $res->status(), false, $error ?? []);
}
return $data;
}
/** A bearer token, cached until 30 seconds before its expiryDate. Pesapal tokens last at most 5 minutes. */
public function token(): string
{
$cached = Cache::get('pesapal_token');
if (is_string($cached)) {
return $cached;
}
$data = $this->call('POST', '/api/Auth/RequestToken', [
'consumer_key' => config('services.pesapal.consumer_key'),
'consumer_secret' => config('services.pesapal.consumer_secret'),
], auth: false);
if (! is_string($data['token'] ?? null)) {
throw new PesapalException('No token in the response');
}
$expires = rescue(fn () => Carbon::parse($data['expiryDate'], 'UTC'), now()->addMinutes(4), report: false);
$expires = $expires->min(now()->addMinutes(5))->subSeconds(30);
if ($expires->isFuture()) {
Cache::put('pesapal_token', $data['token'], $expires);
}
return $data['token'];
}
/** Registers your IPN URL. Keep the ipn_id: it is the notification_id of every order in this environment. */
public function registerIpn(string $url, string $method = 'POST'): string
{
return (string) $this->call('POST', '/api/URLSetup/RegisterIPN', ['url' => $url, 'ipn_notification_type' => $method])['ipn_id'];
}
/** Creates the order. Returns order_tracking_id, merchant_reference and redirect_url. Nothing is paid yet. */
public function submitOrder(array $order): array
{
$data = $this->call('POST', '/api/Transactions/SubmitOrderRequest', $order);
if (empty($data['order_tracking_id']) || empty($data['redirect_url'])) {
throw new PesapalException('Order response without order_tracking_id or redirect_url');
}
return $data;
}
/** The source of truth: status_code 0 INVALID, 1 COMPLETED, 2 FAILED, 3 REVERSED. Any failure here is ambiguous. */
public function status(string $trackingId): array
{
try {
return $this->call('GET', '/api/Transactions/GetTransactionStatus?orderTrackingId='.rawurlencode($trackingId));
} catch (PesapalException $e) {
throw new PesapalException($e->getMessage(), $e->status, true, $e->error);
}
}
}
Use a cache store your servers share (Redis or the database) so they share the token.
Settle each payment once
<?php
// app/Services/PesapalPayments.php
namespace App\Services;
use App\Models\PesapalPayment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
use InvalidArgumentException;
class PesapalPayments
{
public function __construct(private readonly PesapalClient $pesapal)
{
}
/** Create the payment record. Its reference is fixed for life and used for only one order request. */
public function create(string $orderId, string $amount, string $currency, ?string $email, ?string $phone, string $description): PesapalPayment
{
if (! preg_match('/^(\d{1,12})(?:\.(\d{1,2}))?$/', trim($amount), $m)) {
throw new InvalidArgumentException('Enter an amount like 1500 or 1500.50');
}
$cents = (int) $m[1] * 100 + (int) str_pad($m[2] ?? '', 2, '0');
if ($cents <= 0) {
throw new InvalidArgumentException('Amount must be more than zero');
}
if (! preg_match('/^[A-Z]{3}$/', $currency)) {
throw new InvalidArgumentException('Use an ISO currency code, e.g. KES, UGX or TZS');
}
if (! $email && ! $phone) {
throw new InvalidArgumentException('Pesapal needs an email address or a phone number');
}
return PesapalPayment::create([
'reference' => 'pay-'.Str::uuid(), // 40 characters: letters, numbers and -, as Pesapal allows
'order_id' => $orderId, 'amount_cents' => $cents, 'currency' => $currency,
'email' => $email, 'phone' => $phone, 'description' => mb_substr($description, 0, 100), 'status' => 'new',
]);
}
/** Saves the payment as pending, then creates the Pesapal order. Returns the page to send the customer to. */
public function start(PesapalPayment $payment): string
{
$claimed = PesapalPayment::whereKey($payment->id)->where('status', 'new')->update(['status' => 'pending', 'updated_at' => now()]);
if ($claimed !== 1) {
throw new InvalidArgumentException('This payment was already sent to Pesapal'); // a double click
}
try {
$order = $this->pesapal->submitOrder([
'id' => $payment->reference,
'currency' => $payment->currency,
'amount' => $payment->amount(),
'description' => $payment->description,
'callback_url' => route('pesapal.callback'),
'notification_id' => config('services.pesapal.ipn_id'),
'billing_address' => array_filter(['email_address' => $payment->email, 'phone_number' => $payment->phone]),
]);
} catch (PesapalException $e) {
if (! $e->ambiguous) {
PesapalPayment::whereKey($payment->id)->where('status', 'pending')->whereNull('tracking_id')
->update(['status' => 'failed', 'note' => mb_substr((string) ($e->error['code'] ?? $e->getMessage()), 0, 190)]);
}
// Ambiguous: the order may exist. The payment stays pending; its IPN or callback can still settle it.
throw $e;
}
PesapalPayment::whereKey($payment->id)->update(['tracking_id' => $order['order_tracking_id']]);
return $order['redirect_url'];
}
private function row(PesapalPayment $payment): Builder
{
return PesapalPayment::whereKey($payment->id);
}
/** Applies a GetTransactionStatus answer. Every update is conditional, so a stale copy can't undo a result. */
public function settle(PesapalPayment $payment, array $tx): PesapalPayment
{
if (($tx['merchant_reference'] ?? null) !== $payment->reference) {
return $payment->fresh(); // not this payment
}
$code = (int) ($tx['status_code'] ?? -1);
if ($code === 3) { // a reversal only follows a payment
$this->row($payment)->where('status', 'paid')->update(['status' => 'reversed', 'note' => 'REVERSED']);
} elseif ($code === 1) {
$cents = (int) round(((float) ($tx['amount'] ?? -1)) * 100);
$same = is_numeric($tx['amount'] ?? null) && $cents === $payment->amount_cents && strtoupper((string) ($tx['currency'] ?? '')) === $payment->currency;
$this->row($payment)->where('status', 'pending')->update($same
? ['status' => 'paid', 'note' => $tx['confirmation_code'] ?? null]
: ['status' => 'review', 'note' => 'PAID '.($tx['amount'] ?? '?').' '.($tx['currency'] ?? '?')]);
} elseif ($code === 2) {
$this->row($payment)->where('status', 'pending')->update(['status' => 'failed', 'note' => mb_substr((string) ($tx['description'] ?? '') ?: 'FAILED', 0, 190)]);
}
// 0 (INVALID) or anything else: not paid yet. reconcile() expires it eventually.
return $payment->fresh();
}
/**
* The callback redirect or an IPN. Neither carries the status: they only say which order to look up,
* and the tracking id must be the one Pesapal gave us for this reference.
*/
public function handleNotification(?string $trackingId, ?string $reference): ?PesapalPayment
{
if (! $trackingId || ! $reference) {
return null;
}
$payment = PesapalPayment::where('reference', $reference)->first();
if ($payment === null || ($payment->tracking_id && $payment->tracking_id !== $trackingId)) {
return null; // not ours, or not this payment's order
}
$tx = $this->pesapal->status($trackingId); // throws if Pesapal is unreachable
if (! $payment->tracking_id && ($tx['merchant_reference'] ?? null) === $payment->reference) {
$this->row($payment)->whereNull('tracking_id')->update(['tracking_id' => $trackingId]); // the order whose answer we lost
}
return $this->settle($payment, $tx);
}
/** For payments still pending: asks Pesapal, and expires those that never completed. */
public function reconcile(PesapalPayment $payment): PesapalPayment
{
if ($payment->status !== 'pending') {
return $payment;
}
if ($payment->tracking_id) {
try {
$payment = $this->settle($payment, $this->pesapal->status($payment->tracking_id));
} catch (PesapalException) {
return $payment; // Pesapal unreachable: try again on the next run
}
}
if ($payment->status === 'pending' && $payment->created_at->lt(now()->subHour())) {
$this->row($payment)->where('status', 'pending')->update(['status' => 'expired', 'note' => 'NO_PAYMENT']);
}
return $payment->fresh();
}
}
The rules:
- Save before you send.
start()moves the payment fromnewtopendingin one conditional update before calling Pesapal, so a double click can’t create two orders. - Never settle from the callback or IPN. They only say which order to look up, and the tracking id must match the one Pesapal gave you.
- Paid means status 1 with the same amount and currency. Anything else goes to
review. - Final means final.
failed,review,reversedandexpirednever change, andpaidchanges only toreversed. Every update names the status it expects, so a stale copy of the model can’t undo a result. - Reconcile. A scheduled command checks pending payments and expires them after an hour.
Routes and controller
<?php
// app/Http/Controllers/PesapalController.php
namespace App\Http\Controllers;
use App\Services\PesapalException;
use App\Services\PesapalPayments;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class PesapalController extends Controller
{
public function __construct(private readonly PesapalPayments $payments)
{
}
/** Starts a payment from your checkout page, and returns the Pesapal page to send the customer to. */
public function start(Request $request): JsonResponse
{
$data = $request->validate([
'order_id' => ['required', 'string', 'max:64'],
'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'],
'currency' => ['required', 'string', 'size:3'],
'email' => ['required', 'email'],
]);
$payment = $this->payments->create($data['order_id'], $data['amount'], strtoupper($data['currency']), $data['email'], null, "Order {$data['order_id']}");
try {
return response()->json(['reference' => $payment->reference, 'redirect_url' => $this->payments->start($payment)]);
} catch (PesapalException) {
return response()->json(['error' => 'Could not start the payment. Please try again.'], 502);
}
}
/** The customer comes back here. Check the status before showing anything. */
public function callback(Request $request)
{
try {
$payment = $this->payments->handleNotification($request->query('OrderTrackingId'), $request->query('OrderMerchantReference'));
} catch (PesapalException) {
$payment = null;
}
return response($payment?->status === 'paid' ? 'Payment received. Thank you!' : 'We are confirming your payment. This page will update shortly.');
}
/** The IPN: Pesapal calls it when a payment status changes. */
public function ipn(Request $request): JsonResponse
{
$tracking = $request->input('OrderTrackingId');
$reference = $request->input('OrderMerchantReference');
$echo = ['orderNotificationType' => $request->input('OrderNotificationType'), 'orderTrackingId' => $tracking, 'orderMerchantReference' => $reference];
try {
$this->payments->handleNotification(is_string($tracking) ? $tracking : null, is_string($reference) ? $reference : null);
} catch (PesapalException $e) {
Log::warning('Pesapal IPN not processed', ['reference' => $reference, 'error' => $e->getMessage()]);
return response()->json($echo + ['status' => 500], 500);
}
return response()->json($echo + ['status' => 200]);
}
}
<?php
// routes/web.php (add to the end of the file; it already imports Route)
use App\Http\Controllers\PesapalController;
Route::post('/pesapal/pay', [PesapalController::class, 'start']); // from your own checkout page, with its CSRF token
Route::get('/pesapal/callback', [PesapalController::class, 'callback'])->name('pesapal.callback');
Route::match(['get', 'post'], '/pesapal/ipn', [PesapalController::class, 'ipn']); // CSRF off for this one in bootstrap/app.php
Pesapal can’t send a CSRF token, so exclude the IPN route in bootstrap/app.php:
<?php
// bootstrap/app.php, inside ->withMiddleware(...)
$middleware->validateCsrfTokens(except: ['pesapal/ipn']);
The IPN answer. Pesapal’s API 3.0 pages say what it sends to your IPN URL but not what your server must answer. This controller answers HTTP 200 with the three values it received and status: 200 once the payment is settled, and 500 if it couldn’t ask Pesapal. Confirm in the sandbox that Pesapal accepts it, and keep the reconcile command running: it settles any payment whose IPN went wrong.
Register the IPN and reconcile
<?php
// routes/console.php (the whole file; keep any commands you already have, and don't repeat a use line)
use App\Models\PesapalPayment;
use App\Services\PesapalClient;
use App\Services\PesapalPayments;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
// Once per environment: php artisan pesapal:register-ipn https://your-app.com/pesapal/ipn
Artisan::command('pesapal:register-ipn {url} {--method=POST}', function (PesapalClient $pesapal) {
$this->line('PESAPAL_IPN_ID='.$pesapal->registerIpn($this->argument('url'), strtoupper($this->option('method'))));
})->purpose('Register the Pesapal IPN URL and print its ipn_id');
Artisan::command('pesapal:reconcile', function (PesapalPayments $payments) {
PesapalPayment::where('status', 'pending')->where('updated_at', '<=', now()->subMinute())
->orderBy('id')->limit(100)->get()
->each(fn (PesapalPayment $p) => $payments->reconcile($p));
})->purpose('Check pending Pesapal payments and expire the ones that never completed');
Schedule::command('pesapal:reconcile')->everyFiveMinutes()->withoutOverlapping();
Put the printed PESAPAL_IPN_ID in .env. The sandbox and live sites are separate, so register once in each. The scheduler only runs if cron calls it: * * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1.
Test it without calling Pesapal
This test fakes Pesapal with Http::fake(), including the all-null error object Pesapal’s status answers carry. It checks that a payment is saved before the order is sent and sent only once, that the callback and IPN are never trusted, that another order’s tracking id can’t settle a payment, that a different amount goes to review, that a reversal only follows a payment, that final results never change, and that the token is cached.
<?php
// tests/Feature/PesapalPaymentsTest.php
namespace Tests\Feature;
use App\Models\PesapalPayment;
use App\Services\PesapalPayments;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use InvalidArgumentException;
use Tests\TestCase;
class PesapalPaymentsTest extends TestCase
{
use RefreshDatabase;
/** @var array<string, array> orders by tracking id, the way Pesapal keeps them */
private array $orders = [];
private int $tokens = 0;
private array $statusAtSubmit = [];
/** Next SubmitOrderRequest: 'ok', 'refused' or 'timeout'. */
private string $next = 'ok';
private bool $down = false;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
config(['services.pesapal' => ['base_url' => 'https://pesapal.test/v3', 'consumer_key' => 'key', 'consumer_secret' => 'secret', 'ipn_id' => 'e7b1f5a6-1111-4d2c-9e3b-0d5c7e8a9f10']]);
Http::preventStrayRequests();
Http::fake(function (Request $r) {
$path = parse_url($r->url(), PHP_URL_PATH);
if ($path === '/v3/api/Auth/RequestToken') {
$this->tokens++;
return Http::response(['token' => "tok-{$this->tokens}", 'expiryDate' => now('UTC')->addMinutes(5)->format('Y-m-d\TH:i:s.u'), 'error' => null, 'status' => '200']);
}
if ($r->header('Authorization')[0] !== "Bearer tok-{$this->tokens}") {
return Http::response(['error' => ['type' => 'error', 'code' => 'invalid_token', 'message' => 'Unauthorized']], 401);
}
if ($path === '/v3/api/URLSetup/RegisterIPN') {
return Http::response(['url' => $r['url'], 'ipn_id' => 'ipn-guid-1', 'error' => null, 'status' => '200']);
}
if ($path === '/v3/api/Transactions/SubmitOrderRequest') {
$this->statusAtSubmit[] = PesapalPayment::where('reference', $r['id'])->value('status');
$mode = $this->next;
$this->next = 'ok';
if ($mode === 'refused') {
return Http::response(['error' => ['type' => 'error', 'code' => 'invalid_notification_id', 'message' => 'Invalid notification id'], 'status' => '500']);
}
$id = '7e6b62d9-883e-440f-a63e-'.str_pad((string) (count($this->orders) + 1), 12, '0', STR_PAD_LEFT);
$this->orders[$id] = ['merchant_reference' => $r['id'], 'amount' => $r['amount'], 'currency' => $r['currency'], 'status_code' => 0, 'payment_status_description' => 'INVALID'];
if ($mode === 'timeout') {
return Http::response(null, 504);
}
return Http::response(['order_tracking_id' => $id, 'merchant_reference' => $r['id'], 'redirect_url' => "https://cybqa.pesapal.com/iframe?OrderTrackingId={$id}", 'error' => null, 'status' => '200']);
}
if ($path === '/v3/api/Transactions/GetTransactionStatus') {
if ($this->down) {
return Http::response(null, 503);
}
parse_str((string) parse_url($r->url(), PHP_URL_QUERY), $q);
$tx = $this->orders[$q['orderTrackingId'] ?? ''] ?? null;
return $tx
? Http::response($tx + ['error' => ['error_type' => null, 'code' => null, 'message' => null], 'status' => '200'])
: Http::response(['error' => ['error_type' => 'api_error', 'code' => 'order_not_found', 'message' => 'Order not found'], 'status' => '500']);
}
return Http::response(null, 500);
});
}
private function payment(string $amount = '1500'): PesapalPayment
{
return app(PesapalPayments::class)->create('A1001', $amount, 'KES', 'jane@example.com', null, 'Order A1001');
}
private function started(string $amount = '1500'): PesapalPayment
{
$p = $this->payment($amount);
app(PesapalPayments::class)->start($p);
return $p->fresh();
}
private function pay(string $trackingId, array $extra = []): void
{
$this->orders[$trackingId] = ['status_code' => 1, 'payment_status_description' => 'Completed', 'confirmation_code' => 'RKL12AB3CD'] + $extra + $this->orders[$trackingId];
}
private function ipn(PesapalPayment $p, ?string $tracking = null)
{
return $this->postJson('/pesapal/ipn', ['OrderTrackingId' => $tracking ?? $p->tracking_id, 'OrderMerchantReference' => $p->reference, 'OrderNotificationType' => 'IPNCHANGE']);
}
public function test_saved_before_sending_sent_once_and_paid_only_from_the_status_check(): void
{
$p = $this->payment();
$url = app(PesapalPayments::class)->start($p);
$this->assertStringStartsWith('https://cybqa.pesapal.com/', $url);
$this->assertSame(['pending'], $this->statusAtSubmit);
try {
app(PesapalPayments::class)->start($p); // a double click
$this->fail('sent twice');
} catch (InvalidArgumentException) {
$this->assertCount(1, $this->orders);
}
$req = Http::recorded(fn ($r) => str_ends_with($r->url(), 'SubmitOrderRequest'))->first()[0];
$this->assertSame([$p->reference, 'KES', 1500.0, 'e7b1f5a6-1111-4d2c-9e3b-0d5c7e8a9f10', ['email_address' => 'jane@example.com']],
[$req['id'], $req['currency'], $req['amount'], $req['notification_id'], $req['billing_address']]);
$this->assertSame(route('pesapal.callback'), $req['callback_url']);
$p = $p->fresh();
$this->ipn($p)->assertOk()->assertJson(['orderNotificationType' => 'IPNCHANGE', 'orderTrackingId' => $p->tracking_id, 'status' => 200]);
$this->assertSame('pending', $p->fresh()->status); // INVALID: not paid
$this->pay($p->tracking_id);
$this->get("/pesapal/callback?OrderTrackingId={$p->tracking_id}&OrderMerchantReference={$p->reference}&OrderNotificationType=CALLBACKURL")
->assertSee('Payment received');
$this->assertSame(['paid', 'RKL12AB3CD'], [$p->fresh()->status, $p->fresh()->note]);
}
public function test_the_callback_and_ipn_are_never_trusted(): void
{
$mine = $this->started();
$other = $this->started('10');
$this->pay($other->tracking_id, ['amount' => 10]);
$this->ipn($mine, $other->tracking_id)->assertOk(); // our reference, another order's tracking id
$this->assertSame('pending', $mine->fresh()->status);
$this->postJson('/pesapal/ipn', ['OrderTrackingId' => $mine->tracking_id, 'OrderMerchantReference' => 'not-ours'])->assertOk();
$this->postJson('/pesapal/ipn', ['OrderMerchantReference' => $mine->reference])->assertOk();
$this->assertSame('pending', $mine->fresh()->status);
$this->assertSame('pending', app(PesapalPayments::class)->settle($mine, ['merchant_reference' => $other->reference, 'status_code' => 1, 'amount' => 1500, 'currency' => 'KES'])->status);
}
public function test_a_different_amount_or_currency_goes_to_review_and_final_means_final(): void
{
$payments = app(PesapalPayments::class);
$p = $this->started();
$this->pay($p->tracking_id, ['amount' => 150]);
$this->ipn($p)->assertOk();
$this->assertSame(['review', 'PAID 150 KES'], [$p->fresh()->status, $p->fresh()->note]);
$q = $this->started();
$this->pay($q->tracking_id, ['currency' => 'UGX']);
$this->assertSame('review', $payments->reconcile($q)->status);
$this->orders[$q->tracking_id]['currency'] = 'KES';
$this->assertSame('review', $payments->settle($q, $this->orders[$q->tracking_id])->status); // even through an old copy
$f = $this->started();
$this->orders[$f->tracking_id] = ['status_code' => 2, 'description' => 'Insufficient funds'] + $this->orders[$f->tracking_id];
$this->assertSame(['failed', 'Insufficient funds'], [($x = $payments->reconcile($f))->status, $x->note]);
$this->pay($f->tracking_id);
$this->assertSame('failed', $payments->settle($f, $this->orders[$f->tracking_id])->status);
}
public function test_only_a_reversal_changes_a_paid_payment_and_a_reversal_needs_a_payment_first(): void
{
$payments = app(PesapalPayments::class);
$p = $this->started();
$this->orders[$p->tracking_id]['status_code'] = 3;
$this->assertSame('pending', $payments->reconcile($p)->status);
$this->pay($p->tracking_id);
$this->assertSame('paid', $payments->reconcile($p->fresh())->status);
$this->orders[$p->tracking_id]['status_code'] = 2;
$this->assertSame('paid', $payments->settle($p, $this->orders[$p->tracking_id])->status); // a stale failure can't un-pay it
$this->orders[$p->tracking_id]['status_code'] = 3;
$this->ipn($p)->assertOk();
$this->assertSame('reversed', $p->fresh()->status);
}
public function test_refused_lost_and_unreachable(): void
{
$payments = app(PesapalPayments::class);
$this->next = 'refused';
$this->postJson('/pesapal/pay', ['order_id' => 'A1', 'amount' => '1500', 'currency' => 'KES', 'email' => 'jane@example.com'])->assertStatus(502);
$this->next = 'refused';
$r = $this->payment();
try {
$payments->start($r);
} catch (\App\Services\PesapalException) {
}
$this->assertSame(['failed', 'invalid_notification_id'], [$r->fresh()->status, $r->fresh()->note]);
$this->next = 'timeout';
$lost = $this->payment();
try {
$payments->start($lost);
$this->fail('no exception');
} catch (\App\Services\PesapalException $e) {
$this->assertTrue($e->ambiguous);
}
$this->assertSame(['pending', null], [$lost->fresh()->status, $lost->fresh()->tracking_id]);
// Pesapal did create it: the IPN brings the tracking id, and the status check confirms it is ours.
$tracking = array_key_last($this->orders);
$this->pay($tracking);
$this->ipn($lost->fresh(), $tracking)->assertOk();
$this->assertSame(['paid', $tracking], [$lost->fresh()->status, $lost->fresh()->tracking_id]);
$p = $this->started();
$this->down = true;
$this->assertSame('pending', $payments->reconcile($p)->status);
$this->ipn($p)->assertStatus(500)->assertJson(['status' => 500]);
}
public function test_reconcile_expires_old_payments_and_the_commands_run(): void
{
$p = $this->started();
PesapalPayment::query()->update(['created_at' => now()->subHours(2), 'updated_at' => now()->subHours(2)]);
$this->artisan('pesapal:reconcile')->assertSuccessful();
$this->assertSame('expired', $p->fresh()->status);
$this->pay($p->tracking_id);
$this->ipn($p)->assertOk();
$this->assertSame('expired', $p->fresh()->status);
$this->artisan('pesapal:register-ipn', ['url' => 'https://shop.example.com/pesapal/ipn'])->expectsOutput('PESAPAL_IPN_ID=ipn-guid-1')->assertSuccessful();
}
public function test_the_token_is_cached_and_bad_input_is_refused(): void
{
$this->started();
$this->started();
$this->assertSame(1, $this->tokens);
$this->travel(5)->minutes();
$this->started();
$this->assertSame(2, $this->tokens);
foreach ([['1.234', 'KES', 'a@b.c'], ['0', 'KES', 'a@b.c'], ['5', 'kes', 'a@b.c'], ['5', 'KES', null]] as [$amount, $currency, $email]) {
try {
app(PesapalPayments::class)->create('x', $amount, $currency, $email, null, 'x');
$this->fail("$amount $currency was accepted");
} catch (InvalidArgumentException) {
$this->addToAssertionCount(1);
}
}
$this->assertMatchesRegularExpression('/^[A-Za-z0-9\-_.:]{1,50}$/', $this->payment()->reference);
}
}
php artisan test --filter PesapalPaymentsTest
Common errors and fixes
Every Pesapal status and the error format are explained in the Pesapal error reference.
| Symptom | Likely cause | Fix |
|---|---|---|
| Calls start failing after a few minutes | The token expired (at most 5 minutes) | Use token(), which caches until 30 seconds before expiryDate |
| SubmitOrderRequest refused | PESAPAL_IPN_ID missing, or registered in the other environment |
Run pesapal:register-ipn against the same base URL |
419 on the IPN |
CSRF is on for the route | Add pesapal/ipn to validateCsrfTokens(except: ...) |
| Payments never become paid | Code treats the all-null error object in status answers as an error | Only a code or message is an error, as in PesapalClient::call() |
| Status stays INVALID (0) | The customer hasn’t paid | Leave it pending; pesapal:reconcile expires it after an hour |
review |
Completed with a different amount or currency | Look at it in the Pesapal dashboard before giving value |
Going to production
- Set
PESAPAL_BASE_URLtohttps://pay.pesapal.com/v3, use your live consumer key and secret, and register the IPN on the live site. - Use a shared cache store for the token, and keep
pesapal:reconcilerunning. - Send one small real payment end to end before launch.
Want this done for you, alongside MTN MoMo, M-Pesa, Airtel Money, Flutterwave and Paystack? The Laravel starter kit includes Pesapal.
Using Django? See Pesapal API 3.0 in Django.
Using the Next.js App Router? See Pesapal API 3.0 in Next.js: the same design in route handlers.
Frequently asked questions
How do I cache the Pesapal API 3.0 token in Laravel?
Pesapal says a token lasts at most 5 minutes and returns its expiryDate in UTC. Put it in the cache until 30 seconds before that time, with Cache::put, and request a new one only when the cache is empty.
Why does my Pesapal IPN route return 419 in Laravel?
CSRF protection. Pesapal can't send a CSRF token, so exclude the IPN route in bootstrap/app.php with validateCsrfTokens(except: [...]). The IPN carries no payment status, so nothing it says is trusted anyway: the status comes from GetTransactionStatus.
Should my Pesapal IPN route accept GET or POST?
Whichever you registered. RegisterIPN takes ipn_notification_type GET or POST, and Pesapal calls the URL with that method. This guide registers POST and also accepts GET, with Route::match.
Does the Pesapal callback mean the customer paid?
No. Pesapal says the callback and IPN don't carry the payment status. Call GetTransactionStatus with the OrderTrackingId; only status_code 1 (COMPLETED), with your amount and currency, means paid.