API Base
All API requests are sent to https://api.jjuma.com.
Developer Docs
Payment links, checkout and API integration guide for developers building on the Jjuma Pay platform across Uganda and East Africa.
API access is available only for verified merchants.
Jump directly into the most common setup and integration topics.
Create an account, complete KYC, and launch your first payment flow.
SecureUse Bearer tokens safely from your backend only.
CollectCreate fixed, donation, or custom amount payment links.
HostedRoute customers to a hosted payment page on pay.jjuma.com.
TrackReview transaction status, amounts, and payment method details.
EventsReceive payment notifications after status changes.
KeysGenerate, rotate, and revoke merchant API credentials.
VerifyComplete verification to unlock payment links and API access.
StoreConnect WordPress stores with verified merchant credentials.
CodesUnderstand API responses and common failure states.
SDKCopy integration snippets for common stacks.
QAValidate the payment flow before launch.
ProdFollow the production launch checklist before accepting live funds.
UpdatesTrack documentation releases and guide updates.
All API requests are sent to https://api.jjuma.com.
Use https://dashboard.jjuma.com to create links, review transactions, and manage keys.
Share a hosted link for school fees, donations, creator support, church giving, and event payments.
Send customers to a secure checkout page where Jjuma Pay handles the payment experience.
Authenticate from your backend to manage transactions, link creation, and production integrations.
Jjuma Pay is a payment platform for creators, freelancers, churches, NGOs, schools, and businesses that need a simple and secure payment experience in Uganda and East Africa.
https://api.jjuma.com
https://pay.jjuma.com
Use Bearer token authorization from your server only. API keys are generated from the dashboard after verification.
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
curl -X GET https://api.jjuma.com/api/v1/transactions \
-H "Authorization: Bearer YOUR_API_KEY"
Use the payments API to create and verify payments from your backend.
POST https://api.jjuma.com/api/v1/payments/create
{
"amount": 50000,
"currency": "UGX",
"description": "Premium content access",
"customer_name": "John Doe",
"customer_email": "john@example.com",
"redirect_url": "https://yourwebsite.com/success",
"return_url": "https://yourwebsite.com/success",
"cancel_redirect_url": "https://yourwebsite.com/failed",
"webhook_url": "https://yourwebsite.com/webhooks/jjuma"
}
{
"status": "success",
"data": {
"transaction_id": "TXN-A1B2C3D4E5F6",
"reference": "REF-12345678",
"payment_url": "https://pay.jjuma.com/pay/TXN-A1B2C3D4E5F6",
"status": "pending"
}
}
Payment links are ideal for school fees, donations, creators, and small business collections.
POST https://api.jjuma.com/api/payment-links
{
"title": "School Fees Payment",
"description": "Payment for Term One school fees",
"amount": 50000,
"currency": "UGX",
"amount_type": "fixed"
}
{
"success": true,
"payment_link": "https://pay.jjuma.com/p/school-fees-payment",
"reference": "JJPAY_123456"
}
API keys are created from the dashboard for verified merchants. Keep secret keys on the server and rotate them if exposure is suspected.
Verification is required to reduce fraud and unlock payment links, API keys, and production access.
Hosted checkout runs on pay.jjuma.com and shows the merchant name, amount, description, and supported payment gateways.
https://pay.jjuma.com/p/{slug}
https://pay.jjuma.com/checkout/{reference}
Use transaction records to confirm payment status, amount, currency, payer details, and the Jjuma payment reference after a checkout completes.
| Endpoint | Purpose |
|---|---|
| GET /api/v1/transactions | List the authenticated merchant's transactions. |
| GET /api/v1/payments/verify/{transaction_id} | Verify a payment by transaction ID. |
Browser redirects and merchant webhooks are different things. Use redirect_url or return_url for the payer's browser, and use webhook_url for your backend to update order status safely.
This is the browser-based return path after payment. It should be visible to the customer and safe to open in a browser.
redirect_url or return_url in API create-payment requests.callback_url is kept as a legacy browser-return alias when older integrations still send it.https://jjuma.com unless a merchant return URL is present on an API checkout.{
"amount": 50000,
"currency": "UGX",
"customer_name": "John Doe",
"redirect_url": "https://yourwebsite.com/success",
"return_url": "https://yourwebsite.com/success"
}
This is a server-to-server notification. JJuma posts to your webhook after a verified payment succeeds or fails, so your system can update orders even if the customer closes the browser.
webhook_url for payment status updates.X-Jjuma-Signature and X-Jjuma-Timestamp headers before updating your database.{
"event": "payment.completed",
"event_id": "evt_123",
"delivery_id": "wh_456",
"transaction_id": "TXN-A1B2C3D4E5F6",
"reference": "REF-12345678",
"tx_ref": "REF-12345678",
"amount": 50000,
"currency": "UGX",
"status": "successful",
"payment_status": "successful",
"settlement_status": "waiting_settlement",
"timestamp": "2026-07-14T10:00:00Z",
"metadata": { "order_id": "ORD-001" }
}
Your backend can also query JJuma directly to double-check a transaction after the webhook arrives.
/api/v1/payments/verify/{transaction_id}GET https://api.jjuma.com/api/v1/payments/verify/TXN-A1B2C3D4E5F6
redirect_url, return_url, callback_url, success_url, merchant_site_url, and webhook_url. For browser return use the redirect fields; for status updates use webhook_url.
Configure up to three destinations in Dashboard > Tools > Webhooks. Only active destinations receive their selected events. Use HTTPS in production and keep handlers idempotent.
{
"event": "payment.completed",
"event_id": "evt_123",
"delivery_id": "wh_456",
"reference": "REF-12345678",
"amount": 50000,
"currency": "UGX",
"status": "successful"
}
Dashboard-managed deliveries use HMAC SHA256 with the exact raw body. Sign X-Jjuma-Timestamp + "." + raw_body using your owner-only webhook secret.
X-Jjuma-Signature: sha256=<hex digest> (raw hex is also accepted by the verifier)X-Jjuma-TimestampX-Jjuma-EventX-Jjuma-Delivery-Idconst crypto = require('crypto');
const timestamp = req.get('X-Jjuma-Timestamp') || '';
const supplied = (req.get('X-Jjuma-Signature') || '').replace(/^sha256=/i, '');
const expected = crypto.createHmac('sha256', process.env.JJUMA_WEBHOOK_SECRET)
.update(timestamp + '.' + req.rawBody.toString('utf8')).digest('hex');
const valid = supplied.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(supplied, 'hex'), Buffer.from(expected, 'hex'));
$raw = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_JJUMA_TIMESTAMP'] ?? '';
$signature = preg_replace('/^sha256=/i', '', $_SERVER['HTTP_X_JJUMA_SIGNATURE'] ?? '');
$expected = hash_hmac('sha256', $timestamp . '.' . $raw, getenv('JJUMA_WEBHOOK_SECRET'));
if (!hash_equals($expected, $signature)) { http_response_code(401); exit; }
raw = request.get_data()
timestamp = request.headers.get('X-Jjuma-Timestamp', '')
signature = request.headers.get('X-Jjuma-Signature', '').removeprefix('sha256=')
expected = hmac.new(SECRET.encode(), timestamp.encode() + b'.' + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature): abort(401)
Regenerating the secret keeps webhook destinations but invalidates the old secret. Request-level transaction webhook URLs do not use the dashboard signing secret.
WooCommerce integrations should use server-side verification and webhook confirmation before marking orders paid.
| Code | Message |
|---|---|
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not Found |
| 422 | Validation Error |
| 500 | Server Error |
fetch('https://api.jjuma.com/api/v1/payments/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY'
}
})
This section shows a complete PHP integration flow that matches the existing Jjuma Pay API rules, webhook headers, and response format.
config.phpjjuma-client.phppay.phpsuccess.phpcancel.phpwebhook.phpUse this guide when your checkout, return pages, and webhook handler are built in PHP. The browser should be redirected to the hosted Jjuma checkout page, while payment confirmation and order updates must happen on the server.
| File | Purpose |
|---|---|
config.php |
Stores API keys, return URLs, webhook URL, and database connection settings. |
jjuma-client.php |
Wraps the JJuma create-payment and verify-payment requests. |
pay.php |
Creates the payment, saves the local record, and redirects the customer to checkout. |
success.php |
Shows the return page and can verify the transaction again from the server. |
cancel.php |
Shows the cancel page and lets the customer retry later. |
webhook.php |
Verifies the webhook signature, prevents duplicates, and updates your database. |
Keep your credentials and merchant URLs on the server only.
<?php
declare(strict_types=1);
define('JJUMA_API_BASE_URL', 'https://api.jjuma.com');
define('JJUMA_PUBLIC_API_KEY', 'bp_live_pub_your_public_key');
define('JJUMA_SECRET_API_KEY', 'YOUR_SECRET_API_KEY');
define('JJUMA_WEBHOOK_SECRET', 'your_webhook_secret');
define('APP_BASE_URL', 'https://merchant.example.com');
define('JJUMA_SUCCESS_URL', APP_BASE_URL . '/success.php');
define('JJUMA_CANCEL_URL', APP_BASE_URL . '/cancel.php');
define('JJUMA_WEBHOOK_URL', APP_BASE_URL . '/webhook.php');
$pdo = new PDO(
'mysql:host=localhost;dbname=merchant_db;charset=utf8mb4',
'db_user',
'db_password',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
This helper keeps your JJuma calls in one place and reuses the documented auth header and response handling.
<?php
declare(strict_types=1);
final class JjumaClient
{
private string $apiBaseUrl;
private string $publicApiKey;
private string $secretApiKey;
public function __construct(string $apiBaseUrl, string $publicApiKey, string $secretApiKey)
{
$this->apiBaseUrl = $apiBaseUrl;
$this->publicApiKey = $publicApiKey;
$this->secretApiKey = $secretApiKey;
}
public function createPayment(array $payload): array
{
return $this->request('POST', '/api/v1/payments/create', $this->publicApiKey, $payload);
}
public function verifyPayment(string $transactionId): array
{
return $this->request('GET', '/api/v1/payments/verify/' . rawurlencode($transactionId), $this->secretApiKey);
}
private function request(string $method, string $path, string $apiKey, ?array $payload = null): array
{
$ch = curl_init($this->apiBaseUrl . $path);
$headers = [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
'Accept: application/json',
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
]);
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_SLASHES));
}
$body = curl_exec($ch);
if ($body === false) {
$error = curl_error($ch) ?: 'Unknown cURL error';
curl_close($ch);
throw new RuntimeException($error);
}
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$decoded = json_decode($body, true);
if (!is_array($decoded)) {
throw new RuntimeException('JJuma returned invalid JSON.');
}
if ($status < 200 || $status >= 300) {
throw new RuntimeException($decoded['message'] ?? 'JJuma request failed.');
}
return $decoded;
}
}
Create the payment from your server, store the local record, and redirect the customer to the hosted checkout page.
<?php
declare(strict_types=1);
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/jjuma-client.php';
$client = new JjumaClient(JJUMA_API_BASE_URL, JJUMA_PUBLIC_API_KEY, JJUMA_SECRET_API_KEY);
$orderId = trim($_POST['order_id'] ?? '');
$payload = [
'amount' => (int) ($_POST['amount'] ?? 0),
'currency' => 'UGX',
'description' => trim($_POST['description'] ?? 'Order payment'),
'customer_name' => trim($_POST['customer_name'] ?? ''),
'customer_email' => trim($_POST['customer_email'] ?? ''),
'customer_phone' => trim($_POST['customer_phone'] ?? ''),
'redirect_url' => JJUMA_SUCCESS_URL . '?order_id=' . rawurlencode($orderId),
'return_url' => JJUMA_SUCCESS_URL . '?order_id=' . rawurlencode($orderId),
'cancel_redirect_url' => JJUMA_CANCEL_URL . '?order_id=' . rawurlencode($orderId),
'webhook_url' => JJUMA_WEBHOOK_URL,
'metadata' => ['order_id' => $orderId],
'external_order_id' => $orderId,
'idempotency_key' => 'order-' . $orderId,
'provider' => 'jjuma',
];
$response = $client->createPayment($payload);
$paymentUrl = $response['data']['payment_url'] ?? '';
$transactionId = $response['data']['transaction_id'] ?? '';
$reference = $response['data']['reference'] ?? '';
// Save $transactionId, $reference, and a pending status in your merchant database here.
if ($paymentUrl === '') {
http_response_code(500);
exit('Payment URL missing.');
}
header('Location: ' . $paymentUrl);
exit;
Return the customer to your website, then verify the payment again on the server using the stored transaction ID.
<?php
declare(strict_types=1);
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/jjuma-client.php';
$orderId = trim($_GET['order_id'] ?? '');
// Load the local record by $orderId and fetch the saved transaction_id.
$transactionId = 'TXN-A1B2C3D4E5F6';
$client = new JjumaClient(JJUMA_API_BASE_URL, JJUMA_PUBLIC_API_KEY, JJUMA_SECRET_API_KEY);
$verification = $client->verifyPayment($transactionId);
if (($verification['data']['status'] ?? '') === 'successful') {
// Mark the order, subscription, package, donation, or balance as paid.
}
?>
<!doctype html>
<html><body>
<h1>Payment received</h1>
<p>Thanks for your order. We are confirming the payment on the server.</p>
</body></html>
Use the cancel page to show that checkout was closed and that the customer can try again later.
<?php
declare(strict_types=1);
$orderId = trim($_GET['order_id'] ?? '');
// Update the local record as cancelled or left pending, based on your business rules.
?>
<!doctype html>
<html><body>
<h1>Checkout cancelled</h1>
<p>No payment was completed. The customer can return and try again.</p>
</body></html>
Use the webhook to keep your merchant records in sync even if the browser is closed after checkout.
<?php
declare(strict_types=1);
require_once __DIR__ . '/config.php';
$rawBody = file_get_contents('php://input') ?: '';
$timestamp = $_SERVER['HTTP_X_JJUMA_TIMESTAMP'] ?? '';
$signature = preg_replace('/^sha256=/i', '', $_SERVER['HTTP_X_JJUMA_SIGNATURE'] ?? '');
$event = $_SERVER['HTTP_X_JJUMA_EVENT'] ?? '';
$deliveryId = $_SERVER['HTTP_X_JJUMA_DELIVERY_ID'] ?? '';
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, JJUMA_WEBHOOK_SECRET);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
$data = json_decode($rawBody, true);
if (!is_array($data)) {
http_response_code(400);
exit('Invalid JSON');
}
$eventId = $data['event_id'] ?? '';
$transactionId = $data['transaction_id'] ?? ($data['data']['transaction_id'] ?? '');
$reference = $data['reference'] ?? ($data['data']['reference'] ?? '');
// Skip duplicate event_id, deliveryId, transactionId, or reference values in your database.
// Mark the event as processed before applying business updates.
switch ($event) {
case 'payment.completed':
// Mark orders, subscriptions, packages, donations, or balances as paid.
break;
case 'payment.failed':
case 'payment.cancelled':
// Keep the local record unpaid or cancelled.
break;
case 'settlement.completed':
// If you track wallet balances or settlement status, update them here.
break;
case 'withdrawal.paid':
// Update withdrawal records if your application tracks payouts.
break;
}
http_response_code(200);
echo 'OK';
event_id, delivery_id, transaction_id, or reference in a processed-events table and ignore repeats so the same payment cannot update your order twice.
pay.php and confirm the customer lands on the hosted checkout page.success.php or cancel.php.webhook.php with a valid signature.This guide shows a complete Express integration that creates JJuma payments from your server, redirects customers to hosted checkout, verifies signed webhooks, updates the merchant database once, and keeps duplicate deliveries safe.
package.json.env.env.example.gitignoresrc/app.jssrc/config.jssrc/jjuma-client.jssrc/database.jssrc/routes/payments.jssrc/routes/webhook.jssrc/services/order-service.jsnpm init -y
npm install express dotenv mysql2
If you use built-in fetch, use a Node.js version that supports it.
{
"name": "jjuma-node-integration",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/app.js",
"dev": "node --watch src/app.js"
}
}
PORT=3000
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
MERCHANT_BASE_URL=https://merchantwebsite.com
DEFAULT_CURRENCY=UGX
DB_HOST=localhost
DB_PORT=3306
DB_NAME=merchant_database
DB_USER=merchant_user
DB_PASSWORD=merchant_password
node_modules/
.env
npm-debug.log*
import 'dotenv/config';
function requireEnvironmentVariable(name) {
const value = process.env[name]?.trim();
if (!value) {
throw new Error('Missing required environment variable: ' + name);
}
return value;
}
function requireUrl(name) {
const value = requireEnvironmentVariable(name);
try {
return new URL(value).toString().replace(/\/$/, '');
} catch {
throw new Error(name + ' must be a valid URL');
}
}
const port = Number.parseInt(process.env.PORT || '3000', 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('PORT must be a valid network port');
}
export const config = Object.freeze({
port,
jjumaApiBaseUrl: requireUrl('JJUMA_API_BASE_URL'),
jjumaPublicKey: requireEnvironmentVariable('JJUMA_PUBLIC_KEY'),
jjumaSecretKey: requireEnvironmentVariable('JJUMA_SECRET_KEY'),
jjumaWebhookSecret: requireEnvironmentVariable('JJUMA_WEBHOOK_SECRET'),
merchantBaseUrl: requireUrl('MERCHANT_BASE_URL'),
defaultCurrency: process.env.DEFAULT_CURRENCY?.trim() || 'UGX',
database: {
host: requireEnvironmentVariable('DB_HOST'),
port: Number.parseInt(process.env.DB_PORT || '3306', 10),
name: requireEnvironmentVariable('DB_NAME'),
user: requireEnvironmentVariable('DB_USER'),
password: requireEnvironmentVariable('DB_PASSWORD')
}
});
import { config } from './config.js';
export class JjumaApiError extends Error {
constructor(message, options = {}) {
super(message);
this.name = 'JjumaApiError';
this.statusCode = options.statusCode;
this.response = options.response;
}
}
export async function jjumaRequest({ method, endpoint, accessKey, body, timeoutMs = 60000 }) {
const url = new URL(endpoint.replace(/^\//, ''), config.jjumaApiBaseUrl + '/');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method: method.toUpperCase(),
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: 'Bearer ' + accessKey
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: controller.signal
});
const responseText = await response.text();
let responseData = {};
if (responseText) {
try {
responseData = JSON.parse(responseText);
} catch {
throw new JjumaApiError('JJuma returned an invalid JSON response.', { statusCode: response.status });
}
}
if (!response.ok) {
const message = responseData.message || responseData.detail || responseData.error || 'The JJuma request failed.';
throw new JjumaApiError(String(message), { statusCode: response.status, response: responseData });
}
return responseData;
} catch (error) {
if (error instanceof JjumaApiError) {
throw error;
}
if (error && error.name === 'AbortError') {
throw new JjumaApiError('The JJuma request timed out.');
}
throw new JjumaApiError('The merchant server could not connect to JJuma.');
} finally {
clearTimeout(timeout);
}
}
import mysql from 'mysql2/promise';
import { config } from './config.js';
export const database = mysql.createPool({
host: config.database.host,
port: config.database.port,
database: config.database.name,
user: config.database.user,
password: config.database.password,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
charset: 'utf8mb4'
});
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id VARCHAR(100) NOT NULL,
customer_id BIGINT UNSIGNED NULL,
amount DECIMAL(15, 2) NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'UGX',
payment_status VARCHAR(30) NOT NULL DEFAULT 'pending',
jjuma_transaction_id VARCHAR(150) NULL,
jjuma_reference VARCHAR(150) NULL,
paid_amount DECIMAL(15, 2) NULL,
paid_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY unique_order_id (order_id),
UNIQUE KEY unique_jjuma_transaction_id (jjuma_transaction_id)
);
import { database } from '../database.js';
export async function findOrderByOrderId(orderId) {
const [rows] = await database.execute(
'SELECT id, order_id, amount, currency, payment_status, jjuma_transaction_id, jjuma_reference FROM orders WHERE order_id = ? LIMIT 1',
[orderId]
);
return rows[0] || null;
}
export async function markOrderPaidOnce({ orderId, transactionId, reference, amount, currency }) {
const connection = await database.getConnection();
try {
await connection.beginTransaction();
const [rows] = await connection.execute(
'SELECT id, order_id, amount, currency, payment_status, jjuma_transaction_id FROM orders WHERE order_id = ? FOR UPDATE',
[orderId]
);
const order = rows[0];
if (!order) {
throw new Error('Order not found.');
}
if (order.payment_status === 'paid') {
await connection.commit();
return { alreadyProcessed: true };
}
if (String(order.currency).toUpperCase() !== String(currency).toUpperCase()) {
throw new Error('Payment currency does not match the order.');
}
if (Number(amount) < Number(order.amount)) {
throw new Error('Payment amount is lower than the order amount.');
}
await connection.execute(
'UPDATE orders SET payment_status = ?, jjuma_transaction_id = ?, jjuma_reference = ?, paid_amount = ?, paid_at = NOW() WHERE id = ?',
['paid', transactionId, reference, amount, order.id]
);
await connection.commit();
return { alreadyProcessed: false };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
import express from 'express';
import { config } from './config.js';
import { paymentRouter } from './routes/payments.js';
import { webhookRouter } from './routes/webhook.js';
const app = express();
app.disable('x-powered-by');
app.use('/webhooks/jjuma', express.raw({ type: 'application/json', limit: '1mb' }), webhookRouter);
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: false, limit: '1mb' }));
app.use('/payments', paymentRouter);
app.get('/health', (request, response) => {
response.status(200).json({ status: 'ok' });
});
app.use((error, request, response, next) => {
console.error('Application error:', { message: error.message, path: request.path });
if (response.headersSent) {
return next(error);
}
return response.status(500).json({ message: 'The request could not be completed.' });
});
app.listen(config.port, () => {
console.log('Merchant application listening on port ' + config.port);
});
express.raw() for the JJuma webhook route before express.json(). If JSON parsing runs first, signature verification can fail.
import { Router } from 'express';
import crypto from 'node:crypto';
import { config } from '../config.js';
import { JjumaApiError, jjumaRequest } from '../jjuma-client.js';
import { findOrderByOrderId } from '../services/order-service.js';
export const paymentRouter = Router();
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
paymentRouter.post('/create', async (request, response) => {
try {
const orderId = String(request.body.order_id || '').trim();
if (!orderId || orderId.length > 100) {
return response.status(400).json({ message: 'A valid order ID is required.' });
}
const order = await findOrderByOrderId(orderId);
if (!order) {
return response.status(404).json({ message: 'Order not found.' });
}
if (String(order.payment_status).toLowerCase() === 'paid') {
return response.status(409).json({ message: 'This order has already been paid.' });
}
const merchantReference = [order.order_id, Date.now(), crypto.randomBytes(6).toString('hex')].join('-');
const successUrl = new URL('/payments/success', config.merchantBaseUrl);
successUrl.searchParams.set('order_id', order.order_id);
const cancelUrl = new URL('/payments/cancel', config.merchantBaseUrl);
cancelUrl.searchParams.set('order_id', order.order_id);
const payload = {
amount: Number(order.amount),
currency: String(order.currency || 'UGX').toUpperCase(),
reference: merchantReference,
description: 'Payment for order ' + order.order_id,
customer_name: order.customer_name || 'Customer',
customer_email: order.customer_email || undefined,
customer_phone: order.customer_phone || undefined,
redirect_url: successUrl.toString(),
return_url: successUrl.toString(),
cancel_redirect_url: cancelUrl.toString(),
webhook_url: new URL('/webhooks/jjuma', config.merchantBaseUrl).toString(),
metadata: { order_id: order.order_id },
external_order_id: order.order_id,
idempotency_key: 'order-' + order.order_id,
provider: 'jjuma'
};
const result = await jjumaRequest({
method: 'POST',
endpoint: '/api/v1/payments/create',
accessKey: config.jjumaPublicKey,
body: payload
});
const checkoutUrl = result.data?.payment_url || result.payment_url || '';
const parsedCheckoutUrl = new URL(checkoutUrl);
if (parsedCheckoutUrl.hostname !== 'pay.jjuma.com') {
throw new Error('JJuma returned an untrusted checkout URL.');
}
return response.redirect(302, parsedCheckoutUrl.toString());
} catch (error) {
if (error instanceof JjumaApiError) {
return response.status(error.statusCode || 502).json({ message: error.message });
}
console.error('Create payment error:', { message: error.message });
return response.status(500).json({ message: 'The payment could not be created.' });
}
});
paymentRouter.get('/success', (request, response) => {
const orderId = escapeHtml(request.query.order_id || '');
return response.status(200).type('html').send(
'<!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>' +
(orderId ? '<p>Order reference: ' + orderId + '</p>' : '') +
'<a href="/">Continue to website</a></main></body>' +
'</html>'
);
});
paymentRouter.get('/cancel', (request, response) => {
const orderId = escapeHtml(request.query.order_id || '');
return response.status(200).type('html').send(
'<!DOCTYPE html>' +
'<html lang="en">' +
'<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Payment Cancelled</title></head>' +
'<body><main><h1>Payment not completed</h1><p>Your payment was not completed. You can return and try again.</p>' +
(orderId ? '<a href="/payments/retry?order_id=' + encodeURIComponent(orderId) + '">Try again</a>' : '<a href="/">Return to website</a>') +
'</main></body>' +
'</html>'
);
});
import crypto from 'node:crypto';
import { Router } from 'express';
import { config } from '../config.js';
import { markOrderCancelled, markOrderFailed, markOrderPaidOnce } from '../services/order-service.js';
export const webhookRouter = Router();
function getHeader(request, name) {
const value = request.get(name);
return value ? value.trim() : '';
}
function secureCompare(expected, received) {
const expectedBuffer = Buffer.from(expected, 'utf8');
const receivedBuffer = Buffer.from(received, 'utf8');
if (expectedBuffer.length !== receivedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}
function extractPaymentDetails(eventData) {
const data = eventData?.data || eventData || {};
return {
eventType: String(eventData?.event || data.event || '').trim(),
orderId: String(data.order_id || eventData.order_id || data.metadata?.order_id || data.metadata?.woocommerce_order_id || '').trim(),
transactionId: String(data.transaction_id || eventData.transaction_id || '').trim(),
reference: String(data.reference || data.tx_ref || eventData.reference || eventData.tx_ref || '').trim(),
amount: Number(data.amount ?? eventData.amount ?? 0),
currency: String(data.currency || eventData.currency || 'UGX').trim().toUpperCase(),
deliveryId: String(data.delivery_id || eventData.delivery_id || '').trim(),
paymentStatus: String(data.payment_status || eventData.payment_status || data.status || eventData.status || '').trim().toLowerCase(),
failureReason: String(data.failure_reason || eventData.failure_reason || data.message || eventData.message || '').trim()
};
}
async function handlePaymentCompleted(eventData) {
const details = extractPaymentDetails(eventData);
if (!details.orderId || !details.transactionId || !details.reference || !Number.isFinite(details.amount) || !details.currency) {
throw new Error('Completed payment event is missing required fields.');
}
const result = await markOrderPaidOnce(details);
return result?.alreadyProcessed ? 'already-processed' : 'processed';
}
async function handlePaymentFailed(eventData) {
const details = extractPaymentDetails(eventData);
await markOrderFailed(details);
}
async function handlePaymentCancelled(eventData) {
const details = extractPaymentDetails(eventData);
await markOrderCancelled(details);
}
webhookRouter.post('/', async (request, response) => {
try {
const signature = getHeader(request, 'X-Jjuma-Signature');
const timestamp = getHeader(request, 'X-Jjuma-Timestamp');
if (!signature || !timestamp) {
return response.status(401).json({ message: 'Missing webhook authentication headers.' });
}
if (!Buffer.isBuffer(request.body)) {
return response.status(400).json({ message: 'The webhook body is invalid.' });
}
const eventTime = new Date(timestamp).getTime();
if (!Number.isFinite(eventTime) || Math.abs(Date.now() - eventTime) > 300000) {
return response.status(401).json({ message: 'Expired webhook timestamp.' });
}
const rawBody = request.body.toString('utf8');
const receivedSignature = signature.replace(/^sha256=/i, '').trim();
const expectedSignature = crypto.createHmac('sha256', config.jjumaWebhookSecret).update(timestamp + '.' + rawBody).digest('hex');
if (!secureCompare(expectedSignature, receivedSignature)) {
return response.status(401).json({ message: 'Invalid webhook signature.' });
}
let event;
try {
event = JSON.parse(rawBody);
} catch {
return response.status(400).json({ message: 'Invalid webhook JSON.' });
}
const eventType = String(event.event || event.data?.event || '').trim();
switch (eventType) {
case 'payment.completed':
await handlePaymentCompleted(event);
break;
case 'payment.failed':
await handlePaymentFailed(event);
break;
case 'payment.cancelled':
await handlePaymentCancelled(event);
break;
default:
console.log('Ignoring unsupported JJuma event:', eventType);
break;
}
return response.status(200).json({ message: 'OK' });
} catch (error) {
console.error('JJuma webhook processing failed', { message: error.message });
return response.status(500).json({ message: 'The webhook could not be processed.' });
}
});
const result = await jjumaRequest({
method: 'GET',
endpoint: '/api/v1/payments/verify/' + transactionId,
accessKey: config.jjumaSecretKey
});
Use this as a server-side confirmation step. It complements the signed webhook instead of replacing it.
<form action="/payments/create" method="post">
<input type="hidden" name="order_id" value="ORDER-1001">
<button type="submit">Pay with JJuma</button>
</form>
const response = await fetch('/payments/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: 'ORDER-1001' })
});