paynexus / laravel-paynexus
Laravel SDK for the PayNexus payment orchestration platform. Accept M-Pesa STK Push payments, track payment status in real time, and keep local payment records synchronized with PayNexus.
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0
- illuminate/http: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0
README
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
Loading
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