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.
Keep protected credentials on the Laravel server only.
Table of Contents
Jump directly to the parts of the Laravel guide you need.
Introduction
Use Laravel’s built-in HTTP client, validation, logging, and database transactions.
SetupProject Structure
Organize the controller, service, model, request, views, and routes.
ServiceJJuma Service
Wrap create-payment and verification calls in one reusable class.
FormPayment Request
Validate only the order ID and keep pricing data on the server.
PayCheckout Flow
Create a payment, redirect to checkout, and keep redirects separate from confirmation.
WebhookSigned Webhooks
Read the raw body, verify the signature, and process each payment only once.
CheckPayment Validation
Confirm the webhook event, order, amount, currency, and merchant reference.
HTTPResponse Codes
Return the right status codes so JJuma can retry or stop safely.
LogsLogging
Capture useful payment diagnostics without leaking secrets.
AsyncQueues
Keep webhook handling fast and move slow work into jobs.
ProdProduction Prep
Use HTTPS, config cache, and a production web server.
Introduction
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
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
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
composer create-project laravel/laravel jjuma-laravel-integration
cd jjuma-laravel-integration
Environment Variables and Configuration
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
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
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
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
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
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
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
// 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');
<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>
<!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>
<!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>
Webhook Verification
Use the original raw body with $request->getContent(). Do not rebuild the payload from decoded JSON.
<?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']);
}
}
$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
$table->string('jjuma_transaction_id', 150)->nullable()->unique();
- Save the JJuma transaction ID.
- Use
lockForUpdate()andDB::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.
$verification = $jjuma->verifyPayment($transactionId);
if (data_get($verification, 'status') !== 'success') {
throw new RuntimeException('Payment verification failed.');
}
Webhook Response Codes
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
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.
SendPaymentReceipt::dispatch($order->getKey());
Laravel Tests
Http::fake([
'*' => Http::response([
'data' => ['payment_url' => 'https://pay.jjuma.com/checkout/example'],
], 200),
]);
$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:cacheafter deployment changes. - Keep the raw webhook body intact.
- Forward webhook headers unchanged.
- Monitor logs and keep dependencies updated.
Security Checklist
Keep credentials server-side
Never expose protected values in Blade templates or API responses.
Verify raw webhook body
Use the original bytes sent by JJuma.
Validate money values
Load amounts and currencies from the database.
Prevent duplicates
Use transactions and unique indexes.
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.