Developers

Accept M-Pesa Payments with the Daraja STK Push API in Node.js

The STK Push sends a payment prompt straight to the customer's phone: they enter their M-Pesa PIN and you get a callback. Here is a complete Node.js integration, from sandbox to going live.

Developer building an M-Pesa STK Push integration in Node.js
Photo: @disruptxn on Unsplash

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

  1. Your server gets an access token from Daraja with your consumer key and secret.
  2. Your server sends an STK Push request with the amount, phone number and a callback URL.
  3. Safaricom shows the customer a PIN prompt on their phone.
  4. When the customer pays, cancels or the prompt times out, Safaricom sends the result to your callback URL.
  5. Your server updates the order. Your app shows “paid” by checking your own database.

1. Get sandbox credentials

  1. Sign up on the Safaricom Daraja developer portal and create an app with the M-Pesa Express (Lipa na M-Pesa Online) product.
  2. Copy the app’s Consumer Key and Consumer Secret.
  3. 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_ENV to production and use the production key, secret, shortcode and passkey.
  • For a till number (Buy Goods), use TransactionType: 'CustomerBuyGoodsOnline', set BusinessShortCode to the store number and PartyB to 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.

Keep reading

More guides like this.

All guides

Leave a Reply

Your email address will not be published. Required fields are marked *