JJuma Global logo Developer Docs
Jjuma Pay Documentation

Laravel Integration

A complete step by step guide for integrating JJuma payments into a Laravel application, including checkout redirects, secure signed webhooks, database updates, duplicate protection, and UGX payment examples.

API base URL: https://api.jjuma.com Guide URL: /integration-guides/laravel
Jjuma Pay documentation preview

Keep protected credentials on the Laravel server only.

Table of Contents

Jump directly to the parts of the Laravel guide you need.

Introduction

Security Never expose your Secret API Key or Webhook Secret in Blade templates, browser JavaScript, mobile applications, public repositories, frontend applications, or API responses. Keep protected credentials on your Laravel server.

This guide shows merchants how to integrate JJuma payments into a Laravel application. The same payment and webhook principles also apply to other PHP frameworks.

Use the guide together with the official JJuma documentation for authentication, create-payment requests, payment verification, webhook signatures, and testing.

Laravel Project Structure

text
app/
├── Http/
│   ├── Controllers/
│   │   ├── JjumaPaymentController.php
│   │   └── JjumaWebhookController.php
│   └── Requests/
│       └── CreateJjumaPaymentRequest.php
├── Models/
│   └── Order.php
└── Services/
    └── JjumaPaymentService.php

config/
└── services.php

database/
└── migrations/
    └── create_orders_table.php

resources/
└── views/
    └── payments/
        ├── success.blade.php
        └── cancelled.blade.php

routes/
├── web.php
└── api.php

Keep the service class reusable, keep customer-facing redirects separate from webhook handling, and keep payment state in the database rather than in the browser.

Requirements

text
A supported Laravel version
A supported PHP version for that Laravel release
Composer
PHP cURL extension
PHP JSON extension
A supported database
HTTPS SSL certificate
A publicly accessible webhook URL
Accurate server time
bash
composer create-project laravel/laravel jjuma-laravel-integration
cd jjuma-laravel-integration

Environment Variables and Configuration

env
JJUMA_API_BASE_URL=https://api.jjuma.com
JJUMA_PUBLIC_KEY=YOUR_PUBLIC_API_KEY
JJUMA_SECRET_KEY=YOUR_SECRET_API_KEY
JJUMA_WEBHOOK_SECRET=YOUR_WEBHOOK_SECRET

JJUMA_DEFAULT_CURRENCY=UGX
JJUMA_CHECKOUT_HOST=pay.jjuma.com

APP_URL=https://merchantwebsite.com
php
<?php

return [
    'jjuma' => [
        'base_url' => env('JJUMA_API_BASE_URL'),
        'public_key' => env('JJUMA_PUBLIC_KEY'),
        'secret_key' => env('JJUMA_SECRET_KEY'),
        'webhook_secret' => env('JJUMA_WEBHOOK_SECRET'),
        'default_currency' => env('JJUMA_DEFAULT_CURRENCY', 'UGX'),
        'checkout_host' => env('JJUMA_CHECKOUT_HOST', 'pay.jjuma.com'),
    ],
];

Production apps commonly use php artisan config:cache. After changing environment values, clear and rebuild the cache so old values do not break authentication or webhook verification.

JJuma Payment Service

php
<?php

declare(strict_types=1);

namespace App\Services;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;

class JjumaPaymentService
{
    private string $baseUrl;

    public function __construct()
    {
        $this->baseUrl = rtrim($this->requireConfig('base_url'), '/');
    }

    public function createPayment(array $payload): array
    {
        return $this->request('POST', '/api/v1/payments/create', $this->requireConfig('public_key'), $payload);
    }

    public function verifyPayment(string $transactionId): array
    {
        return $this->request('GET', '/api/v1/payments/verify/' . rawurlencode($transactionId), $this->requireConfig('secret_key'), []);
    }

    private function request(string $method, string $endpoint, string $accessKey, array $payload = []): array
    {
        $url = $this->baseUrl . '/' . ltrim($endpoint, '/');

        try {
            $request = Http::acceptJson()->asJson()->withToken($accessKey)->connectTimeout(15)->timeout(60);

            $response = match (strtoupper($method)) {
                'GET' => $request->get($url, $payload),
                'POST' => $request->post($url, $payload),
                default => throw new RuntimeException('Unsupported HTTP method.'),
            };
        } catch (ConnectionException $exception) {
            throw new RuntimeException('The merchant server could not connect to JJuma.') from $exception;
        } catch (Throwable $exception) {
            throw new RuntimeException('The JJuma request could not be completed.') from $exception;
        }

        return $this->parseResponse($response);
    }

