Skip to content

M-Pesa · Node.js

M-Pesa STK Push in Node.js: Daraja Integration with Callbacks

Accept M-Pesa payments in Node.js with the Daraja API: token caching, Password and Timestamp, STK push, safe callbacks, 1037 handling and tests.

Published

Skip the boilerplate → Generate STK push requests and decode your Password with the M-Pesa STK Push Builder.

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

  1. Create an app on the Daraja portal (developer.safaricom.co.ke) and copy its Consumer Key and Consumer Secret.
  2. On the sandbox test credentials page, note the test shortcode (normally 174379) and its passkey.
  3. Your callback URL must be public HTTPS. For local development, run a tunnel such as cloudflared tunnel --url http://localhost:3000 and use the address it prints.
  4. 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) as YYYYMMDDHHmmss, 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 /pay starts a payment.
  • GET /pay/:id lets your frontend poll for the status.
  • POST /webhooks/payments receives 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.
  • 1037 and 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=production and use the production Consumer Key, Secret and the passkey Safaricom sends you. The sandbox passkey won’t work.
  • Set MPESA_CALLBACK_URL to 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.03 errors.
  • Replace the in-memory Map with your database. Save the CheckoutRequestID with 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.

Need this built for you?

I integrate MTN MoMo, M-Pesa and Airtel Money into Laravel / PHP, Python / Django, Node.js / Next.js apps: payments, callbacks, payouts and go-live, with tests.