If you sell anything in Kenya, your customers want to pay with M-Pesa. Asking them to type a paybill number, account number and amount by hand loses sales and causes mistakes. With Safaricom’s Daraja API you can send the payment request to their phone instead: they see your business name and amount, enter their PIN, and you get notified automatically.
This tutorial builds an M-Pesa STK Push in Node.js 18 or newer, with no SDK, so you can see every request and fix it when something breaks. If you’re in Somalia, read EVC Plus vs M-Pesa first: the APIs work differently.
How STK Push works
- Your server gets an access token from Daraja with your consumer key and secret.
- Your server sends an STK Push request with the amount, phone number and a callback URL.
- Safaricom shows the customer a PIN prompt on their phone.
- When the customer pays, cancels or the prompt times out, Safaricom sends the result to your callback URL.
- Your server updates the order. Your app shows “paid” by checking your own database.
1. Get sandbox credentials
- Sign up on the Safaricom Daraja developer portal and create an app with the M-Pesa Express (Lipa na M-Pesa Online) product.
- Copy the app’s Consumer Key and Consumer Secret.
- From the portal’s M-Pesa Express test page, copy the sandbox passkey. The sandbox shortcode is
174379.
Put everything in environment variables:
MPESA_ENV=sandbox
MPESA_KEY=your_consumer_key
MPESA_SECRET=your_consumer_secret
MPESA_SHORTCODE=174379
MPESA_PASSKEY=your_sandbox_passkey
MPESA_CALLBACK_URL=https://api.yourdomain.com/mpesa/callback/long-random-secret
CALLBACK_SECRET=long-random-secret
Your callback URL must be public HTTPS. During development, a tunnel such as ngrok works. Adding a long random secret to the path is a simple way to stop strangers from posting fake callbacks.
2. Token, timestamp and phone helpers
// mpesa.js (Node 18+ has fetch built in)
const BASE = process.env.MPESA_ENV === 'production'
? 'https://api.safaricom.co.ke'
: 'https://sandbox.safaricom.co.ke';
async function getToken() {
const auth = Buffer.from(process.env.MPESA_KEY + ':' + process.env.MPESA_SECRET).toString('base64');
const res = await fetch(BASE + '/oauth/v1/generate?grant_type=client_credentials', {
headers: { Authorization: 'Basic ' + auth },
});
if (!res.ok) throw new Error('M-Pesa OAuth failed: ' + res.status);
return (await res.json()).access_token; // valid for about an hour: cache it
}
// YYYYMMDDHHmmss in East Africa Time (UTC+3)
function timestamp() {
const eat = new Date(Date.now() + 3 * 60 * 60 * 1000);
return eat.toISOString().replace(/[-:TZ.]/g, '').slice(0, 14);
}
// 0712345678, +254712345678 or 254712345678 -> 254712345678
function normalisePhone(input) {
const d = String(input).replace(/\D/g, '');
if (/^0[17]\d{8}$/.test(d)) return '254' + d.slice(1);
if (/^254[17]\d{8}$/.test(d)) return d;
throw new Error('Not a valid Kenyan mobile number');
}
module.exports = { BASE, getToken, timestamp, normalisePhone };
Two details trip people up. The timestamp must be YYYYMMDDHHmmss, and the same value must be used in the password. The password is Base64 of shortcode + passkey + timestamp. Phone numbers must be in the 2547XXXXXXXX or 2541XXXXXXXX format.
3. Send the STK Push
const { BASE, getToken, timestamp, normalisePhone } = require('./mpesa');
async function stkPush({ phone, amount, reference }) {
const ts = timestamp();
const shortcode = process.env.MPESA_SHORTCODE;
const password = Buffer.from(shortcode + process.env.MPESA_PASSKEY + ts).toString('base64');
const msisdn = normalisePhone(phone);
const res = await fetch(BASE + '/mpesa/stkpush/v1/processrequest', {
method: 'POST',
headers: { Authorization: 'Bearer ' + (await getToken()), 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessShortCode: shortcode,
Password: password,
Timestamp: ts,
TransactionType: 'CustomerPayBillOnline',
Amount: Math.round(amount),
PartyA: msisdn,
PartyB: shortcode,
PhoneNumber: msisdn,
CallBackURL: process.env.MPESA_CALLBACK_URL,
AccountReference: String(reference).slice(0, 12),
TransactionDesc: 'Order payment',
}),
});
const data = await res.json();
// Accepted: { ResponseCode: '0', CheckoutRequestID: 'ws_CO_...', CustomerMessage: '...' }
if (data.ResponseCode !== '0') throw new Error(data.errorMessage || data.ResponseDescription || 'STK push failed');
return data.CheckoutRequestID;
}
AccountReference is what the customer sees as the account, such as your order code; keep it short. Save the returned CheckoutRequestID against the order: it is how you match the callback later.
4. Start a payment from your app
Your front-end sends only the order ID and phone number. The amount always comes from your database, never from the browser.
app.post('/api/pay', express.json(), async (req, res) => {
const order = await db.orders.find(req.body.orderId); // amount comes from YOUR database
if (!order || order.status !== 'unpaid') return res.status(400).json({ error: 'Invalid order' });
const checkoutId = await stkPush({ phone: req.body.phone, amount: order.total, reference: order.code });
await db.orders.update(order.id, { checkoutId, status: 'pending' });
res.json({ message: 'Check your phone and enter your M-Pesa PIN' });
});
5. Handle the callback
Safaricom posts JSON to your callback URL. On success, ResultCode is 0 and CallbackMetadata.Item holds the amount, the M-Pesa receipt number and the phone number. Any other code means the payment did not happen, for example the customer cancelled or entered the wrong PIN.
app.post('/mpesa/callback/:secret', express.json(), async (req, res) => {
if (req.params.secret !== process.env.CALLBACK_SECRET) return res.sendStatus(404);
// Reply fast; Safaricom does not wait long
res.json({ ResultCode: 0, ResultDesc: 'Accepted' });
const cb = req.body && req.body.Body && req.body.Body.stkCallback;
if (!cb) return;
const order = await db.orders.findByCheckoutId(cb.CheckoutRequestID);
if (!order || order.status === 'paid') return; // unknown or already handled
if (cb.ResultCode !== 0) { // cancelled, wrong PIN, timeout...
return db.orders.update(order.id, { status: 'failed', note: cb.ResultDesc });
}
const meta = Object.fromEntries(cb.CallbackMetadata.Item.map((i) => [i.Name, i.Value]));
if (Number(meta.Amount) !== Number(order.total)) {
return db.orders.update(order.id, { status: 'review', note: 'Amount mismatch' });
}
await db.orders.update(order.id, { status: 'paid', receipt: meta.MpesaReceiptNumber });
});
Notice the safety checks: unknown or already-paid orders are ignored, and the paid amount must match the order. Callbacks can arrive twice, so your handler must be safe to run more than once.
6. When the callback never arrives
Networks fail. If an order is still pending after about a minute, ask Daraja directly with the STK Push Query endpoint:
// Ask Safaricom for the result if no callback arrived after ~60 seconds
async function stkQuery(checkoutId) {
const ts = timestamp();
const shortcode = process.env.MPESA_SHORTCODE;
const res = await fetch(BASE + '/mpesa/stkpushquery/v1/query', {
method: 'POST',
headers: { Authorization: 'Bearer ' + (await getToken()), 'Content-Type': 'application/json' },
body: JSON.stringify({
BusinessShortCode: shortcode,
Password: Buffer.from(shortcode + process.env.MPESA_PASSKEY + ts).toString('base64'),
Timestamp: ts,
CheckoutRequestID: checkoutId,
}),
});
return res.json(); // ResultCode '0' means paid
}
Run this from a scheduled job for orders stuck in “pending”, and from a “Check payment” button in your app.
7. Going live
- You need a real paybill or till number registered for M-Pesa Express. Use the Daraja portal’s Go Live process; Safaricom sends the production passkey to the business contacts.
- Switch
MPESA_ENVtoproductionand use the production key, secret, shortcode and passkey. - For a till number (Buy Goods), use
TransactionType: 'CustomerBuyGoodsOnline', setBusinessShortCodeto the store number andPartyBto the till number. - Do a real KES 1 payment end to end before you announce anything.
Security checklist
- Keep keys and the passkey in environment variables, never in front-end code or Git.
- Take the amount from your database, not the request.
- Use a secret callback path, HTTPS, and idempotent updates.
- Log every callback in full; you will need them for reconciliation and disputes.
- Rate-limit the “pay” endpoint so nobody can spam customers with PIN prompts.
Before you launch, run through our Express API security checklist. If your customers use basic phones, you can pair this with a USSD menu so they can start a payment without a smartphone. Safaricom’s own reference is on the Daraja developer portal.