    private function parseResponse(Response $response): array
    {
        $data = $response->json();
        if (!is_array($data)) {
            throw new RuntimeException('JJuma returned an invalid response.');
        }

        if ($response->failed()) {
            $message = $data['message'] ?? $data['detail'] ?? $data['error'] ?? 'The JJuma request failed.';
            throw new RuntimeException((string) $message);
        }

        return $data;
    }

    private function requireConfig(string $key): string
    {
        $value = trim((string) config("services.jjuma.{$key}"));
        if ($value === '') {
            throw new RuntimeException("Missing JJuma configuration: {$key}");
        }
        return $value;
    }
}

Payment Request

php
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class CreateJjumaPaymentRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'order_id' => ['required', 'string', 'max:100'],
        ];
    }
}

The browser should submit only the order ID. Load amount, currency, customer information, and payment status from the merchant database on the server.

Order Model and Migration

php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    protected $fillable = [
        'order_id',
        'customer_id',
        'amount',
        'currency',
        'payment_status',
        'jjuma_transaction_id',
        'jjuma_reference',
        'paid_amount',
        'paid_at',
    ];

    protected $casts = [
        'amount' => 'decimal:2',
        'paid_amount' => 'decimal:2',
        'paid_at' => 'datetime',
    ];
}
php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table): void {
            $table->id();
            $table->string('order_id', 100)->unique();
            $table->unsignedBigInteger('customer_id')->nullable();
            $table->decimal('amount', 15, 2);
            $table->string('currency', 10)->default('UGX');
            $table->string('payment_status', 30)->default('pending');
            $table->string('jjuma_transaction_id', 150)->nullable()->unique();
            $table->string('jjuma_reference', 150)->nullable();
            $table->decimal('paid_amount', 15, 2)->nullable();
            $table->timestamp('paid_at')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('orders');
    }
};

Payment Controller

php
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Http\Requests\CreateJjumaPaymentRequest;
use App\Models\Order;
use App\Services\JjumaPaymentService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;

class JjumaPaymentController extends Controller
{
    public function create(CreateJjumaPaymentRequest $request, JjumaPaymentService $jjuma): RedirectResponse
    {
        $orderId = trim((string) $request->validated('order_id'));
        $order = Order::query()->where('order_id', $orderId)->first();

        if (!$order) {
            return back()->withErrors(['payment' => 'Order not found.']);
        }

        if ($order->payment_status === 'paid') {
            return back()->withErrors(['payment' => 'This order has already been paid.']);
        }

        $merchantReference = sprintf('%s-%s-%s', $order->order_id, now()->timestamp, Str::lower(Str::random(12)));
        $successUrl = route('jjuma.payment.success', ['order_id' => $order->order_id]);
        $cancelUrl = route('jjuma.payment.cancelled', ['order_id' => $order->order_id]);

        $payload = [
            'amount' => (string) $order->amount,
            'currency' => strtoupper((string) $order->currency),
            'reference' => $merchantReference,
            'description' => "Payment for order {$order->order_id}",
            'redirect_url' => $successUrl,
            'return_url' => $successUrl,
            'cancel_redirect_url' => $cancelUrl,
            'webhook_url' => route('jjuma.webhook'),
            'external_order_id' => $order->order_id,
            'idempotency_key' => 'order-' . $order->order_id,
        ];

        try {
            $result = $jjuma->createPayment($payload);
            $paymentUrl = data_get($result, 'data.payment_url') ?? data_get($result, 'payment_url');

            if (!is_string($paymentUrl) || $paymentUrl === '') {
                throw new RuntimeException('JJuma did not return a payment URL.');
            }

            $this->validateCheckoutUrl($paymentUrl);
            return redirect()->away($paymentUrl);
        } catch (Throwable $exception) {
            Log::error('JJuma payment creation failed', ['order_id' => $order->order_id, 'message' => $exception->getMessage()]);
            return back()->withErrors(['payment' => 'The payment could not be created.']);
        }
    }

    public function success(): \Illuminate\View\View
    {
        return view('payments.success', ['orderId' => trim((string) request('order_id', ''))]);
    }

    public function cancelled(): \Illuminate\View\View
    {
        return view('payments.cancelled', ['orderId' => trim((string) request('order_id', ''))]);
    }

