JJuma Global logo Developer Docs
JJuma Pay Documentation

Node.js API Example

This guide shows a complete Express integration that creates JJuma payments from your server, redirects customers to hosted checkout, verifies signed webhooks,…

Node.js Integration

Security Never expose your Secret API Key or Webhook Secret in frontend JavaScript, React applications, browser code, mobile applications, or public repositories. Keep protected credentials on your Node.js server.

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.

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

Project files

  • package.json
  • .env
  • .env.example
  • .gitignore
  • src/app.js
  • src/config.js
  • src/jjuma-client.js
  • src/database.js
  • src/routes/payments.js
  • src/routes/webhook.js
  • src/services/order-service.js

Credentials

  • Public API Key for create-payment
  • Secret API Key for server verification
  • Webhook Secret for signature checks

Install packages

bash
npm init -y
npm install express dotenv mysql2

If you use built-in fetch, use a Node.js version that supports it.

package.json

json
{
  "name": "jjuma-node-integration",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node src/app.js",
    "dev": "node --watch src/app.js"
  }
}

.env.example

env
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

.gitignore

text
node_modules/
.env
npm-debug.log*

src/config.js

js
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')
  }
});

src/jjuma-client.js

js
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);
  }
}

src/database.js

js
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'
});

Orders table

sql
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)
);

src/services/order-service.js

js
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();
  }
}

src/app.js

js
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);
});
Raw body requirement Register express.raw() for the JJuma webhook route before express.json(). If JSON parsing runs first, signature verification can fail.

src/routes/payments.js

js
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('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#039;');
}

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>'
  );
});
Redirects are not payment confirmation The success page only means the customer returned from JJuma checkout. The verified webhook must update wallets, subscriptions, packages, invoices, or orders.

Webhook configuration

  1. Log in to the JJuma dashboard.
  2. Open Tools.
  3. Open Webhooks.
  4. Add the merchant webhook URL.
  5. Choose payment.completed, payment.failed, and payment.cancelled.
  6. Save and enable the webhook.

src/routes/webhook.js

js
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.' });
  }
});
Duplicate protection and testing Store the webhook 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.

Payment verification

js
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.

Frontend payment button examples

html
<form action="/payments/create" method="post">
  <input type="hidden" name="order_id" value="ORDER-1001">
  <button type="submit">Pay with JJuma</button>
</form>
js
const response = await fetch('/payments/create', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ order_id: 'ORDER-1001' })
});

Testing checklist

  • Node.js application starts successfully
  • Environment variables are validated
  • Database connection works
  • Payment route accepts a valid order ID
  • Order amount is loaded from the database
  • UGX currency is used correctly
  • JJuma payment request succeeds
  • Checkout URL is validated
  • Customer is redirected to JJuma
  • Success redirect opens correctly
  • Cancel redirect opens correctly
  • Webhook route receives a raw Buffer
  • Valid signature is accepted
  • Invalid signature is rejected
  • Expired timestamp is rejected

Security checklist

  • Keep protected credentials on the Node.js server
  • Never expose the Webhook Secret
  • Never expose the Secret API Key
  • Use HTTPS
  • Verify the raw webhook body
  • Verify the timestamp
  • Use timing safe signature comparison
  • Load the amount from the database
  • Validate currency
  • Validate merchant reference
  • Prevent duplicate processing
  • Use database transactions
  • Use prepared statements
  • Escape HTML output
  • Validate checkout redirect domains
  • Do not log secrets
  • Do not activate services from redirect routes
  • Keep dependencies and Node.js updated