Pesapal is a leading pan-African payment gateway that enables businesses to accept payments from customers across multiple African countries. This integration supports various payment methods including mobile money (M-Pesa, Airtel Money), credit/debit cards, and bank transfers, making it ideal for businesses operating in Africa.
| Country | Currency | Code | Mobile Money Options |
|---|---|---|---|
| Kenya | Kenyan Shilling | KES | M-Pesa, Airtel Money, Tigo Pesa |
| Uganda | Ugandan Shilling | UGX | MTN Mobile Money, Airtel Money |
| Tanzania | Tanzanian Shilling | TZS | M-Pesa, Tigo Pesa, Airtel Money |
| Rwanda | Rwandan Franc | RWF | MTN Mobile Money, Airtel Money |
| Malawi | Malawian Kwacha | MWK | TNM Mpamba, Airtel Money |
| Zambia | Zambian Kwacha | ZMW | MTN Mobile Money, Airtel Money |
| Zimbabwe | USD/ZWL | USD | EcoCash, OneMoney |
| Method | Code | Description |
|---|---|---|
| Credit/Debit Card | card | Visa, Mastercard |
| Mobile Money | mobile | M-Pesa, Airtel Money, Tigo Pesa, MTN Mobile Money |
| Bank Transfer | bank_transfer | Direct bank deposit |
| Pesapal Wallet | pesapal_wallet | Pesapal digital wallet |
composer require njoguamos/laravel-pesapal
Add these variables to your .env file:
# Pesapal Configuration
PESAPAL_CONSUMER_KEY=YOUR_CONSUMER_KEY
PESAPAL_CONSUMER_SECRET=YOUR_CONSUMER_SECRET
PESAPAL_TEST_MODE=true
PESAPAL_WEBHOOK_URL=https://yourapp.com/payment/pesapal/webhook
PESAPAL_RETURN_URL=https://yourapp.com/payment/pesapal/success
PESAPAL_BRANCH=MAIN
php artisan vendor:publish --provider="Mdiqbal\LaravelPayments\PaymentsServiceProvider"
// config/payments.php
'gateways' => [
'pesapal' => [
'driver' => 'pesapal',
'consumer_key' => env('PESAPAL_CONSUMER_KEY'),
'consumer_secret' => env('PESAPAL_CONSUMER_SECRET'),
'test_mode' => env('PESAPAL_TEST_MODE', true),
'webhook_url' => env('PESAPAL_WEBHOOK_URL'),
'return_url' => env('PESAPAL_RETURN_URL'),
'branch' => env('PESAPAL_BRANCH', 'MAIN'),
],
],
Once approved:
In your Pesapal dashboard:
https://yourapp.com/payment/pesapal/webhookuse Mdiqbal\LaravelPayments\Facades\Payment;
use Mdiqbal\LaravelPayments\DTOs\PaymentRequest;
// Initialize Pesapal gateway
$payment = Payment::gateway('pesapal');
// Create a payment request
$response = $payment->pay(new PaymentRequest(
amount: 1000.00,
currency: 'KES',
orderId: 'ORDER-' . uniqid(),
description: 'Product purchase',
customer: [
'name' => 'John Doe',
'email' => 'john@example.com',
'phone' => '+254712345678',
'address' => 'Nairobi, Kenya',
'city' => 'Nairobi',
'country' => 'Kenya',
],
returnUrl: route('payment.success'),
notifyUrl: route('payment.webhook'),
metadata: [
'language' => 'EN', // or 'SW' for Swahili
]
));
if ($response->success) {
// Redirect to Pesapal payment page
return redirect($response->redirectUrl);
} else {
// Handle error
return back()->with('error', $response->message);
}
$response = $payment->pay(new PaymentRequest(
amount: 2500.00,
currency: 'KES',
orderId: 'MPESA-' . uniqid(),
description: 'M-Pesa payment',
customer: [
'name' => 'Jane Smith',
'email' => 'jane@example.com',
'phone' => '+254722345678', // M-Pesa registered number
],
metadata: [
'payment_method' => 'MPESA',
'language' => 'EN',
]
));
$linkResponse = $payment->createPaymentLink([
'amount' => 5000.00,
'currency' => 'KES',
'id' => 'LINK_' . time(),
'description' => 'Invoice payment',
'customer_name' => 'David Mwangi',
'customer_email' => 'david@example.com',
'customer_phone' => '+254733456789',
'redirect_url' => route('payment.success'),
'callback_url' => route('payment.webhook'),
]);
if ($linkResponse->success) {
$paymentUrl = $linkResponse->redirectUrl;
// Send payment link via SMS or email
}
// Check payment status using order tracking ID
$orderTrackingId = 'PESAPAL-123456789';
$response = $payment->verify(['order_tracking_id' => $orderTrackingId]);
if ($response->success) {
echo "Payment Status: " . $response->status;
echo "Amount: " . $response->data['amount'];
echo "Currency: " . $response->data['currency'];
echo "Payment Method: " . $response->data['payment_method'];
if ($response->status === 'completed') {
// Update order status
// Send confirmation email
// Process order fulfillment
}
}
// Process a refund
$refundResponse = $payment->refund([
'order_tracking_id' => 'PESAPAL-123456789',
'amount' => 500.00,
'reason' => 'Customer requested refund'
]);
if ($refundResponse->success) {
echo "Refund processed successfully";
echo "Refund Reference: " . $refundResponse->data['refund_reference'];
}
$response = $payment->pay(new PaymentRequest(
amount: 150000.00,
currency: 'UGX', // Ugandan Shillings
orderId: 'UG-' . uniqid(),
description: 'Payment for service',
customer: [
'name' => 'Sarah Katumba',
'email' => 'sarah@example.com',
'phone' => '+256772123456', // MTN Uganda number
'city' => 'Kampala',
'country' => 'Uganda',
],
metadata: [
'payment_method' => 'MTN_MOBILE_MONEY',
'language' => 'EN',
]
));
// routes/web.php
Route::post('/payment/pesapal/webhook', [PesapalController::class, 'handleWebhook'])
->name('payment.pesapal.webhook')
->middleware('ipn.whitelist');
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Mdiqbal\LaravelPayments\Facades\Payment;
class PesapalController extends Controller
{
/**
* Handle Pesapal IPN (Instant Payment Notification)
*/
public function handleWebhook(Request $request)
{
$gateway = Payment::gateway('pesapal');
// Process IPN
$response = $gateway->processWebhook($request->all());
if ($response->success) {
// Extract IPN data
$ipnData = $response->data;
$status = $response->status;
// Process based on status
switch ($status) {
case 'completed':
$this->handleSuccessfulPayment($ipnData);
break;
case 'failed':
$this->handleFailedPayment($ipnData);
break;
case 'cancelled':
$this->handleCancelledPayment($ipnData);
break;
}
return response('IPN processed successfully');
}
return response('IPN processing failed', 400);
}
/**
* Handle successful payment
*/
private function handleSuccessfulPayment(array $data)
{
// Update your database
DB::table('payments')
->where('order_tracking_id', $data['order_tracking_id'])
->update([
'status' => 'completed',
'pesapal_transaction_id' => $data['order_id'] ?? null,
'payment_method' => $data['payment_method'] ?? null,
'confirmation_code' => $data['confirmation_code'] ?? null,
'payment_account' => $data['payment_account'] ?? null,
'paid_at' => now()
]);
// Update order status
DB::table('orders')
->where('order_tracking_id', $data['order_tracking_id'])
->update(['status' => 'paid']);
// Send confirmation email
// Generate receipt
// Trigger fulfillment process
}
/**
* Handle failed payment
*/
private function handleFailedPayment(array $data)
{
DB::table('payments')
->where('order_tracking_id', $data['order_tracking_id'])
->update([
'status' => 'failed',
'failure_reason' => $data['status'] ?? 'Payment failed',
'failed_at' => now()
]);
// Notify customer
// Log the failure for review
}
/**
* Handle cancelled payment
*/
private function handleCancelledPayment(array $data)
{
DB::table('payments')
->where('order_tracking_id', $data['order_tracking_id'])
->update([
'status' => 'cancelled',
'cancelled_at' => now()
]);
// Notify customer
// Restore inventory if needed
}
}
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class IpnWhitelist
{
// Pesapal IP ranges (check documentation for latest IPs)
private $allowedIps = [
'196.201.214.200',
'196.201.214.206',
'196.201.214.207',
'196.201.214.208',
// Add more IPs as provided by Pesapal
];
public function handle(Request $request, Closure $next)
{
if (!in_array($request->ip(), $this->allowedIps)) {
Log::warning('Unauthorized IPN attempt', [
'ip' => $request->ip(),
'data' => $request->all()
]);
abort(403, 'Unauthorized');
}
return $next($request);
}
}
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Mdiqbal\LaravelPayments\Facades\Payment;
class PaymentController extends Controller
{
/**
* Handle successful payment callback
*/
public function handleSuccess(Request $request)
{
$orderTrackingId = $request->get('OrderTrackingId');
$merchantReference = $request->get('OrderMerchantReference');
if (!$orderTrackingId) {
return redirect()->route('checkout')
->with('error', 'Invalid payment reference');
}
// Verify payment status
$gateway = Payment::gateway('pesapal');
$response = $gateway->verify(['order_tracking_id' => $orderTrackingId]);
if ($response->success && $response->status === 'completed') {
return view('payment.success', [
'transaction_id' => $response->transactionId,
'amount' => $response->data['amount'],
'currency' => $response->data['currency'],
'payment_method' => $response->data['payment_method'],
'confirmation_code' => $response->data['confirmation_code'],
]);
}
// Payment not completed yet
return redirect()->route('payment.pending')
->with('order_tracking_id', $orderTrackingId);
}
/**
* Show pending payment page
*/
public function showPending(Request $request)
{
$orderTrackingId = $request->session('order_tracking_id');
if (!$orderTrackingId) {
return redirect()->route('home');
}
return view('payment.pending', [
'order_tracking_id' => $orderTrackingId,
'check_url' => route('payment.check.status'),
]);
}
}
Pesapal provides a sandbox environment:
PESAPAL_TEST_MODE=true
Get test credentials from Pesapal:
// Test mobile money payment
$testMobilePayment = $payment->pay(new PaymentRequest(
amount: 100.00,
currency: 'KES',
orderId: 'TEST_' . time(),
description: 'Test payment',
customer: [
'name' => 'Test User',
'email' => 'test@example.com',
'phone' => '+254712345678',
],
metadata: [
'payment_method' => 'MPESA',
]
));
// Verify phone number format for specific countries
function validatePhoneNumber($phone, $country) {
$patterns = [
'KE' => '/^\+2547[0-9]{8}$/', // Kenya
'UG' => '/^\+2567[0-9]{8}$/', // Uganda
'TZ' => '/^\+255[67][0-9]{8}$/', // Tanzania
];
return isset($patterns[$country]) && preg_match($patterns[$country], $phone);
}
| Code | Description | Solution |
|---|---|---|
| 400 | Bad Request | Check request parameters |
| 401 | Unauthorized | Verify Consumer Key/Secret |
| 403 | Forbidden | Check IP whitelist |
| 404 | Not Found | Verify endpoint URL |
| 500 | Server Error | Retry with backoff |
| 1001 | Invalid Amount | Check amount format |
| 1002 | Invalid Currency | Use supported currency |
| 1003 | Invalid Phone | Verify phone number format |
try {
$response = $payment->pay($paymentRequest);
if (!$response->success) {
// Log error details
Log::error('Pesapal payment failed', [
'error_code' => $response->errorCode,
'message' => $response->message,
'order_id' => $paymentRequest->orderId
]);
// Show user-friendly message based on error
$userMessage = $this->getUserFriendlyErrorMessage($response->errorCode);
return back()->with('error', $userMessage);
}
} catch (\Exception $e) {
Log::error('Pesapal gateway error', [
'error' => $e->getMessage()
]);
return back()->with('error', 'Payment service temporarily unavailable.');
}
private function getUserFriendlyErrorMessage(string $errorCode): string
{
$errorMessages = [
'1001' => 'Invalid payment amount. Please check the amount.',
'1002' => 'Currency not supported. Please use a supported currency.',
'1003' => 'Invalid phone number. Please check the format.',
'1004' => 'Payment method not available. Please try another method.',
];
return $errorMessages[$errorCode] ?? 'Payment failed. Please try again.';
}
// Country-specific mobile money detection
function getMobileMoneyOperator($phone, $country) {
$operators = [
'KE' => [
'0711' => 'Safaricom M-Pesa',
'0757' => 'Airtel Money',
'0765' => 'Tigo Pesa',
],
'UG' => [
'0772' => 'MTN Mobile Money',
'0757' => 'Airtel Money',
],
];
$prefix = substr($phone, 4, 4); // Get country code prefix + first digits
return $operators[$country][$prefix] ?? 'Unknown';
}
Pesapal supports multiple languages:
// English (default)
'language' => 'EN'
// Swahili
'language' => 'SW'
Authentication Failed
Mobile Money Not Working
IPN Not Received
Cross-border Issues
Enable debug logging:
// config/payments.php
'pesapal' => [
// ... other config
'debug' => env('APP_DEBUG', false),
],
This will log all Pesapal requests and responses for debugging purposes.
How can I help you explore Laravel packages today?