    private function validateCheckoutUrl(string $checkoutUrl): void
    {
        if (filter_var($checkoutUrl, FILTER_VALIDATE_URL) === false) {
            throw new RuntimeException('Invalid checkout URL.');
        }

        if (parse_url($checkoutUrl, PHP_URL_HOST) !== trim((string) config('services.jjuma.checkout_host'))) {
            throw new RuntimeException('Untrusted checkout destination.');
        }
    }
}

The browser form should submit only the order ID. The server must load amount, currency, customer details, package data, subscription data, and payment status from the merchant database.

Routes and Views

php
// routes/web.php
Route::post('/payments/jjuma/create', [JjumaPaymentController::class, 'create'])->name('jjuma.payment.create');
Route::get('/payments/jjuma/success', [JjumaPaymentController::class, 'success'])->name('jjuma.payment.success');
Route::get('/payments/jjuma/cancelled', [JjumaPaymentController::class, 'cancelled'])->name('jjuma.payment.cancelled');

// routes/api.php
Route::post('/webhooks/jjuma', JjumaWebhookController::class)->name('jjuma.webhook');
blade
<form method="POST" action="{{ route('jjuma.payment.create') }}">
    @csrf
    <input type="hidden" name="order_id" value="{{ $order->order_id }}">
    <button type="submit">Pay with JJuma</button>
</form>
blade
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Payment Received</title></head>
<body>
  <main>
    <h1>Thank you</h1>
    <p>Your payment has been submitted. We are confirming the transaction.</p>
    @if ($orderId !== '')
      <p>Order reference: {{ $orderId }}</p>
    @endif
    <a href="{{ url('/') }}">Continue to website</a>
  </main>
</body>
</html>
blade
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Payment Not Completed</title></head>
<body>
  <main>
    <h1>Payment not completed</h1>
    <p>Your payment was not completed. You can return and try again.</p>
    @if ($orderId !== '')
      <form method="POST" action="{{ route('jjuma.payment.create') }}">
        @csrf
        <input type="hidden" name="order_id" value="{{ $orderId }}">
        <button type="submit">Try again</button>
      </form>
    @else
      <a href="{{ url('/') }}">Return to website</a>
    @endif
  </main>
</body>
</html>
Redirects are not payment confirmation A success redirect only means the customer returned from checkout. The webhook must do the real update.

Webhook Verification

Use the original raw body with $request->getContent(). Do not rebuild the payload from decoded JSON.

php
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Order;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use JsonException;
use RuntimeException;
use Throwable;

class JjumaWebhookController extends Controller
{
    public function __invoke(Request $request): JsonResponse
    {
        $signature = trim((string) $request->header('X-Jjuma-Signature', ''));
        $timestamp = trim((string) $request->header('X-Jjuma-Timestamp', ''));

        if ($signature === '' || $timestamp === '') {
            return response()->json(['message' => 'Missing webhook authentication headers.'], 401);
        }

        $rawBody = $request->getContent();
        if ($rawBody === '') {
            return response()->json(['message' => 'Webhook body is empty.'], 400);
        }

        if (!$this->validTimestamp($timestamp)) {
            return response()->json(['message' => 'Invalid webhook timestamp.'], 401);
        }

        if (!$this->validSignature($rawBody, $timestamp, $signature)) {
            return response()->json(['message' => 'Invalid webhook signature.'], 401);
        }

        try {
            $event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            return response()->json(['message' => 'Invalid webhook JSON.'], 400);
        }

        if (!is_array($event)) {
            return response()->json(['message' => 'Invalid webhook payload.'], 400);
        }

        try {
            $this->handleEvent($event);
        } catch (RuntimeException $exception) {
            return response()->json(['message' => $exception->getMessage()], 400);
        } catch (Throwable $exception) {
            Log::error('JJuma webhook processing failed', ['message' => $exception->getMessage()]);
            return response()->json(['message' => 'The webhook could not be processed.'], 500);
        }

        return response()->json(['status' => 'ok']);
    }

    private function validTimestamp(string $timestamp): bool
    {
        try {
            $eventTime = new \DateTimeImmutable($timestamp);
        } catch (Throwable) {
            return false;
        }

        return abs(now()->timestamp - $eventTime->getTimestamp()) <= 300;
    }

