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.

Maintainers

Package info

github.com/MCBANKSKE/paynexus-laravel-plugin

Homepage

Documentation

pkg:composer/paynexus/laravel-paynexus

Statistics

Installs: 41

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.7 2026-05-26 13:45 UTC

This package is auto-updated.

Last update: 2026-05-26 13:48:00 UTC


README

PayNexus Laravel Plugin

Latest Version License Laravel PHP

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

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:

  1. Initiate โ€” Your app calls PayNexus::initiatePayment(...). A payment record is created both on PayNexus and in your local paynexus_payments table.
  2. STK Push โ€” PayNexus sends an M-Pesa STK Push to the customer's phone.
  3. Customer pays โ€” The customer enters their PIN on their phone.
  4. Callback โ€” M-Pesa sends a callback to PayNexus. PayNexus updates its record and fires a webhook to your app.
  5. Webhook received โ€” This plugin receives the webhook, updates the local paynexus_payments record, and dispatches a Laravel event (PaymentCompleted or PaymentFailed).
  6. Your listener โ€” Your EventServiceProvider listener marks the order as paid, sends a receipt, etc.

Both records always match. If the webhook is missed, you can call PayNexus::pollStatus(...) or PayNexus::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 information
  • GET /api/merchant/businesses - List merchant businesses
  • GET /api/merchant/payment-accounts - List payment accounts

Payment Read Operations

  • GET /api/payments/{reference} - Get payment status by reference
  • GET /api/payments/{id}/status-by-id - Get payment status by ID
  • POST /api/payments/status-by-checkout-id - Get payment status by checkout ID
  • GET /api/payments - List all payments

M-Pesa Operations

  • GET /api/mpesa/health - M-Pesa service health check
  • POST /api/mpesa/validate-phone - Validate phone number
  • POST /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 invoices
  • GET /api/invoices/{invoice} - View invoice details

Receipt Read Operations

  • GET /api/receipts - List receipts
  • GET /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 payments
  • POST /api/mpesa/payment/initiate - Initiate STK push payment

Webhook Management

  • POST /api/webhooks/register - Register webhooks
  • PUT /api/webhooks/{id} - Update webhooks
  • DELETE /api/webhooks/{id} - Delete webhooks

API Key Management

  • POST /api/api-keys - Create API keys
  • PUT /api/api-keys/{id} - Update API keys
  • DELETE /api/api-keys/{id} - Delete API keys

Invoice Management

  • POST /api/invoices - Create invoices
  • PUT /api/invoices/{invoice} - Update invoices
  • DELETE /api/invoices/{invoice} - Delete invoices
  • POST /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:

  1. Verifies the X-PayNexus-Signature header (HMAC-SHA256 of the JSON body using your webhook secret).
  2. Checks X-PayNexus-Timestamp for replay protection (rejects payloads older than 5 minutes by default).
  3. Finds or creates the matching paynexus_payments record.
  4. Updates the local record's status.
  5. Dispatches a Laravel event (PaymentCompleted or PaymentFailed).

Setup in PayNexus dashboard:

  1. Go to Webhooks in your PayNexus merchant panel.
  2. Add a webhook pointing to https://yourapp.com/paynexus/webhook.
  3. Copy the generated secret and set PAYNEXUS_WEBHOOK_SECRET in 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:

  1. Register at paynexus.co.ke to create an account
  2. Choose a subscription plan that fits your needs
  3. Navigate to the merchant panel after registration
  4. Create a business in your merchant dashboard
  5. Add a payment account (e.g., M-Pesa till number) to your business
  6. 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.
  7. Configure webhook in PayNexus dashboard pointing to https://yourapp.com/paynexus/webhook
  8. Copy the webhook secret and set PAYNEXUS_WEBHOOK_SECRET in your .env

๐Ÿ’ฌ Support

๐Ÿ”ง Troubleshooting

"PayNexus secret key is required"

  • Set PAYNEXUS_SECRET_KEY (or PAYNEXUS_API_KEY for backwards compatibility) in your .env.
  • Run php artisan config:clear after changing environment variables.

"Unable to reach PayNexus API"

  • Check your internet connection.
  • Verify PAYNEXUS_BASE_URL is 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_SECRET matches 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.log for [PayNexus Webhook] entries.
  • Verify your .env has PAYNEXUS_WEBHOOK_SECRET set correctly.
  • You can manually sync by calling PayNexus::getPaymentByCheckoutId(...).

๐Ÿ“„ License

MIT โ€” see LICENSE.

Built with โค๏ธ for the Laravel community

PayNexus โ€ข GitHub