This guide accepts payments through Pesapal API 3.0 from Node.js. Pesapal hosts the payment page: your customer picks how to pay there (Pesapal’s status responses name methods such as M-Pesa, MTN, Tigo and cards), so one integration covers every method enabled on your Pesapal account. You’ll build:
- a
pesapal.jsclient: a cached token, IPN registration, orders and transaction status, - a
payments.jsmodule that saves each order before sending it, settles only fromGetTransactionStatus, and never settles a payment twice, - an Express server with the order, callback and IPN routes,
- a test that runs every case without calling Pesapal.
It uses Node’s built-in fetch (Node 18+). Every code block was run in a fresh project: the test passes, and the client was checked against a fake Pesapal server. API facts come from Pesapal’s API 3.0 documentation at developer.pesapal.com, checked in November 2026.
How it works
- Get a token:
POST /api/Auth/RequestTokenwith your consumer key and secret. It lasts at most 5 minutes. - Register your IPN URL once:
POST /api/URLSetup/RegisterIPN. Keep theipn_idit returns. - Submit an order:
POST /api/Transactions/SubmitOrderRequestwith your ownid, the amount, currency,callback_url,notification_id(theipn_id) and a billing address. Pesapal answers with anorder_tracking_idand aredirect_url. - Send the customer to
redirect_url(or load it in an iframe). They pay there. - Pesapal redirects them to your
callback_urland calls your IPN URL, both withOrderTrackingId,OrderMerchantReferenceandOrderNotificationType. Neither carries the payment status. - Ask for the status:
GET /api/Transactions/GetTransactionStatus?orderTrackingId=.... That is the source of truth.
Base URLs: sandbox https://cybqa.pesapal.com/pesapalv3, live https://pay.pesapal.com/v3.
Before you start
- Sandbox keys. Pesapal publishes demo consumer keys for the sandbox (linked from the authentication page of its developer docs as
api3-demo-keys.txt). Live keys are emailed to you when your Pesapal business account is opened. - A public URL for the IPN. Pesapal must reach it from the internet. For local testing, use a tunnel.
- Create a
.envfile (load it withnode --env-file=.env) and never commit it:
PESAPAL_BASE_URL=https://cybqa.pesapal.com/pesapalv3
PESAPAL_CONSUMER_KEY=your-consumer-key
PESAPAL_CONSUMER_SECRET=your-consumer-secret
PESAPAL_IPN_ID=
PUBLIC_URL=https://your-app.example.com
Install Express with npm install express, and set "type": "module" in package.json (npm pkg set type=module).
The Pesapal client
// pesapal.js
const base = () => (process.env.PESAPAL_BASE_URL ?? 'https://cybqa.pesapal.com/pesapalv3').replace(/\/$/, '');
export class PesapalError extends Error {
constructor(message, { status = 0, ambiguous = false, error = null } = {}) {
super(message);
this.status = status;
this.ambiguous = ambiguous; // true: the request may have worked. Look it up before doing anything else.
this.error = error; // Pesapal's error object: { type, code, message }
}
}
async function call(path, { method = 'GET', body, token } = {}) {
let res;
try {
res = await fetch(`${base()}${path}`, {
method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30_000),
});
} catch (err) {
throw new PesapalError(`Pesapal unreachable: ${err.message}`, { ambiguous: true });
}
const data = await res.json().catch(() => null);
if (res.status >= 500 || !data) throw new PesapalError(`Pesapal HTTP ${res.status}`, { status: res.status, ambiguous: true });
// Successful answers can carry an error object whose fields are all null: only a code or message is an error.
const error = data.error && (data.error.code || data.error.message) ? data.error : null;
if (!res.ok || error) {
throw new PesapalError(`Pesapal error: ${error?.code ?? res.status} ${error?.message ?? data.message ?? ''}`.trim(), { status: res.status, error });
}
return data;
}
let cached = null; // { token, expires }
/** A bearer token, cached until 30 seconds before its expiryDate. Pesapal tokens last at most 5 minutes. */
export async function token(now = Date.now()) {
if (cached && cached.expires - 30_000 > now) return cached.token;
const data = await call('/api/Auth/RequestToken', {
method: 'POST',
body: { consumer_key: process.env.PESAPAL_CONSUMER_KEY, consumer_secret: process.env.PESAPAL_CONSUMER_SECRET },
});
if (!data.token) throw new PesapalError('No token in the response');
const expires = Date.parse(/[zZ]|[+-]\d\d:\d\d$/.test(data.expiryDate ?? '') ? data.expiryDate : `${data.expiryDate}Z`); // UTC
cached = { token: data.token, expires: Number.isFinite(expires) ? Math.min(expires, now + 5 * 60_000) : now + 4 * 60_000 };
return cached.token;
}
export function forgetToken() {
cached = null;
}
/** Registers your IPN URL. Do it once per environment and keep the ipn_id: it is the notification_id of every order. */
export async function registerIpn(url, method = 'POST') {
const data = await call('/api/URLSetup/RegisterIPN', { method: 'POST', token: await token(), body: { url, ipn_notification_type: method } });
return data.ipn_id;
}
/** Creates the order. Returns { order_tracking_id, merchant_reference, redirect_url }. Nothing is paid yet. */
export async function submitOrder(order) {
const data = await call('/api/Transactions/SubmitOrderRequest', { method: 'POST', token: await token(), body: order });
if (!data.order_tracking_id || !data.redirect_url) throw new PesapalError('Order response without order_tracking_id or redirect_url');
return data;
}
/** The source of truth for an order: status_code 0 INVALID, 1 COMPLETED, 2 FAILED, 3 REVERSED. */
export async function getStatus(orderTrackingId) {
try {
return await call(`/api/Transactions/GetTransactionStatus?orderTrackingId=${encodeURIComponent(orderTrackingId)}`, { token: await token() });
} catch (err) {
if (err instanceof PesapalError) err.ambiguous = true; // a failed lookup proves nothing about the payment
throw err;
}
}
Pesapal answers errors with a JSON error object of type, code and message. The client turns those into a PesapalError. Network failures and 5xx answers are ambiguous: the order may exist, so it’s looked up, not resent.
Register the IPN once, from a small script, and put the ipn_id in .env as PESAPAL_IPN_ID:
// register-ipn.js (run: node --env-file=.env register-ipn.js)
import { registerIpn } from './pesapal.js';
const url = `${process.env.PUBLIC_URL}/pesapal/ipn`;
console.log('PESAPAL_IPN_ID=' + (await registerIpn(url, 'POST')));
The sandbox and live sites are separate, so register once in each and keep two ipn_id values.
Order once, settle from the status check
new ──submit──▶ pending ──status 1, same amount & currency──▶ paid
│ ├──status 1, other amount or currency──▶ review
│ ├──status 2──▶ failed
│ └──status 0 or no answer──▶ pending, expired after an hour
└──refused──▶ failed paid ──status 3──▶ reversed
// payments.js
import { randomUUID } from 'node:crypto';
import { getStatus, PesapalError, submitOrder } from './pesapal.js';
const pesapal = { submitOrder, getStatus };
const FINAL = ['failed', 'review', 'reversed', 'expired'];
const EXPIRE_AFTER_MS = 60 * 60_000;
/** "1500.50" -> 150050, so amounts compare exactly. */
export function toCents(amount) {
const m = /^(\d{1,12})(?:\.(\d{1,2}))?$/.exec(String(amount).trim());
if (!m) throw new Error('Enter an amount like 1500 or 1500.50');
return Number(m[1]) * 100 + Number((m[2] ?? '').padEnd(2, '0'));
}
/** Pesapal's amount is a number (e.g. 1500 or 1500.5): compare it in cents. */
const centsOf = (value) => Math.round(Number(value) * 100);
/**
* Create the payment record. Pesapal's id: letters, numbers, - _ . : only, at most 50 characters,
* unique for every order request.
*/
export function newPayment({ orderId, amount, currency, email, phone, description, now = Date.now() }) {
if (!/^[A-Z]{3}$/.test(String(currency))) throw new Error('Use an ISO currency code, e.g. KES, UGX or TZS');
if (!email && !phone) throw new Error('Pesapal needs an email address or a phone number');
const cents = toCents(amount);
if (cents <= 0) throw new Error('Amount must be more than zero');
return {
reference: `pay-${randomUUID()}`, // 40 characters
orderId: String(orderId), amount: cents, currency, email: email ?? '', phone: phone ?? '',
description: String(description).slice(0, 100), status: 'new', trackingId: null, note: null, createdAt: now,
};
}
/** Saves the payment, then creates the Pesapal order. Returns the page to send the customer to. */
export async function startPayment(payment, { find, save, api = pesapal }) {
const existing = await find(payment.reference);
if (payment.status !== 'new' || (existing && existing.status !== 'new')) throw new Error('This payment was already sent to Pesapal');
await save({ ...payment, status: 'pending' }); // saved BEFORE the network call
try {
const order = await api.submitOrder({
id: payment.reference,
currency: payment.currency,
amount: payment.amount / 100,
description: payment.description,
callback_url: `${process.env.PUBLIC_URL}/pesapal/callback`,
notification_id: process.env.PESAPAL_IPN_ID,
billing_address: { email_address: payment.email, phone_number: payment.phone },
});
await save({ ...payment, status: 'pending', trackingId: order.order_tracking_id });
return order.redirect_url;
} catch (err) {
if (err instanceof PesapalError && !err.ambiguous) {
await save({ ...payment, status: 'failed', note: err.error?.code ?? err.message });
}
// Ambiguous: the order may exist, but without its tracking id there is nothing to send the customer to.
// The payment stays pending; the IPN or callback (which carry your reference) can still settle it.
throw err;
}
}
/** Applies a GetTransactionStatus answer to its payment. */
export async function settle(payment, tx, { save }) {
if (!tx || tx.merchant_reference !== payment.reference) return payment; // not this payment
if (FINAL.includes(payment.status)) return payment; // final results never change
const code = Number(tx.status_code);
if (code === 3) return payment.status === 'paid' ? save({ ...payment, status: 'reversed', note: 'REVERSED' }) : payment;
if (payment.status === 'paid') return payment; // only a reversal changes a paid payment
if (code === 1) {
const same = centsOf(tx.amount) === payment.amount && String(tx.currency).toUpperCase() === payment.currency;
return save({ ...payment, status: same ? 'paid' : 'review', note: same ? tx.confirmation_code ?? null : `PAID ${tx.amount} ${tx.currency}` });
}
if (code === 2) return save({ ...payment, status: 'failed', note: tx.description || 'FAILED' });
return payment; // 0 (INVALID) or anything else: not paid yet. reconcile() expires it eventually.
}
/**
* Handles the callback redirect or an IPN. Neither carries the status, so the only thing taken from
* them is which order to look up, and the tracking id must be the one Pesapal gave us for it.
*/
export async function handleNotification({ OrderTrackingId, OrderMerchantReference }, { find, save, api = pesapal }) {
if (!OrderTrackingId || !OrderMerchantReference) return null;
const payment = await find(String(OrderMerchantReference));
if (!payment) return null; // not ours
if (payment.trackingId && payment.trackingId !== String(OrderTrackingId)) return null; // not this payment's order
const tx = await api.getStatus(String(OrderTrackingId)); // throws if Pesapal is unreachable: try again later
const settled = await settle(payment, tx, { save });
if (!settled.trackingId && tx?.merchant_reference === payment.reference) return save({ ...settled, trackingId: String(OrderTrackingId) });
return settled;
}
/** For payments still pending: asks Pesapal, and expires payments that never completed. */
export async function reconcile(payment, { save, api = pesapal, now = Date.now() }) {
if (payment.status !== 'pending') return payment;
if (payment.trackingId) {
try {
const settled = await settle(payment, await api.getStatus(payment.trackingId), { save });
if (settled.status !== 'pending') return settled;
} catch {
return payment; // Pesapal unreachable: try again on the next run
}
}
if (now - payment.createdAt > EXPIRE_AFTER_MS) return save({ ...payment, status: 'expired', note: 'NO_PAYMENT' });
return payment;
}
The rules:
- Save before you send. The payment is
pendingbeforeSubmitOrderRequest, with a reference that is unique for every order request, as Pesapal requires. - Never settle from the callback or IPN. Pesapal says they don’t carry the payment status. They only say which order to look up, and the tracking id must match the one Pesapal gave you for that reference.
- Paid means status 1 with the same amount and currency. Anything else from a “completed” order goes to
reviewfor a person. - Final means final.
failed,review,reversedandexpirednever change, and apaidpayment changes only toreversed. - Reconcile. Payments still pending are checked every few minutes, and expired after an hour if Pesapal never reports them completed.
Your save function should update the row by reference and refuse to change a payment that is already final, for example UPDATE payments SET ... WHERE reference = $1 AND status NOT IN ('failed', 'review', 'reversed', 'expired').
The server
// server.js (Express)
import express from 'express';
import { handleNotification, newPayment, startPayment } from './payments.js';
import { find, save } from './db.js'; // your database: find(reference) and save(payment)
for (const name of ['PESAPAL_CONSUMER_KEY', 'PESAPAL_CONSUMER_SECRET', 'PESAPAL_IPN_ID', 'PUBLIC_URL']) {
if (!process.env[name]) throw new Error(`Set ${name}`);
}
const app = express();
app.use(express.json());
// 1. Start a payment and send the customer to Pesapal's page.
app.post('/pay', async (req, res) => {
try {
const payment = newPayment({ orderId: req.body.orderId, amount: req.body.amount, currency: req.body.currency, email: req.body.email, description: `Order ${req.body.orderId}` });
res.json({ reference: payment.reference, redirectUrl: await startPayment(payment, { find, save }) });
} catch (err) {
res.status(502).json({ error: 'Could not start the payment. Please try again.' });
}
});
// 2. The customer comes back here. Check the status before showing anything.
app.get('/pesapal/callback', async (req, res) => {
try {
const payment = await handleNotification(req.query, { find, save });
res.send(payment?.status === 'paid' ? 'Payment received. Thank you!' : 'We are confirming your payment. This page will update shortly.');
} catch {
res.send('We are confirming your payment. This page will update shortly.');
}
});
// 3. The IPN: Pesapal calls it when a status changes (POST here, as registered).
app.post('/pesapal/ipn', async (req, res) => {
const { OrderTrackingId, OrderMerchantReference, OrderNotificationType } = req.body ?? {};
try {
await handleNotification({ OrderTrackingId, OrderMerchantReference }, { find, save });
} catch (err) {
console.error('Pesapal IPN not processed', OrderMerchantReference, err);
return res.status(500).json({ orderNotificationType: OrderNotificationType, orderTrackingId: OrderTrackingId, orderMerchantReference: OrderMerchantReference, status: 500 });
}
res.json({ orderNotificationType: OrderNotificationType, orderTrackingId: OrderTrackingId, orderMerchantReference: OrderMerchantReference, status: 200 });
});
app.listen(3000);
The IPN answer. Pesapal’s API 3.0 pages say what it sends to your IPN URL (OrderTrackingId, OrderMerchantReference and OrderNotificationType set to IPNCHANGE, as GET or POST, matching your registration) but not what your server must answer. This server answers HTTP 200 with the three values echoed back and status: 200 once the payment is saved, and 500 if it couldn’t ask Pesapal. Confirm in the sandbox that Pesapal accepts it, and keep the reconcile job running either way: it settles any payment whose IPN went wrong.
Run reconcile() every few minutes for payments that are pending and more than a minute old.
Test it without calling Pesapal
This test replaces the client with a fake Pesapal. It checks that a payment is saved before the order is sent, that the callback and IPN are never trusted, that another order’s tracking id can’t settle a payment, that a different amount goes to review, that a reversal only follows a payment, and that final results never change. A second part checks the token cache against a fake fetch.
// payments.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { forgetToken, getStatus, PesapalError, token } from './pesapal.js';
import { handleNotification, newPayment, reconcile, settle, startPayment, toCents } from './payments.js';
process.env.PUBLIC_URL = 'https://shop.example.com';
process.env.PESAPAL_IPN_ID = 'e7b1f5a6-1111-4d2c-9e3b-0d5c7e8a9f10';
function makeDb() {
const rows = new Map();
return {
rows,
find: async (ref) => rows.get(ref) ?? null,
save: async (p) => {
const old = rows.get(p.reference);
if (old && ['failed', 'review', 'reversed', 'expired'].includes(old.status)) return old; // final results never change
rows.set(p.reference, p);
return p;
},
};
}
function fakePesapal() {
const orders = new Map(); // tracking id -> transaction, like Pesapal's side
let next = 1;
return {
orders,
sent: [],
async submitOrder(order) {
this.sent.push(order);
const id = `7e6b62d9-883e-440f-a63e-${String(next++).padStart(12, '0')}`;
orders.set(id, { merchant_reference: order.id, amount: order.amount, currency: order.currency, status_code: 0, payment_status_description: 'INVALID' });
return { order_tracking_id: id, merchant_reference: order.id, redirect_url: `https://cybqa.pesapal.com/iframe?OrderTrackingId=${id}` };
},
async getStatus(id) {
const tx = orders.get(id);
if (!tx) throw new PesapalError('Pesapal error: unknown order', { status: 400, ambiguous: true });
return { ...tx };
},
pay(id, extra = {}) {
Object.assign(orders.get(id), { status_code: 1, payment_status_description: 'Completed', confirmation_code: 'RKL12AB3CD', ...extra });
},
};
}
const payment = (extra = {}) => newPayment({ orderId: 'A1001', amount: '1500', currency: 'KES', email: 'jane@example.com', description: 'Order A1001', ...extra });
async function started(db, api, p = payment()) {
await startPayment(p, { ...db, api });
return db.rows.get(p.reference);
}
test('a payment is saved before the order is sent, and paid only from the status check', async () => {
const db = makeDb();
const api = fakePesapal();
const real = api.submitOrder.bind(api);
api.submitOrder = async (order) => {
assert.equal(db.rows.get(order.id)?.status, 'pending'); // saved BEFORE the call
return real(order);
};
const p = payment();
const url = await startPayment(p, { ...db, api });
assert.match(url, /^https:\/\/cybqa\.pesapal\.com\//);
assert.deepEqual(api.sent[0], {
id: p.reference, currency: 'KES', amount: 1500, description: 'Order A1001',
callback_url: 'https://shop.example.com/pesapal/callback', notification_id: process.env.PESAPAL_IPN_ID,
billing_address: { email_address: 'jane@example.com', phone_number: '' },
});
await assert.rejects(startPayment(p, { ...db, api }), /already sent/); // never twice
const saved = db.rows.get(p.reference);
const ipn = { OrderTrackingId: saved.trackingId, OrderMerchantReference: p.reference, OrderNotificationType: 'IPNCHANGE' };
assert.equal((await handleNotification(ipn, { ...db, api })).status, 'pending'); // status 0: not paid
api.pay(saved.trackingId);
const paid = await handleNotification(ipn, { ...db, api });
assert.deepEqual([paid.status, paid.note], ['paid', 'RKL12AB3CD']);
});
test('the callback and IPN are never trusted', async () => {
const db = makeDb();
const api = fakePesapal();
const mine = await started(db, api);
const other = await started(db, api, payment({ amount: '10' }));
api.pay(other.trackingId, { amount: 10 });
// Our reference with another order's tracking id:
assert.equal(await handleNotification({ OrderTrackingId: other.trackingId, OrderMerchantReference: mine.reference }, { ...db, api }), null);
assert.equal(db.rows.get(mine.reference).status, 'pending');
assert.equal(await handleNotification({ OrderTrackingId: mine.trackingId, OrderMerchantReference: 'not-ours' }, { ...db, api }), null);
assert.equal(await handleNotification({ OrderMerchantReference: mine.reference }, { ...db, api }), null);
// A status answer for another reference changes nothing:
assert.equal((await settle(mine, { merchant_reference: other.reference, status_code: 1, amount: 1500, currency: 'KES' }, db)).status, 'pending');
});
test('a different amount or currency goes to review; failed and expired are final', async () => {
const db = makeDb();
const api = fakePesapal();
const p = await started(db, api);
api.pay(p.trackingId, { amount: 150 });
const ipn = { OrderTrackingId: p.trackingId, OrderMerchantReference: p.reference };
assert.equal((await handleNotification(ipn, { ...db, api })).status, 'review');
const q = await started(db, api);
api.pay(q.trackingId, { currency: 'UGX' });
assert.equal((await reconcile(q, { ...db, api })).status, 'review');
const f = await started(db, api);
Object.assign(api.orders.get(f.trackingId), { status_code: 2, description: 'Insufficient funds' });
const failed = await reconcile(f, { ...db, api });
assert.deepEqual([failed.status, failed.note], ['failed', 'Insufficient funds']);
api.pay(f.trackingId); // a later, different answer doesn't undo it
assert.equal((await settle(failed, api.orders.get(f.trackingId), db)).status, 'failed');
const old = await started(db, api, payment({ now: Date.now() - 2 * 60 * 60_000 }));
assert.equal((await reconcile(old, { ...db, api })).status, 'expired');
api.pay(old.trackingId);
await handleNotification({ OrderTrackingId: old.trackingId, OrderMerchantReference: old.reference }, { ...db, api });
assert.equal(db.rows.get(old.reference).status, 'expired');
});
test('settle itself never changes a final payment', async () => {
const saved = [];
const save = async (p) => (saved.push(p), p); // a save that guards nothing
for (const status of ['failed', 'review', 'reversed', 'expired']) {
const p = { ...payment(), status };
assert.equal((await settle(p, { merchant_reference: p.reference, status_code: 1, amount: 1500, currency: 'KES' }, { save })).status, status);
}
assert.equal(saved.length, 0);
});
test('only a reversal changes a paid payment, and a reversal needs a payment first', async () => {
const db = makeDb();
const api = fakePesapal();
const p = await started(db, api);
Object.assign(api.orders.get(p.trackingId), { status_code: 3 });
assert.equal((await reconcile(p, { ...db, api })).status, 'pending'); // nothing was paid to reverse
api.pay(p.trackingId);
const paid = await reconcile(db.rows.get(p.reference), { ...db, api });
assert.equal(paid.status, 'paid');
Object.assign(api.orders.get(p.trackingId), { status_code: 2 });
assert.equal((await settle(paid, api.orders.get(p.trackingId), db)).status, 'paid'); // a stale failure can't un-pay it
Object.assign(api.orders.get(p.trackingId), { status_code: 3 });
assert.equal((await settle(paid, api.orders.get(p.trackingId), db)).status, 'reversed');
});
test('a refused order fails; a lost answer stays pending; an unreachable Pesapal changes nothing', async () => {
const db = makeDb();
const api = fakePesapal();
const refused = payment();
const bad = { ...api, async submitOrder() { throw new PesapalError('Pesapal error: invalid_notification_id', { status: 400, error: { type: 'error', code: 'invalid_notification_id', message: 'x' } }); } };
await assert.rejects(startPayment(refused, { ...db, api: bad }));
assert.deepEqual([db.rows.get(refused.reference).status, db.rows.get(refused.reference).note], ['failed', 'invalid_notification_id']);
const lost = payment();
const timeout = { ...api, async submitOrder() { throw new PesapalError('Pesapal unreachable: timeout', { ambiguous: true }); } };
await assert.rejects(startPayment(lost, { ...db, api: timeout }));
assert.equal(db.rows.get(lost.reference).status, 'pending');
const p = await started(db, api);
const down = { ...api, async getStatus() { throw new PesapalError('Pesapal unreachable', { ambiguous: true }); } };
assert.equal((await reconcile(p, { ...db, api: down })).status, 'pending');
await assert.rejects(handleNotification({ OrderTrackingId: p.trackingId, OrderMerchantReference: p.reference }, { ...db, api: down }));
});
test('amounts, references and input checks', () => {
assert.deepEqual([toCents('1500'), toCents('1500.5'), toCents('0.29')], [150000, 150050, 29]);
assert.throws(() => toCents('1.234'));
const p = payment();
assert.match(p.reference, /^[A-Za-z0-9\-_.:]{1,50}$/);
assert.notEqual(p.reference, payment().reference);
assert.throws(() => payment({ currency: 'kes' }), /ISO/);
assert.throws(() => payment({ email: undefined }), /email address or a phone/);
assert.throws(() => payment({ amount: '0' }), /more than zero/);
});
test('the token is cached until shortly before its expiryDate', async () => {
const real = globalThis.fetch;
let calls = 0;
globalThis.fetch = async (url, init) => {
calls += 1;
assert.match(String(url), /\/api\/Auth\/RequestToken$/);
assert.deepEqual(JSON.parse(init.body), { consumer_key: 'key', consumer_secret: 'secret' });
return new Response(JSON.stringify({ token: `tok-${calls}`, expiryDate: '2026-11-05T10:05:00.000Z', error: null, status: '200', message: 'Request processed successfully' }), { status: 200 });
};
process.env.PESAPAL_CONSUMER_KEY = 'key';
process.env.PESAPAL_CONSUMER_SECRET = 'secret';
try {
forgetToken();
const t0 = Date.parse('2026-11-05T10:00:00Z');
assert.equal(await token(t0), 'tok-1');
assert.equal(await token(t0 + 4 * 60_000), 'tok-1'); // still valid
assert.equal(await token(t0 + 4 * 60_000 + 40_000), 'tok-2'); // within 30 s of expiry: renewed
assert.equal(calls, 2);
// GetTransactionStatus answers with an error object even when nothing went wrong.
globalThis.fetch = async (url) => new Response(JSON.stringify(String(url).includes('RequestToken')
? { token: 'tok-3', expiryDate: new Date(Date.now() + 5 * 60_000).toISOString() }
: String(url).includes('orderTrackingId=ok')
? { merchant_reference: 'pay-1', status_code: 1, amount: 1500, currency: 'KES', error: { error_type: null, code: null, message: null }, status: '200' }
: { error: { error_type: 'api_error', code: 'order_not_found', message: 'Order not found' }, status: '500' }), { status: 200 });
assert.equal((await getStatus('ok')).status_code, 1);
await assert.rejects(getStatus('missing'), (err) => err instanceof PesapalError && err.error.code === 'order_not_found');
} finally {
globalThis.fetch = real;
forgetToken();
}
});
Run it with node --test:
node --test payments.test.js
Test in the sandbox
Use the demo keys, set PESAPAL_BASE_URL to the sandbox URL, run register-ipn.js against a public tunnel URL, then start a payment and open the redirect_url. After paying on the sandbox page, check that your server logs the IPN, that GetTransactionStatus returns status_code 1, and that the payment becomes paid.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Every call fails after a few minutes | The token expired (it lasts at most 5 minutes) | Use the cached token(), which renews 30 seconds before expiryDate |
| SubmitOrderRequest refused | notification_id missing, or registered in the other environment |
Register the IPN in the same environment (sandbox or live) and use that ipn_id |
| SubmitOrderRequest refused | id has spaces or symbols, or is over 50 characters, or was used before |
Letters, numbers and - _ . : only; a new id for every order request |
| Order created but no IPN arrives | The IPN URL isn’t public, or it was registered for GET and your route is POST | Check the URL from outside; match the route to ipn_notification_type |
Status stays INVALID (0) |
The customer hasn’t paid, or left the page | Leave it pending; reconcile() expires it after an hour |
Payment in review |
Completed with a different amount or currency | Look at it in the Pesapal dashboard before giving value |
Pesapal also has a RefundRequest (only for COMPLETED payments, once each, mobile money only in full, approved by the merchant) and a CancelOrder endpoint (only for failed or pending payments, once). Both take the token like every other call.
Going to production
- Switch
PESAPAL_BASE_URLtohttps://pay.pesapal.com/v3and use your live consumer key and secret. - Register the IPN on the live site and put the new
ipn_idin your production environment. - Run
reconcile()every few minutes, and alert yourself about payments inreview. - Send one small real payment end to end before launch.
Using Laravel or Django? See Pesapal in Laravel and Pesapal in Django.
Want this done for you, in Next.js, Laravel or Django? The starter kits include Pesapal.
Taking mobile money directly instead? See MTN MoMo collections in Node.js, M-Pesa STK push in Node.js and the payment API comparison.
Using the Next.js App Router? See Pesapal API 3.0 in Next.js: the same design in route handlers.
Frequently asked questions
How long does a Pesapal API 3.0 token last?
Pesapal says the token from /api/Auth/RequestToken is valid for a maximum of 5 minutes, and the response has an expiryDate in UTC. Cache it and request a new one shortly before it expires, not once per request.
Where do I get the notification_id for SubmitOrderRequest?
From RegisterIPN. Register your IPN URL once per environment with POST /api/URLSetup/RegisterIPN; the response has an ipn_id (a GUID). Send that as notification_id with every order.
Does the Pesapal IPN tell me whether the customer paid?
No. Pesapal says the callback URL and the IPN calls do not carry the payment status, for security reasons. Both only give you the OrderTrackingId and your OrderMerchantReference. Call GetTransactionStatus with the OrderTrackingId and settle from that.
What do Pesapal status_code values mean?
In GetTransactionStatus, status_code 0 is INVALID, 1 is COMPLETED, 2 is FAILED and 3 is REVERSED. Only 1, with the amount and currency you expected, means paid.
Can I refund a Pesapal mobile money payment partly?
No. Pesapal says mobile money payments can only be refunded in full, card payments in full or in part, only COMPLETED payments can be refunded, once each, and the merchant must approve the refund.