This guide adds M-Pesa payments to a Node.js app using Safaricom’s Daraja API and STK push (Lipa na M-Pesa Online), where the customer gets a PIN prompt on their phone. By the end you’ll have:
- a small
mpesa.jsclient with token caching and the Password and Timestamp done right, - an Express server that starts payments and receives callbacks safely,
- automatic status queries for callbacks that are late, missing or ambiguous (
1037), - a test that runs the whole flow without calling Safaricom.
It uses Node’s built-in fetch (Node 18+) and Express. Every code block here was run in a fresh project, and the test at the end passes.
Before you start
- Create an app on the Daraja portal (developer.safaricom.co.ke) and copy its Consumer Key and Consumer Secret.
- On the sandbox test credentials page, note the test shortcode (normally
174379) and its passkey. - Your callback URL must be public HTTPS. For local development, run a tunnel such as
cloudflared tunnel --url http://localhost:3000and use the address it prints. - Install Express:
npm init -y && npm pkg set type=module
npm install express
Create a .env file (load it with node --env-file=.env, built into Node 20+) and never commit it:
MPESA_ENV=sandbox
MPESA_CONSUMER_KEY=your-consumer-key
MPESA_CONSUMER_SECRET=your-consumer-secret
MPESA_SHORTCODE=174379
MPESA_PASSKEY=your-passkey
MPESA_CALLBACK_URL=https://your-tunnel.example.com/webhooks/payments
Keep “mpesa” and “safaricom” out of the callback URL. Developers report that Safaricom silently drops callbacks to URLs containing them, which is why this guide uses /webhooks/payments.
The Daraja client
All the Daraja-specific details live in one file. The two that trip people up most:
- Password is
base64(BusinessShortCode + Passkey + Timestamp), and the request must send that same Timestamp. - Timestamp is Kenyan time (
Africa/Nairobi) asYYYYMMDDHHmmss, whatever timezone your server runs in.
// mpesa.js
const env = process.env;
const baseUrl = () => (env.MPESA_ENV === 'production' ? 'https://api.safaricom.co.ke' : 'https://sandbox.safaricom.co.ke');
let cachedToken = null;
let tokenExpiresAt = 0;
export async function getToken() {
if (cachedToken && Date.now() < tokenExpiresAt - 60_000) return cachedToken;
const auth = Buffer.from(`${env.MPESA_CONSUMER_KEY}:${env.MPESA_CONSUMER_SECRET}`).toString('base64');
const res = await fetch(`${baseUrl()}/oauth/v1/generate?grant_type=client_credentials`, {
headers: { Authorization: `Basic ${auth}` },
});
if (!res.ok) throw new Error(`Daraja token failed: ${res.status} ${await res.text()}`);
const data = await res.json();
cachedToken = data.access_token;
tokenExpiresAt = Date.now() + Number(data.expires_in ?? 3599) * 1000;
return cachedToken;
}
/** Kenyan time as YYYYMMDDHHmmss, regardless of the server's timezone. */
export function timestamp(date = new Date()) {
return new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Africa/Nairobi', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
}).format(date).replace(/\D/g, '');
}
export function password(ts) {
return Buffer.from(`${env.MPESA_SHORTCODE}${env.MPESA_PASSKEY}${ts}`).toString('base64');
}
/** "0712 345 678", "+254712345678", "712345678" -> "254712345678" */
export function normaliseKenyanPhone(raw) {
let digits = String(raw).replace(/\D/g, '');
if (digits.startsWith('00')) digits = digits.slice(2);
if (digits.startsWith('2540')) digits = '254' + digits.slice(4);
if (digits.startsWith('0')) digits = '254' + digits.slice(1);
else if (/^[17]\d{8}$/.test(digits)) digits = '254' + digits;
if (!/^254[17]\d{8}$/.test(digits)) throw new Error(`Not a Kenyan mobile number: ${raw}`);
return digits;
}
async function darajaPost(path, body) {
const res = await fetch(`${baseUrl()}${path}`, {
method: 'POST',
headers: { Authorization: `Bearer ${await getToken()}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(`Daraja ${path} failed: ${data.errorCode ?? res.status} ${data.errorMessage ?? ''}`.trim());
err.code = data.errorCode;
throw err;
}
return data;
}
export async function stkPush({ amount, phone, reference, description = 'Payment' }) {
const ts = timestamp();
const msisdn = normaliseKenyanPhone(phone);
const data = await darajaPost('/mpesa/stkpush/v1/processrequest', {
BusinessShortCode: env.MPESA_SHORTCODE,
Password: password(ts),
Timestamp: ts, // the same value used in the Password
TransactionType: env.MPESA_TRANSACTION_TYPE ?? 'CustomerPayBillOnline',
Amount: Math.round(Number(amount)), // whole shillings only
PartyA: msisdn,
PartyB: env.MPESA_PARTY_B ?? env.MPESA_SHORTCODE, // till number for Buy Goods
PhoneNumber: msisdn,
CallBackURL: env.MPESA_CALLBACK_URL,
AccountReference: String(reference).slice(0, 12),
TransactionDesc: String(description).slice(0, 13),
});
if (String(data.ResponseCode) !== '0') throw new Error(`STK push rejected: ${data.ResponseDescription}`);
return { checkoutRequestId: data.CheckoutRequestID, merchantRequestId: data.MerchantRequestID };
}
export async function stkQuery(checkoutRequestId) {
const ts = timestamp();
return darajaPost('/mpesa/stkpushquery/v1/query', {
BusinessShortCode: env.MPESA_SHORTCODE,
Password: password(ts),
Timestamp: ts,
CheckoutRequestID: checkoutRequestId,
});
}
/** Read a callback safely. Failed payments have no CallbackMetadata. */
export function parseCallback(body) {
const cb = body?.Body?.stkCallback;
if (!cb) return null;
const meta = Object.fromEntries((cb.CallbackMetadata?.Item ?? []).map((item) => [item.Name, item.Value]));
return {
checkoutRequestId: cb.CheckoutRequestID,
resultCode: String(cb.ResultCode),
resultDesc: cb.ResultDesc,
amount: meta.Amount,
receipt: meta.MpesaReceiptNumber,
};
}
/** paid | failed | unknown. "unknown" means: query before deciding. */
export function outcome(resultCode) {
const code = String(resultCode);
if (code === '0') return 'paid';
if (['1', '1032', '2001', '1019'].includes(code)) return 'failed';
return 'unknown'; // 1037, 1001, 1025 and anything new
}
For a Buy Goods till, set MPESA_TRANSACTION_TYPE=CustomerBuyGoodsOnline, MPESA_SHORTCODE to your store number and MPESA_PARTY_B to the till number.
The outcome function is deliberately cautious. Anything that isn’t clearly paid or clearly failed becomes unknown, and the server checks with Safaricom instead of guessing. Every ResultCode is explained in the M-Pesa error code reference. For why callbacks go missing and what 1037 really means, see M-Pesa callbacks and ResultCode 1037.
The server
The server has three routes:
POST /paystarts a payment.GET /pay/:idlets your frontend poll for the status.POST /webhooks/paymentsreceives Safaricom’s callback.
Payments live in a Map here to keep the example short. Use your database in a real app.
// server.js
import express from 'express';
import { fileURLToPath } from 'node:url';
import { stkPush, stkQuery, parseCallback, outcome } from './mpesa.js';
export const app = express();
app.use(express.json());
// Demo store: checkoutRequestId -> payment. Use your database in a real app.
const payments = new Map();
app.post('/pay', async (req, res) => {
const { orderId, amount, phone } = req.body ?? {};
try {
const { checkoutRequestId } = await stkPush({ amount, phone, reference: orderId });
payments.set(checkoutRequestId, { orderId, amount: Math.round(Number(amount)), status: 'pending' });
setTimeout(() => resolvePending(checkoutRequestId), 90_000).unref(); // no callback yet? ask Safaricom
res.status(202).json({ checkoutRequestId, message: 'Check your phone and enter your M-Pesa PIN.' });
} catch (err) {
res.status(502).json({ error: err.message });
}
});
app.get('/pay/:id', (req, res) => {
const payment = payments.get(req.params.id);
if (!payment) return res.sendStatus(404);
res.json({ status: payment.status, receipt: payment.receipt ?? null });
});
app.post('/webhooks/payments', (req, res) => {
res.json({ ResultCode: 0, ResultDesc: 'Accepted' }); // acknowledge first, then work
const cb = parseCallback(req.body);
if (!cb) return;
const payment = payments.get(cb.checkoutRequestId);
if (!payment || payment.status !== 'pending') return; // unknown, or a duplicate callback
const result = outcome(cb.resultCode);
if (result === 'paid' && Number(cb.amount) === payment.amount) {
payment.status = 'paid';
payment.receipt = cb.receipt;
} else if (result === 'failed') {
payment.status = 'failed';
payment.reason = cb.resultCode;
} else {
resolvePending(cb.checkoutRequestId); // e.g. 1037: the money may still arrive
}
});
async function resolvePending(id, attempt = 1) {
const payment = payments.get(id);
if (!payment || payment.status !== 'pending') return;
const retry = () => attempt < 5 && setTimeout(() => resolvePending(id, attempt + 1), 30_000 * attempt).unref();
try {
const result = await stkQuery(id);
const state = outcome(result.ResultCode);
if (state === 'paid') payment.status = 'paid';
else if (state === 'failed') Object.assign(payment, { status: 'failed', reason: String(result.ResultCode) });
else retry();
} catch {
retry(); // on STK query, 500.001.1001 usually means "still processing"
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
app.listen(process.env.PORT ?? 3000, () => console.log('Listening'));
}
Run it with node --env-file=.env server.js, then start a payment:
curl -X POST http://localhost:3000/pay -H "Content-Type: application/json" \
-d '{"orderId": "Order1001", "amount": 1, "phone": "0712345678"}'
Three details in this server prevent real production bugs:
- Duplicate callbacks are ignored. Once a payment leaves
pending, later callbacks change nothing. - The amount is checked against what you charged before marking the order paid.
1037and other ambiguous codes trigger a query instead of a failure, so a customer whose payment was only slow isn’t told to pay again.
Test it without calling Safaricom
This test swaps fetch for a fake Daraja when the URL points at Safaricom, and passes local requests through to the real server. It checks that the Password is built from the Timestamp actually sent, that a successful callback marks the payment paid, and that a cancelled callback without CallbackMetadata is handled.
// mpesa.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
Object.assign(process.env, {
MPESA_ENV: 'sandbox', MPESA_CONSUMER_KEY: 'key', MPESA_CONSUMER_SECRET: 'secret',
MPESA_SHORTCODE: '174379', MPESA_PASSKEY: 'test-passkey', MPESA_CALLBACK_URL: 'https://shop.example.com/webhooks/payments',
});
const realFetch = globalThis.fetch;
const sent = [];
let nextId = 1;
globalThis.fetch = async (url, options = {}) => {
if (!String(url).startsWith('https://sandbox.safaricom.co.ke')) return realFetch(url, options);
const reply = (data) => new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
if (String(url).includes('/oauth/')) return reply({ access_token: 'test-token', expires_in: '3599' });
sent.push(JSON.parse(options.body));
return reply({ ResponseCode: '0', CheckoutRequestID: `ws_CO_${nextId++}`, MerchantRequestID: 'm-1' });
};
const { app } = await import('./server.js');
const server = app.listen(0);
const local = `http://localhost:${server.address().port}`;
const post = (path, body) => realFetch(local + path, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
const callback = (id, resultCode, items) => ({
Body: { stkCallback: { CheckoutRequestID: id, ResultCode: resultCode, ResultDesc: 'test', ...(items && { CallbackMetadata: { Item: items } }) } },
});
test('successful payment', async () => {
const res = await post('/pay', { orderId: 'Order1001', amount: 10, phone: '0712 345 678' });
assert.equal(res.status, 202);
const { checkoutRequestId } = await res.json();
const body = sent.at(-1);
assert.equal(body.PartyA, '254712345678');
assert.equal(Buffer.from(body.Password, 'base64').toString(), `174379test-passkey${body.Timestamp}`);
await post('/webhooks/payments', callback(checkoutRequestId, 0, [
{ Name: 'Amount', Value: 10 }, { Name: 'MpesaReceiptNumber', Value: 'TJR1234567' },
]));
const status = await (await realFetch(`${local}/pay/${checkoutRequestId}`)).json();
assert.deepEqual(status, { status: 'paid', receipt: 'TJR1234567' });
});
test('cancelled payment has no CallbackMetadata and is marked failed', async () => {
const { checkoutRequestId } = await (await post('/pay', { orderId: 'Order1002', amount: 10, phone: '0712345678' })).json();
const ack = await post('/webhooks/payments', callback(checkoutRequestId, 1032));
assert.deepEqual(await ack.json(), { ResultCode: 0, ResultDesc: 'Accepted' });
const status = await (await realFetch(`${local}/pay/${checkoutRequestId}`)).json();
assert.equal(status.status, 'failed');
});
test.after(() => server.close());
Run it with node --test.
Test against the sandbox
With the tests passing, run the server against the real sandbox. Start your tunnel, put its address in MPESA_CALLBACK_URL, and send a payment to your own Safaricom number. Sandbox payments don’t move real money. You’ll see the PIN prompt and then a callback in your server.
If the push is rejected with 500.001.1001 or 400.002.02, decode your Password with the Password decoder. It shows whether the shortcode, passkey or timestamp is the problem.
Going to production
When your shortcode is approved for Daraja go-live:
- Set
MPESA_ENV=productionand use the production Consumer Key, Secret and the passkey Safaricom sends you. The sandbox passkey won’t work. - Set
MPESA_CALLBACK_URLto your public HTTPS URL on port 443. Tunnels aren’t allowed for production shortcodes. - Store the OAuth token in a shared cache (Redis, for example) if you run several processes, so they don’t each fetch their own. Hammering the token endpoint causes
404.001.03errors. - Replace the in-memory
Mapwith your database. Save theCheckoutRequestIDwith the order in the same step that starts the payment.
Frequently asked questions
Why does my M-Pesa callback handler crash on failed payments?
Failed and cancelled STK pushes have no CallbackMetadata. Code that reads CallbackMetadata.Item without checking crashes and returns a 500 for every failed payment. The parseCallback function in this guide handles both cases.
What should I do with ResultCode 1037?
1037 means Safaricom could not reach the phone in time, but the payment can still complete afterwards. Keep the payment pending, query its status with STK Push Query, and don't let the customer start a second push until it is resolved.
Can I test the M-Pesa callback on localhost?
Safaricom cannot reach localhost. Use a tunnel such as cloudflared or ngrok for sandbox testing, and set MPESA_CALLBACK_URL to the tunnel's HTTPS address. Production shortcodes need a real public HTTPS URL.
Why do I get 500.001.1001 when I call STK push?
Usually the Password is wrong: the Timestamp inside it doesn't match the Timestamp you sent, the passkey has a stray space, or it belongs to a different shortcode. Paste your Password into the decoder on the M-Pesa STK Push Builder page to find which part is off.