laravel-paynexus maintained by paynexus
PayNexus Laravel Plugin
Accept M-Pesa payments through PayNexus in any Laravel application
A powerful client SDK that connects your Laravel application to the PayNexus payment platform, handling M-Pesa STK Push, real-time payment status tracking, webhook processing, and automatic local record-keeping.
Documentation • Installation • Quick Start • API Reference
✨ Features
- 🚀 Easy Integration - Simple facade-based API for quick setup
- 💳 M-Pesa STK Push - Seamless mobile payment initiation
- 🔄 Real-time Tracking - Poll for payment completion or use webhooks
- 📊 Local Records - Automatic database synchronization
- 🔔 Webhook Events - Laravel events for payment state changes
- 🔒 Secure - HMAC signature verification for webhooks
- 🎯 Polymorphic Relations - Link payments to any model (Order, Invoice, etc.)
- ✅ Phone Validation - Built-in phone number normalization
⚠️ Plugin vs Platform
This plugin is for external Laravel applications (e.g., ecommerce stores, SaaS apps) that want to accept payments through PayNexus.
| Component | Description |
|---|---|
| PayNexus Platform | The payment gateway platform at paynexus.co.ke that processes payments |
| This Plugin | A Laravel package that YOUR application installs to connect to the PayNexus platform |
Note: If you're looking for the PayNexus platform source code, that's a separate repository. This plugin is the client SDK for merchants integrating with PayNexus.
📑 Table of Contents
- How It Works
- Installation
- Configuration
- Quick Start
- API Reference
- Webhook Integration
- Events
- Local Payment Records
- Ecommerce Integration Guide
- Error Handling
- Testing
- JavaScript Polling
- Custom Webhook Handler
- Configuration Reference
- Getting API Keys
- Support
- Troubleshooting
- License
🔄 How It Works
sequenceDiagram
participant App as Your Laravel App
participant API as PayNexus API
participant MPesa as M-Pesa (Daraja)
participant Customer as Customer
App->>API: initiatePayment()
API->>API: Create payment record
App->>App: Create local record
API->>MPesa: STK Push
MPesa->>Customer: Payment prompt
Customer->>MPesa: Enter PIN
MPesa->>API: Callback
API->>API: Update payment status
API->>App: Webhook
App->>App: Update local record
App->>App: Dispatch event
Payment Flow:
- Initiate — Your app calls
PayNexus::initiatePayment(...). A payment record is created both on PayNexus and in your localpaynexus_paymentstable. - STK Push — PayNexus sends an M-Pesa STK Push to the customer's phone.
- Customer pays — The customer enters their PIN on their phone.
- Callback — M-Pesa sends a callback to PayNexus. PayNexus updates its record and fires a webhook to your app.
- Webhook received — This plugin receives the webhook, updates the local
paynexus_paymentsrecord, and dispatches a Laravel event (PaymentCompletedorPaymentFailed). - Your listener — Your
EventServiceProviderlistener marks the order as paid, sends a receipt, etc.
Both records always match. If the webhook is missed, you can call
PayNexus::pollStatus(...)orPayNexus::getPaymentByCheckoutId(...)to sync manually.
📦 Installation
Install the package via Composer:
composer require paynexus/laravel-paynexus
The service provider and facade are auto-discovered by Laravel.
Publish Config and Migration
php artisan vendor:publish --tag=paynexus-config
php artisan vendor:publish --tag=paynexus-migrations
php artisan migrate
⚙️ Configuration
Add these to your .env file:
# Required — your secret API key from the PayNexus merchant dashboard
PAYNEXUS_SECRET_KEY=sk_your_secret_key_here
# Optional — your public API key for read operations (client-side safe)
PAYNEXUS_PUBLIC_KEY=pk_your_public_key_here
# For backwards compatibility, you can also use PAYNEXUS_API_KEY (maps to secret key)
# PAYNEXUS_API_KEY=sk_your_secret_key_here
# Required — PayNexus API base URL
PAYNEXUS_BASE_URL=https://paynexus.co.ke
# Optional — webhook signature verification secret
PAYNEXUS_WEBHOOK_SECRET=whsec_your_webhook_secret
# Optional — default currency (default: KES)
PAYNEXUS_CURRENCY=KES
🔑 Where to Get Credentials
| Credential | Location | Usage |
|---|---|---|
Secret Key (sk_…) |
PayNexus Dashboard → API Keys | Write operations (initiate payments, manage webhooks). Keep server-side only. |
Public Key (pk_…) |
PayNexus Dashboard → API Keys | Read operations only (merchant info, payment status). Safe for client-side code. |
| Webhook Secret | PayNexus Dashboard → Webhooks | Generate a webhook pointing to https://yourapp.com/paynexus/webhook |
🔓 Public Key Allowed Operations
Public keys (pk_) can perform the following read-only operations:
Merchant Information
GET /api/merchant- Get merchant informationGET /api/merchant/businesses- List merchant businessesGET /api/merchant/payment-accounts- List payment accounts
Payment Read Operations
GET /api/payments/{reference}- Get payment status by referenceGET /api/payments/{id}/status-by-id- Get payment status by IDPOST /api/payments/status-by-checkout-id- Get payment status by checkout IDGET /api/payments- List all payments
M-Pesa Operations
GET /api/mpesa/health- M-Pesa service health checkPOST /api/mpesa/validate-phone- Validate phone numberPOST /api/mpesa/payment/status- Check M-Pesa transaction status
Webhook Read Operations
GET /api/webhooks- List registered webhooks
API Key Read Operations
GET /api/api-keys- List API keys
Invoice Read Operations
GET /api/invoices- List invoicesGET /api/invoices/{invoice}- View invoice details
Receipt Read Operations
GET /api/receipts- List receiptsGET /api/receipts/{receipt}- View receipt details
🔒 Public Key Blocked Operations
Public keys (pk_) are blocked from these write operations at the middleware level:
Payment Write Operations
POST /api/payments/initiate- Initiate paymentsPOST /api/mpesa/payment/initiate- Initiate STK push payment
Webhook Management
POST /api/webhooks/register- Register webhooksPUT /api/webhooks/{id}- Update webhooksDELETE /api/webhooks/{id}- Delete webhooks
API Key Management
POST /api/api-keys- Create API keysPUT /api/api-keys/{id}- Update API keysDELETE /api/api-keys/{id}- Delete API keys
Invoice Management
POST /api/invoices- Create invoicesPUT /api/invoices/{invoice}- Update invoicesDELETE /api/invoices/{invoice}- Delete invoicesPOST /api/invoices/{invoice}/send- Send invoices
Receipt Management
POST /api/receipts/{receipt}/resend- Resend receipts
🚀 Quick Start
use PayNexus\Facades\PayNexus;
// 1. Initiate an M-Pesa STK Push payment
$result = PayNexus::initiatePayment([
'amount' => 1500,
'phone' => '254712345678',
'description' => 'Order #1001 - Wireless Headphones',
]);
if ($result['success']) {
$checkoutRequestId = $result['data']['checkout_request_id'];
$reference = $result['data']['reference'];
echo "STK Push sent! Reference: {$reference}";
}
// 2. Check status immediately
$status = PayNexus::getPaymentByCheckoutId($checkoutRequestId);
echo $status['data']['status']; // 'pending', 'completed', 'failed'
// 3. Or poll until completion (blocks up to 120s by default)
$final = PayNexus::pollStatus($checkoutRequestId);
if ($final['data']['status'] === 'completed') {
echo 'Payment completed!';
}
📚 API Reference
👤 Merchant
// Get merchant details
$merchant = PayNexus::getMerchant();
// Returns: { success: true, data: { id, business_name, status, subscription_status, … } }
🏢 Businesses
// List merchant businesses
$businesses = PayNexus::getBusinesses();
// Returns: { success: true, data: [{ id, business_name, business_email, status, … }] }
💳 Payment Accounts
// List all payment accounts
$accounts = PayNexus::getPaymentAccounts();
// Returns: { success: true, data: [{ id, provider, type, account_name, till_number, … }] }
💰 Initiate Payment
Two endpoints are available:
// Option A: Generic payment initiation (recommended)
// payment_account_id is auto-resolved from the API if not provided
$result = PayNexus::initiatePayment([
'amount' => 500,
'phone' => '254712345678',
'description' => 'Invoice #42',
]);
// Option B: M-Pesa-specific endpoint (includes server-side phone validation)
$result = PayNexus::initiateMpesaPayment([
'amount' => 500,
'phone' => '0712345678', // accepts local format too
'description' => 'Invoice #42',
'remark' => 'Website Payment',
]);
// You can also pass a specific payment_account_id if you have multiple accounts:
// $result = PayNexus::initiatePayment(['payment_account_id' => 2, ...]);
Idempotency Protection:
To prevent duplicate payments, you can provide an idempotency key:
$result = PayNexus::initiatePayment([
'amount' => 500,
'phone' => '254712345678',
'description' => 'Invoice #42',
'idempotency_key' => 'inv-42-unique-key', // Optional but recommended
]);
If a payment with the same idempotency_key already exists, the existing payment details will be returned instead of creating a new one. This is especially useful for handling network retries and preventing duplicate charges.
Benefits of idempotency:
- Prevents duplicate payments on network retries
- Safe to retry payment initiation without side effects
- Atomic database transactions ensure consistency
- Existing payment returned if key matches
Response:
{
"success": true,
"data": {
"payment_id": 42,
"reference": "PNXABCD1234",
"checkout_request_id": "ws_CO_...",
"merchant_request_id": "...",
"amount": 500,
"currency": "KES",
"phone": "254712345678",
"status": "pending",
"response_code": "0",
"response_description": "Success. Request accepted for processing",
"customer_message": "Success. Request accepted for processing"
},
"message": "Payment initiated successfully"
}
Both methods also create a local paynexus_payments record automatically.
📊 Payment Status
// By PayNexus reference
$status = PayNexus::getPaymentByReference('PNXABCD1234');
// By PayNexus payment ID
$status = PayNexus::getPaymentById(42);
// By M-Pesa checkout request ID (also syncs local record)
$status = PayNexus::getPaymentByCheckoutId('ws_CO_...');
// Real-time M-Pesa query via Daraja (most accurate, also syncs local record)
$status = PayNexus::checkMpesaStatus('ws_CO_...');
⏱️ Poll for Completion
Blocks until the payment reaches a terminal state or times out:
$result = PayNexus::pollStatus(
checkoutRequestId: 'ws_CO_...',
intervalSeconds: 3, // check every 3 seconds (default)
timeoutSeconds: 120, // give up after 2 minutes (default)
);
if ($result['data']['status'] === 'completed') {
// Payment is done
}
📋 List Payments
$payments = PayNexus::listPayments([
'status' => 'completed',
'payment_method' => 'mpesa',
'from_date' => '2026-01-01',
'to_date' => '2026-12-31',
'per_page' => 50,
]);
✅ Phone Validation
$validation = PayNexus::validatePhone('0712345678');
// { success: true, data: { valid: true, normalized: '254712345678' } }
🔗 Webhooks
// Register your webhook endpoint with PayNexus
PayNexus::registerWebhook(
name: 'My App',
url: 'https://myapp.com/paynexus/webhook',
events: ['payment.completed', 'payment.failed']
);
// List webhooks
$webhooks = PayNexus::listWebhooks();
// Update a webhook
PayNexus::updateWebhook(1, ['active' => false]);
// Delete a webhook
PayNexus::deleteWebhook(1);
🔔 Webhook Integration
The plugin automatically registers a POST route at /paynexus/webhook (configurable via PAYNEXUS_WEBHOOK_PATH).
Queue Support for Webhooks
For production environments, you can enable queue processing for webhooks to improve reliability:
PAYNEXUS_QUEUE_WEBHOOKS=true
PAYNEXUS_QUEUE_CONNECTION=redis
PAYNEXUS_QUEUE_NAME=webhooks
When enabled, webhooks are dispatched to a queue job (ProcessPayNexusWebhook) with configurable retry behavior:
PAYNEXUS_WEBHOOK_MAX_ATTEMPTS=5
PAYNEXUS_WEBHOOK_BACKOFF=exponential # linear, exponential, or constant
PAYNEXUS_WEBHOOK_BASE_DELAY=2000 # milliseconds
Benefits of queue processing:
- Asynchronous processing prevents webhook timeouts
- Automatic retries with exponential backoff
- Better handling of high-volume webhook traffic
- Dead-letter queue support for failed webhooks
Handling Webhook Failures:
When a webhook job exhausts its retry attempts, a WebhookProcessingFailed event is dispatched. You can listen for this event:
use PayNexus\Events\WebhookProcessingFailed;
protected $listen = [
WebhookProcessingFailed::class => [
\App\Listeners\HandleWebhookFailure::class,
],
];
PayNexus sends this payload when a payment completes:
{
"event": "payment.completed",
"timestamp": "2026-05-22T10:00:00.000000Z",
"data": {
"payment_id": 42,
"merchant_id": 1,
"reference": "PNXABCD1234",
"amount": "1500.00",
"currency": "KES",
"phone": "254712345678",
"status": "completed",
"account_reference": "ORD-1001",
"checkout_request_id": "ws_CO_20260522100000123456",
"transaction_id": "SH1234ABCDE",
"provider_transaction_id": "SH1234ABCDE",
"provider_reference": "SH1234ABCDE",
"payer_name": "JOHN DOE",
"created_at": "2026-05-22T09:59:00.000000Z",
"updated_at": "2026-05-22T10:00:00.000000Z"
}
}
Headers sent with each webhook:
| Header | Description |
|---|---|
X-PayNexus-Signature |
HMAC-SHA256 of the JSON body using your webhook secret |
X-PayNexus-Timestamp |
Unix timestamp — used for replay protection |
The webhook controller:
- Verifies the
X-PayNexus-Signatureheader (HMAC-SHA256 of the JSON body using your webhook secret). - Checks
X-PayNexus-Timestampfor replay protection (rejects payloads older than 5 minutes by default). - Finds or creates the matching
paynexus_paymentsrecord. - Updates the local record's status.
- Dispatches a Laravel event (
PaymentCompletedorPaymentFailed).
Setup in PayNexus dashboard:
- Go to Webhooks in your PayNexus merchant panel.
- Add a webhook pointing to
https://yourapp.com/paynexus/webhook. - Copy the generated secret and set
PAYNEXUS_WEBHOOK_SECRETin your.env.
🎯 Events
| Event | When | Properties |
|---|---|---|
PayNexus\Events\PaymentCompleted |
Webhook reports payment.completed |
$payment (PaynexusPayment), $payload (array) |
PayNexus\Events\PaymentFailed |
Webhook reports payment.failed |
$payment (PaynexusPayment), $payload (array), $reason (string) |
PayNexus\Events\PaymentInitiated |
After initiatePayment() succeeds |
$payment (PaynexusPayment) |
Register listeners in your EventServiceProvider:
use PayNexus\Events\PaymentCompleted;
use PayNexus\Events\PaymentFailed;
protected $listen = [
PaymentCompleted::class => [
\App\Listeners\HandlePaymentSuccess::class,
],
PaymentFailed::class => [
\App\Listeners\HandlePaymentFailure::class,
],
];
📦 Local Payment Records
Every payment initiated through the plugin creates a row in the paynexus_payments table.
use PayNexus\Models\PaynexusPayment;
// Find by reference
$payment = PaynexusPayment::where('reference', 'PNXABCD1234')->first();
// Find by checkout request ID
$payment = PaynexusPayment::where('checkout_request_id', 'ws_CO_...')->first();
// Query scopes
$pending = PaynexusPayment::pending()->get();
$completed = PaynexusPayment::completed()->get();
$failed = PaynexusPayment::failed()->get();
// Check state
$payment->isPending(); // true/false
$payment->isCompleted(); // true/false
$payment->isTerminal(); // completed, failed, or timeout
$payment->isVerified(); // true/false - if payment was verified with provider
$payment->isManuallyConfirmed(); // true/false - if manually confirmed by admin
$payment->canRetry(); // true/false - if failed payment can be retried
// Helper methods
$payment->markCompleted($transactionId, $providerReference);
$payment->markFailed($reason);
$payment->markVerified($verifiedAmount, $verifiedPhone, $verificationMethod);
$payment->markManuallyConfirmed('admin@example.com');
// Link to your models via polymorphic relation
$payment->payable; // → App\Models\Order, App\Models\Invoice, etc.
🔍 Payment Verification
The plugin tracks verification data from the payment provider:
$payment = PaynexusPayment::where('reference', 'PNXABCD1234')->first();
// Check if payment has been verified with provider
if ($payment->isVerified()) {
echo "Verified on: " . $payment->verified_date->format('Y-m-d H:i:s');
echo "Verified amount: " . $payment->verified_amount;
echo "Verification method: " . $payment->verification_method;
}
// Manually mark as verified (e.g., after checking with bank)
$payment->markVerified(
verifiedAmount: 1500.00,
verifiedPhone: '254712345678',
verificationMethod: 'bank_statement'
);
👤 Manual Confirmation
For payments that need manual review:
// Mark payment as manually confirmed by an admin
$payment->markManuallyConfirmed('admin@example.com');
// Check if manually confirmed
if ($payment->isManuallyConfirmed()) {
echo "Confirmed by: " . $payment->confirmed_by;
echo "Confirmed at: " . $payment->confirmed_at->format('Y-m-d H:i:s');
}
🔗 Polymorphic Relationship
Link payments to any of your models:
// In your Order model
class Order extends Model
{
public function payments()
{
return $this->morphMany(\PayNexus\Models\PaynexusPayment::class, 'payable');
}
}
// When initiating payment, set payable after creation:
$result = PayNexus::initiatePayment([...]);
if ($result['success']) {
$localPayment = PaynexusPayment::where('checkout_request_id', $result['data']['checkout_request_id'])->first();
$localPayment->update([
'payable_type' => Order::class,
'payable_id' => $order->id,
]);
}
🛒 Ecommerce Integration Guide
📦 Order Checkout Flow
Here is a complete end-to-end checkout flow for an ecommerce store.
Step 1: Order Model Setup
// app/Models/Order.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use PayNexus\Models\PaynexusPayment;
class Order extends Model
{
protected $fillable = [
'user_id', 'order_number', 'total', 'currency', 'status',
'customer_name', 'customer_email', 'customer_phone',
];
public function payments()
{
return $this->morphMany(PaynexusPayment::class, 'payable');
}
public function latestPayment()
{
return $this->morphOne(PaynexusPayment::class, 'payable')->latestOfMany();
}
public function isPaid(): bool
{
return $this->payments()->completed()->exists();
}
public function markAsPaid(): void
{
$this->update(['status' => 'paid']);
}
}
Step 2: Orders Migration
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('order_number')->unique();
$table->decimal('total', 14, 2);
$table->string('currency', 10)->default('KES');
$table->string('status')->default('pending'); // pending, paid, shipped, cancelled
$table->string('customer_name');
$table->string('customer_email')->nullable();
$table->string('customer_phone');
$table->timestamps();
});
🛍️ Cart Checkout Controller
// app/Http/Controllers/CheckoutController.php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
use PayNexus\Facades\PayNexus;
use PayNexus\Models\PaynexusPayment;
class CheckoutController extends Controller
{
/**
* Step 1: Show checkout page with cart summary.
*/
public function show(Request $request)
{
$cart = $request->user()->cart; // your cart logic
return view('checkout', compact('cart'));
}
/**
* Step 2: Create order and initiate M-Pesa payment.
*/
public function pay(Request $request)
{
$request->validate([
'phone' => 'required|string',
]);
// Create the order
$order = Order::create([
'user_id' => $request->user()->id,
'order_number' => 'ORD-' . strtoupper(uniqid()),
'total' => $request->user()->cart->total(),
'customer_name' => $request->user()->name,
'customer_email' => $request->user()->email,
'customer_phone' => $request->phone,
]);
// Initiate M-Pesa STK Push
$result = PayNexus::initiatePayment([
'amount' => $order->total,
'phone' => $request->phone,
'account_reference' => $order->order_number,
'description' => "Payment for {$order->order_number}",
'metadata' => ['order_id' => $order->id],
]);
if (!$result['success']) {
return back()->withErrors(['payment' => $result['message'] ?? 'Payment initiation failed.']);
}
// Link the local PaynexusPayment to the order
$checkoutRequestId = $result['data']['checkout_request_id'];
$localPayment = PaynexusPayment::where('checkout_request_id', $checkoutRequestId)->first();
if ($localPayment) {
$localPayment->update([
'payable_type' => Order::class,
'payable_id' => $order->id,
]);
}
// Redirect to a page that polls for payment status
return redirect()->route('checkout.status', [
'order' => $order->id,
'checkout_request_id' => $checkoutRequestId,
]);
}
/**
* Step 3: Show payment status page (polls via AJAX or Livewire).
*/
public function status(Request $request, Order $order)
{
return view('checkout.status', [
'order' => $order,
'checkoutRequestId' => $request->query('checkout_request_id'),
]);
}
/**
* AJAX endpoint: check payment status from the browser.
*/
public function checkStatus(Request $request)
{
$request->validate(['checkout_request_id' => 'required|string']);
$result = PayNexus::getPaymentByCheckoutId($request->checkout_request_id);
$status = $result['data']['status'] ?? 'pending';
// If completed, mark the order as paid
if ($status === 'completed') {
$local = PaynexusPayment::where('checkout_request_id', $request->checkout_request_id)->first();
if ($local && $local->payable) {
$local->payable->markAsPaid();
}
}
return response()->json([
'status' => $status,
'data' => $result['data'] ?? [],
]);
}
}
Routes:
// routes/web.php
Route::middleware('auth')->group(function () {
Route::get('/checkout', [CheckoutController::class, 'show'])->name('checkout');
Route::post('/checkout/pay', [CheckoutController::class, 'pay'])->name('checkout.pay');
Route::get('/checkout/{order}/status', [CheckoutController::class, 'status'])->name('checkout.status');
Route::post('/checkout/check-status', [CheckoutController::class, 'checkStatus'])->name('checkout.check-status');
});
⚡ Payment Status Page (Livewire)
If you use Livewire, here is a component that polls automatically:
// app/Livewire/PaymentStatus.php
namespace App\Livewire;
use Livewire\Component;
use PayNexus\Facades\PayNexus;
use PayNexus\Models\PaynexusPayment;
class PaymentStatus extends Component
{
public string $checkoutRequestId;
public string $status = 'pending';
public ?string $transactionId = null;
public ?string $reference = null;
public function mount(string $checkoutRequestId)
{
$this->checkoutRequestId = $checkoutRequestId;
$this->checkPaymentStatus();
}
public function checkPaymentStatus(): void
{
$result = PayNexus::getPaymentByCheckoutId($this->checkoutRequestId);
$this->status = $result['data']['status'] ?? 'pending';
$this->transactionId = $result['data']['provider_transaction_id'] ?? null;
$this->reference = $result['data']['reference'] ?? null;
if ($this->status === 'completed') {
$local = PaynexusPayment::where('checkout_request_id', $this->checkoutRequestId)->first();
if ($local && $local->payable && method_exists($local->payable, 'markAsPaid')) {
$local->payable->markAsPaid();
}
}
}
public function render()
{
return view('livewire.payment-status');
}
}
{{-- resources/views/livewire/payment-status.blade.php --}}
<div wire:poll.3s="checkPaymentStatus">
@if ($status === 'pending')
<div class="text-center p-8">
<div class="animate-spin h-12 w-12 border-4 border-blue-500 border-t-transparent rounded-full mx-auto mb-4"></div>
<h2 class="text-xl font-semibold">Waiting for payment...</h2>
<p class="text-gray-500 mt-2">Check your phone and enter your M-Pesa PIN to complete the payment.</p>
</div>
@elseif ($status === 'completed')
<div class="text-center p-8 bg-green-50 rounded-lg">
<svg class="h-16 w-16 text-green-500 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<h2 class="text-xl font-semibold text-green-700">Payment Successful!</h2>
@if ($transactionId)
<p class="text-gray-600 mt-2">M-Pesa Receipt: <strong>{{ $transactionId }}</strong></p>
@endif
@if ($reference)
<p class="text-gray-600">Reference: <strong>{{ $reference }}</strong></p>
@endif
</div>
@elseif ($status === 'failed')
<div class="text-center p-8 bg-red-50 rounded-lg">
<svg class="h-16 w-16 text-red-500 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<h2 class="text-xl font-semibold text-red-700">Payment Failed</h2>
<p class="text-gray-600 mt-2">The payment was not completed. Please try again.</p>
<a href="{{ route('checkout') }}" class="mt-4 inline-block bg-red-600 text-white px-6 py-2 rounded-lg">
Try Again
</a>
</div>
@endif
</div>
🎧 Handling Webhooks in Your App
// app/Listeners/HandlePaymentSuccess.php
namespace App\Listeners;
use PayNexus\Events\PaymentCompleted;
class HandlePaymentSuccess
{
public function handle(PaymentCompleted $event): void
{
$payment = $event->payment;
// Update the linked order
if ($payment->payable && method_exists($payment->payable, 'markAsPaid')) {
$payment->payable->markAsPaid();
}
// Send confirmation email, SMS, etc.
// Mail::to($payment->payable->customer_email)->send(new OrderConfirmation($payment->payable));
}
}
// app/Listeners/HandlePaymentFailure.php
namespace App\Listeners;
use PayNexus\Events\PaymentFailed;
class HandlePaymentFailure
{
public function handle(PaymentFailed $event): void
{
$payment = $event->payment;
// Update order status
if ($payment->payable) {
$payment->payable->update(['status' => 'payment_failed']);
}
// Notify the customer
// Notification::send($payment->payable->user, new PaymentFailedNotification($event->reason));
}
}
🔄 Subscription Integration
For subscription-based applications:
Subscription Model:
// app/Models/Subscription.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use PayNexus\Models\PaynexusPayment;
class Subscription extends Model
{
protected $fillable = [
'user_id',
'plan_id',
'status',
'expires_at',
];
public function payments()
{
return $this->morphMany(PaynexusPayment::class, 'payable');
}
public function activate()
{
$this->update([
'status' => 'active',
'expires_at' => now()->addMonth(),
]);
}
}
Subscription Payment Controller:
// app/Http/Controllers/SubscriptionController.php
public function renew(Request $request)
{
$subscription = $request->user()->subscription;
$result = PayNexus::initiatePayment([
'amount' => 1000,
'phone' => $request->user()->phone,
'account_reference' => "SUB-{$subscription->id}",
'description' => 'Subscription renewal',
]);
if ($result['success']) {
$payment = PaynexusPayment::where(
'checkout_request_id',
$result['data']['checkout_request_id']
)->first();
$payment->update([
'payable_type' => Subscription::class,
'payable_id' => $subscription->id,
]);
}
return back();
}
� Manual Payment Verification
For payments that require manual admin review:
// app/Http/Controllers/Admin/PaymentController.php
public function verifyPayment(Request $request, $id)
{
$payment = PaynexusPayment::findOrFail($id);
$payment->markVerified(
verifiedAmount: $request->verified_amount,
verifiedPhone: $request->verified_phone,
verificationMethod: 'manual_admin'
);
return back()->with('success', 'Payment verified successfully');
}
public function confirmPayment(Request $request, $id)
{
$payment = PaynexusPayment::findOrFail($id);
$payment->markManuallyConfirmed(auth()->user()->email);
// Update linked order
if ($payment->payable && method_exists($payment->payable, 'markAsPaid')) {
$payment->payable->markAsPaid();
}
return back()->with('success', 'Payment confirmed successfully');
}
⚠️ Error Handling
The plugin provides specific exceptions for different error scenarios:
use PayNexus\Exceptions\PayNexusAuthException;
use PayNexus\Exceptions\PayNexusConnectionException;
use PayNexus\Exceptions\PayNexusApiException;
try {
$result = PayNexus::initiatePayment([
'amount' => 1000,
'phone' => '254712345678',
'account_reference' => 'TEST',
]);
if (!$result['success']) {
Log::error('Payment initiation failed', [
'message' => $result['message'],
'error' => $result['error'] ?? null,
]);
return back()->with('error', 'Payment failed. Please try again.');
}
} catch (PayNexusAuthException $e) {
Log::error('Authentication error', ['message' => $e->getMessage()]);
return back()->with('error', 'API authentication failed. Check your API keys.');
} catch (PayNexusConnectionException $e) {
Log::error('Connection error', ['message' => $e->getMessage()]);
return back()->with('error', 'Could not connect to payment gateway. Please try again.');
} catch (PayNexusApiException $e) {
Log::error('API error', ['message' => $e->getMessage()]);
return back()->with('error', 'Payment gateway error. Please try again.');
}
🧪 Testing
Mocking HTTP Requests
// tests/Feature/PaymentTest.php
use PayNexus\Facades\PayNexus;
use Illuminate\Support\Facades\Http;
test('payment can be initiated', function () {
// Mock the HTTP client
Http::fake([
'paynexus.co.ke/*' => Http::response([
'success' => true,
'data' => [
'payment_id' => 123,
'reference' => 'PNXTEST',
'checkout_request_id' => 'ws_CO_test',
],
]),
]);
$result = PayNexus::initiatePayment([
'amount' => 1000,
'phone' => '254712345678',
'account_reference' => 'TEST',
]);
expect($result['success'])->toBeTrue();
expect($result['data']['checkout_request_id'])->toBe('ws_CO_test');
});
🌐 JavaScript Polling
For client-side payment status polling:
// resources/js/payment-status.js
function pollPaymentStatus(checkoutRequestId) {
const interval = setInterval(async () => {
try {
const response = await fetch(`/api/payment/status/${checkoutRequestId}`);
const data = await response.json();
if (data.status === 'completed') {
clearInterval(interval);
window.location.href = '/payment/success';
} else if (data.status === 'failed') {
clearInterval(interval);
window.location.href = '/payment/failed';
}
} catch (error) {
console.error('Error checking payment status:', error);
}
}, 3000); // Check every 3 seconds
}
// Usage
pollPaymentStatus('ws_CO_123456789');
� Custom Webhook Handler
If you need custom webhook handling beyond the built-in controller:
// app/Http/Controllers/CustomWebhookController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use PayNexus\Facades\PayNexus;
use PayNexus\Models\PaynexusPayment;
class CustomWebhookController extends Controller
{
public function handle(Request $request)
{
// Verify signature
$signature = $request->header('X-PayNexus-Signature');
$payload = $request->getContent();
$secret = config('paynexus.webhook.secret');
$expected = hash_hmac('sha256', $payload, $secret);
if (!hash_equals($expected, $signature)) {
return response()->json(['error' => 'Invalid signature'], 403);
}
$data = $request->all();
$event = $data['event'] ?? 'unknown';
// Find or create payment
$payment = PaynexusPayment::where(
'reference',
$data['data']['reference'] ?? null
)->first();
if (!$payment) {
$payment = PaynexusPayment::create([
'reference' => $data['data']['reference'],
'amount' => $data['data']['amount'],
'status' => $event === 'payment.completed' ? 'completed' : 'failed',
]);
}
// Handle event
if ($event === 'payment.completed') {
$payment->markCompleted(
$data['data']['transaction_id'] ?? null,
$data['data']['provider_reference'] ?? null
);
}
return response()->json(['received' => true]);
}
}
Register your custom webhook route:
// routes/web.php
Route::post('/webhooks/paynexus', [CustomWebhookController::class, 'handle'])
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
�� Configuration Reference
| Key | Env Variable | Default | Description |
|---|---|---|---|
secret_key |
PAYNEXUS_SECRET_KEY |
— | Your PayNexus secret key (sk_…) - Required for write operations |
public_key |
PAYNEXUS_PUBLIC_KEY |
— | Your PayNexus public key (pk_…) - Optional, for read operations |
base_url |
PAYNEXUS_BASE_URL |
https://paynexus.co.ke |
PayNexus API base URL |
currency |
PAYNEXUS_CURRENCY |
KES |
Default currency |
webhook.secret |
PAYNEXUS_WEBHOOK_SECRET |
— | HMAC secret for webhook signature verification |
webhook.path |
PAYNEXUS_WEBHOOK_PATH |
/paynexus/webhook |
Webhook receiver route |
webhook.tolerance |
— | 300 |
Max age (seconds) for webhook timestamp |
polling.interval |
PAYNEXUS_POLL_INTERVAL |
3 |
Seconds between pollStatus queries |
polling.timeout |
PAYNEXUS_POLL_TIMEOUT |
120 |
Max seconds pollStatus will block |
http.timeout |
PAYNEXUS_HTTP_TIMEOUT |
30 |
HTTP request timeout (seconds) |
http.retry_times |
PAYNEXUS_HTTP_RETRIES |
2 |
Number of retries on connection failure |
http.retry_sleep |
PAYNEXUS_HTTP_RETRY_SLEEP |
500 |
Milliseconds between retries |
log_channel |
PAYNEXUS_LOG_CHANNEL |
null |
Custom log channel |
queue.webhooks |
PAYNEXUS_QUEUE_WEBHOOKS |
false |
Enable queue processing for webhooks |
queue.connection |
PAYNEXUS_QUEUE_CONNECTION |
default |
Queue connection for webhooks |
queue.queue |
PAYNEXUS_QUEUE_NAME |
default |
Queue name for webhooks |
retry.webhook_max_attempts |
PAYNEXUS_WEBHOOK_MAX_ATTEMPTS |
3 |
Max retry attempts for webhook jobs |
retry.webhook_backoff |
PAYNEXUS_WEBHOOK_BACKOFF |
exponential |
Retry backoff strategy (linear, exponential, constant) |
retry.webhook_base_delay |
PAYNEXUS_WEBHOOK_BASE_DELAY |
1000 |
Base delay in milliseconds for retries |
Advanced Configuration Options
Queue Webhooks:
PAYNEXUS_QUEUE_WEBHOOKS=true
PAYNEXUS_QUEUE_CONNECTION=redis
PAYNEXUS_QUEUE_NAME=webhooks
Retry Configuration:
PAYNEXUS_WEBHOOK_MAX_ATTEMPTS=5
PAYNEXUS_WEBHOOK_BACKOFF=exponential # linear, exponential, or constant
PAYNEXUS_WEBHOOK_BASE_DELAY=2000 # milliseconds
Custom Webhook Path:
PAYNEXUS_WEBHOOK_PATH=/custom/webhook/path
Custom Polling Settings:
PAYNEXUS_POLL_INTERVAL=5 # Check every 5 seconds
PAYNEXUS_POLL_TIMEOUT=180 # Timeout after 3 minutes
HTTP Client Settings:
PAYNEXUS_HTTP_TIMEOUT=60 # 60 second timeout
PAYNEXUS_HTTP_RETRIES=3 # Retry 3 times
PAYNEXUS_HTTP_RETRY_SLEEP=1000 # Wait 1 second between retries
Custom Log Channel:
PAYNEXUS_LOG_CHANNEL=paynexus
Then in config/logging.php:
'channels' => [
'paynexus' => [
'driver' => 'daily',
'path' => storage_path('logs/paynexus.log'),
'level' => 'info',
],
],
🔑 Getting API Keys
To use this plugin, you need API keys from the PayNexus platform:
- Register at paynexus.co.ke to create an account
- Choose a subscription plan that fits your needs
- Navigate to the merchant panel after registration
- Create a business in your merchant dashboard
- Add a payment account (e.g., M-Pesa till number) to your business
- Navigate to API Keys and select the appropriate API key for the specific payment account you created
- Secret Key (
sk_): For initiating payments and write operations. Keep this server-side only. - Public Key (
pk_): For read operations like checking payment status. Safe to use in client-side code.
- Secret Key (
- Configure webhook in PayNexus dashboard pointing to
https://yourapp.com/paynexus/webhook - Copy the webhook secret and set
PAYNEXUS_WEBHOOK_SECRETin your.env
💬 Support
- Documentation: README.md
- Issues: GitHub Issues
- Email: support@paynexus.co.ke
- Platform: paynexus.co.ke
🔧 Troubleshooting
"PayNexus secret key is required"
- Set
PAYNEXUS_SECRET_KEY(orPAYNEXUS_API_KEYfor backwards compatibility) in your.env. - Run
php artisan config:clearafter changing environment variables.
"Unable to reach PayNexus API"
- Check your internet connection.
- Verify
PAYNEXUS_BASE_URLis correct (https://paynexus.co.ke, no trailing/api). - Test connectivity:
curl -H "X-API-Key: sk_..." https://paynexus.co.ke/api/merchant
"Invalid PayNexus webhook signature"
- Ensure
PAYNEXUS_WEBHOOK_SECRETmatches the secret shown in PayNexus when you created the webhook. - The webhook URL must be publicly accessible (PayNexus needs to reach it).
Payment stays "pending"
- The customer may not have confirmed the STK push on their phone.
- Call
PayNexus::checkMpesaStatus($checkoutRequestId)to query M-Pesa directly. - Check that your PayNexus callback URL is correctly configured in the M-Pesa Daraja portal.
Local record not updating
- Ensure your webhook endpoint is correctly registered with PayNexus.
- Check
storage/logs/laravel.logfor[PayNexus Webhook]entries. - Verify your
.envhasPAYNEXUS_WEBHOOK_SECRETset correctly. - You can manually sync by calling
PayNexus::getPaymentByCheckoutId(...).
📄 License
MIT — see LICENSE.
Built with ❤️ for the Laravel community