Server requirements
- Node.js with fetch support or another HTTP client
- npm or another package manager
- Express
- HTTPS SSL certificate
- Public webhook URL
- Merchant database access
- Accurate server time
Developer Docs
This guide shows a complete Express integration that creates JJuma payments from your server, redirects customers to hosted checkout, verifies signed webhooks,…
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' })
});