    private function validSignature(string $rawBody, string $timestamp, string $receivedSignature): bool
    {
        $secret = trim((string) config('services.jjuma.webhook_secret'));
        if ($secret === '') {
            throw new RuntimeException('Webhook secret is not configured.');
        }

        $expectedSignature = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
        $receivedSignature = preg_replace('/^sha256=/i', '', $receivedSignature) ?? '';

        return hash_equals($expectedSignature, $receivedSignature);
    }

    private function handleEvent(array $event): void
    {
        $eventType = trim((string) (data_get($event, 'event') ?? data_get($event, 'type') ?? ''));
        $payment = $this->extractPayment($event);

        match ($eventType) {
            'payment.completed' => $this->handleCompleted($payment),
            'payment.failed' => $this->handleFailed($payment),
            'payment.cancelled' => $this->handleCancelled($payment),
            default => Log::info('Ignoring unsupported JJuma event', ['event_type' => $eventType]),
        };
    }

    private function extractPayment(array $event): array
    {
        $data = data_get($event, 'data', []);
        if (!is_array($data)) {
            $data = $event;
        }

        return [
            'order_id' => trim((string) (data_get($data, 'order_id') ?? data_get($data, 'metadata.order_id') ?? data_get($event, 'order_id') ?? '')),
            'transaction_id' => trim((string) (data_get($data, 'transaction_id') ?? data_get($data, 'id') ?? data_get($event, 'transaction_id') ?? '')),
            'reference' => trim((string) (data_get($data, 'reference') ?? data_get($data, 'tx_ref') ?? data_get($event, 'reference') ?? data_get($event, 'tx_ref') ?? '')),
            'amount' => data_get($data, 'amount') ?? data_get($event, 'amount'),
            'currency' => strtoupper(trim((string) (data_get($data, 'currency') ?? data_get($event, 'currency') ?? ''))),
            'payment_status' => trim((string) (data_get($data, 'payment_status') ?? data_get($data, 'status') ?? data_get($event, 'payment_status') ?? data_get($event, 'status') ?? '')),
            'delivery_id' => trim((string) (data_get($data, 'delivery_id') ?? data_get($event, 'delivery_id') ?? '')),
            'failure_reason' => trim((string) (data_get($data, 'failure_reason') ?? data_get($data, 'message') ?? data_get($event, 'failure_reason') ?? data_get($event, 'message') ?? '')),
        ];
    }

    private function handleCompleted(array $payment): void
    {
        if ($payment['order_id'] === '' || $payment['transaction_id'] === '' || $payment['reference'] === '' || $payment['currency'] === '' || !is_numeric($payment['amount'])) {
            throw new RuntimeException('Completed payment is missing required fields.');
        }

        DB::transaction(function () use ($payment): void {
            $order = Order::query()->where('order_id', $payment['order_id'])->lockForUpdate()->first();
            if (!$order) {
                throw new RuntimeException('Order not found.');
            }

            if ($order->payment_status === 'paid') {
                return;
            }

            if (strtoupper((string) $order->currency) !== strtoupper($payment['currency'])) {
                throw new RuntimeException('Payment currency does not match the order.');
            }

            $expectedAmount = (int) $order->amount;
            $confirmedAmount = (int) $payment['amount'];
            if ($confirmedAmount < $expectedAmount) {
                throw new RuntimeException('Payment amount is lower than the order amount.');
            }

            $existingTransaction = Order::query()->where('jjuma_transaction_id', $payment['transaction_id'])->whereKeyNot($order->getKey())->exists();
            if ($existingTransaction) {
                return;
            }

            $order->forceFill([
                'payment_status' => 'paid',
                'jjuma_transaction_id' => $payment['transaction_id'],
                'jjuma_reference' => $payment['reference'],
                'paid_amount' => $payment['amount'],
                'paid_at' => now(),
            ])->save();
        }, attempts: 3);
    }

    private function handleFailed(array $payment): void
    {
        $orderId = $payment['order_id'];
        if ($orderId === '') {
            throw new RuntimeException('Failed payment is missing the order ID.');
        }

        Order::query()->where('order_id', $orderId)->where('payment_status', '!=', 'paid')->update(['payment_status' => 'failed']);
    }

    private function handleCancelled(array $payment): void
    {
        $orderId = $payment['order_id'];
        if ($orderId === '') {
            throw new RuntimeException('Cancelled payment is missing the order ID.');
        }

        Order::query()->where('order_id', $orderId)->where('payment_status', '!=', 'paid')->update(['payment_status' => 'cancelled']);
    }
}
Raw body rule The exact bytes from JJuma must be used for signature verification. Do not rebuild the signed payload from $request->all() or decoded JSON.

Payment Validation

  • Validate the webhook signature and timestamp first.
  • Confirm the event type is completed before marking anything paid.
  • Confirm the order ID exists and the order is still unpaid.
  • Check that the payment belongs to the right merchant.
  • Compare the saved amount, currency, merchant reference, and transaction ID.
  • Do not trust browser-submitted payment values.

Duplicate Protection

php
$table->string('jjuma_transaction_id', 150)->nullable()->unique();
  • Save the JJuma transaction ID.
  • Use lockForUpdate() and DB::transaction().
  • Check whether the order is already paid.
  • Reject duplicate processing safely.
  • Never credit the same wallet twice or activate the same package twice.

Optional Payment Verification

The signed webhook is the primary source of truth. You can optionally confirm the payment with the official JJuma verification endpoint using the transaction ID or reference.

php
$verification = $jjuma->verifyPayment($transactionId);
if (data_get($verification, 'status') !== 'success') {
    throw new RuntimeException('Payment verification failed.');
}

Webhook Response Codes

text
200 OK - Event verified and processed, ignored safely, or already processed
400 Bad Request - Invalid JSON or missing payment data
401 Unauthorized - Invalid signature or timestamp
405 Method Not Allowed - Request method is not POST
500 Internal Server Error - Temporary server or database error

A 500 response may cause JJuma to retry the webhook according to its official retry behaviour.

Logging

php
Log::info('JJuma webhook processed', [
    'event_type' => $eventType,
    'order_id' => $orderId,
    'transaction_id' => $transactionId,
]);

Log::error('JJuma webhook processing failed', [
    'event_type' => $eventType,
    'order_id' => $orderId,
    'message' => $exception->getMessage(),
]);
  • Log event type, order ID, transaction ID, payment reference, processing result, error category, and processing time.
  • Do not log public API keys, secret API keys, webhook secrets, authorization headers, passwords, card details, or authentication tokens.

Queues and Slow Work

Keep the webhook response fast. Save the verified payment result, commit the transaction, then dispatch slow tasks such as emails, SMS, receipts, reports, or external API calls as queued jobs.

php
SendPaymentReceipt::dispatch($order->getKey());

Laravel Tests

php
Http::fake([
    '*' => Http::response([
        'data' => ['payment_url' => 'https://pay.jjuma.com/checkout/example'],
    ], 200),
]);
php
$rawBody = json_encode($payload, JSON_UNESCAPED_SLASHES);
$signature = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, config('services.jjuma.webhook_secret'));
  • A valid unpaid order creates a payment and redirects to a trusted checkout URL.
  • An invalid order returns an error.
  • A paid order cannot be paid again.
  • A valid webhook marks the order paid.
  • Invalid signatures, expired timestamps, and invalid JSON return the correct error codes.

Troubleshooting

  • Webhook signature is invalid: check the raw body, webhook secret, timestamp format, and proxy body handling.
  • Payment creation returns unauthorized: check the Bearer header, public key, and JJuma base URL.
  • Checkout redirects to the wrong domain: validate the returned URL and restrict redirects to the confirmed checkout host.
  • Customer returns but order stays pending: the redirect is not confirmation; check the webhook URL and selected events.
  • Payment is processed twice: check the unique transaction index, row locking, transaction handling, and duplicate delivery logic.
  • Database connection fails: check host, port, username, password, and firewall rules.

Production Preparation

  • Run the app behind a production web server.
  • Enable HTTPS.
  • Store credentials in environment variables.
  • Use php artisan config:cache after deployment changes.
  • Keep the raw webhook body intact.
  • Forward webhook headers unchanged.
  • Monitor logs and keep dependencies updated.

Security Checklist

Final Integration Checklist

Laravel project created, environment variables configured, JJuma service added, order lookup implemented, payment controller added, success and cancel views added, webhook route added, raw body verification added, required events selected, signature verification confirmed, amount and currency validation added, duplicate protection added, HTTPS enabled, and production webhook tested.

Full Payment Flow

Customer clicks Pay with JJuma, Laravel loads the order, creates a payment, redirects to the JJuma checkout URL, receives a signed webhook, verifies the raw body and timestamp, validates the order amount and currency, updates the database once, and then returns the customer to the success or cancel page.