Dev Dave

Integrating M-Pesa into a Node.js Backend

Integrating M-Pesa into a Node.js Backend

By David TopoikaFlutter & backend developerPublished April 5, 2026
Node.jsM-PesaPaymentsAPI Integration

Most M-Pesa integrations break in the same place: the developer treats the STK push response as proof of payment. It isn't. Getting M-Pesa right in a Node.js backend is less about the happy path and more about the callback — the part that actually tells you money moved.

How do you integrate M-Pesa into a Node.js backend?

You integrate M-Pesa in Node.js through Safaricom's Daraja API in three steps: request an OAuth access token with your consumer key and secret, send an STK Push request to prompt the customer for their PIN, then confirm the payment from the callback Safaricom sends to your server — never from the initial response.

The initial STK Push call only tells you the prompt was sent. Whether the customer entered their PIN, had funds, or cancelled is answered later, when Safaricom POSTs a result to your callback URL. Build around that and everything else falls into place.

Step 1: Get an access token

Every Daraja request needs a bearer token. You get one by sending your consumer key and secret (from the Daraja developer portal) as Basic auth:

import axios from "axios";
 
const BASE = "https://sandbox.safaricom.co.ke"; // api.safaricom.co.ke in production
 
async function getAccessToken() {
  const auth = Buffer.from(
    `${process.env.MPESA_CONSUMER_KEY}:${process.env.MPESA_CONSUMER_SECRET}`,
  ).toString("base64");
 
  const { data } = await axios.get(
    `${BASE}/oauth/v1/generate?grant_type=client_credentials`,
    { headers: { Authorization: `Basic ${auth}` } },
  );
 
  return data.access_token; // valid ~3600s
}

The token is valid for about an hour, so cache it — don't request a fresh one on every transaction. A simple in-memory cache with an expiry timestamp (or Redis if you run multiple instances) saves you a needless round-trip on every payment.

Step 2: Trigger the STK Push

This is the call that makes the customer's phone buzz with a PIN prompt. Two fields trip people up: the Timestamp and the Password, which is a base64 hash of your shortcode, passkey, and that exact same timestamp.

function timestamp() {
  const d = new Date();
  const p = (n) => String(n).padStart(2, "0");
  return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(
    d.getHours(),
  )}${p(d.getMinutes())}${p(d.getSeconds())}`;
}
 
async function initiateStkPush({ phone, amount, accountRef }) {
  const token = await getAccessToken();
  const ts = timestamp();
  const shortcode = process.env.MPESA_SHORTCODE;
  const password = Buffer.from(
    `${shortcode}${process.env.MPESA_PASSKEY}${ts}`,
  ).toString("base64");
 
  const { data } = await axios.post(
    `${BASE}/mpesa/stkpush/v1/processrequest`,
    {
      BusinessShortCode: shortcode,
      Password: password,
      Timestamp: ts,
      TransactionType: "CustomerPayBillOnline",
      Amount: amount, // integer KES — no decimals
      PartyA: phone, // 2547XXXXXXXX
      PartyB: shortcode,
      PhoneNumber: phone,
      CallBackURL: `${process.env.BASE_URL}/api/mpesa/callback`,
      AccountReference: accountRef,
      TransactionDesc: "Payment",
    },
    { headers: { Authorization: `Bearer ${token}` } },
  );
 
  return data; // includes CheckoutRequestID — store it, but it is NOT payment confirmation
}

Note the phone format: 2547XXXXXXXX, no + and no leading 0. Normalize whatever the user types before it reaches this function. Store the returned CheckoutRequestID against your order — you'll match the callback to it.

Step 3: Handle the callback (where it actually counts)

Safaricom POSTs the result to your CallBackURL. Acknowledge it immediately with a 200 so Safaricom doesn't retry, then process:

app.post("/api/mpesa/callback", (req, res) => {
  // Ack fast, before any DB work, so Safaricom doesn't resend
  res.status(200).json({ ResultCode: 0, ResultDesc: "Received" });
 
  const cb = req.body?.Body?.stkCallback;
  if (!cb) return;
 
  if (cb.ResultCode === 0) {
    const meta = Object.fromEntries(
      cb.CallbackMetadata.Item.map((i) => [i.Name, i.Value]),
    );
    // meta.MpesaReceiptNumber, meta.Amount, meta.PhoneNumber, meta.TransactionDate
    // Mark the order paid — idempotently, keyed on cb.CheckoutRequestID
  } else {
    // Failed or cancelled — cb.ResultDesc explains why
  }
});

Two things make this production-safe. First, idempotency: key your "mark as paid" logic on CheckoutRequestID so a duplicate callback can't double-credit an order. Second, your callback URL must be publicly reachable over HTTPSlocalhost won't work. In development, tunnel it with ngrok; in production, it's a real HTTPS endpoint.

Gotchas I've learned the hard way

  • The STK response is not confirmation. Only ResultCode: 0 in the callback (or a Transaction Status query) means money actually moved. Never fulfil an order on the initial response.
  • Cache the access token. It lasts ~an hour; regenerating per request is wasted latency.
  • Timestamp and password must agree. The password hashes the same timestamp you send — generate it once, use it in both.
  • Timeouts happen. Customers ignore the prompt. Handle the "no response" case; don't leave orders stuck in "pending" forever.
  • Sandbox first. Test the whole loop on sandbox credentials before switching the base URL and shortcode to production.

Always confirm the exact field names and endpoints against the current official Daraja documentation before going live — credentials and shortcodes are per-account, and the docs are the source of truth.

Need M-Pesa built into your app or backend?

Payment integrations are unforgiving — a small mistake means a customer is charged and your system never knows. I've integrated M-Pesa into production Node.js backends and mobile apps, callbacks and edge cases included. If you want it done right the first time, reach out, or find me on Fiverr and Upwork.

Want help with something similar?

If this article matches a problem you're facing, I can help you scope it and ship it — starting with a quick consultation.

Related Articles

Prevent Double Payments (Node.js)
Prevent Double Payments (Node.js)

May 5, 2026

Prevent Double Payments (Node.js)

Why users get charged twice when you integrate a third-party payment provider — and how to stop double payments with idempotency keys, state checks, a...

PaymentsNode.jsIdempotencyAPI Integration
Designing an Efficient Payment Database in Node.js
Designing an Efficient Payment Database in Node.js

April 20, 2026

Designing an Efficient Payment Database in Node.js

How to design a reliable, efficient payments database in Node.js with a SQL database and Sequelize — one schema that serves both M-Pesa and Pesapal, w...

Node.jsSequelizePaymentsPostgreSQLM-Pesa

Get In Touch

Let's Work Together

I'm currently open to new opportunities and collaborations. Whether you have a project in mind or just want to say hello, feel free to reach out!

Dev Dave

© 2026 Topoika. All rights reserved.

Privacy

This site uses cookies to understand how visitors use it and to improve the experience. You can accept or reject analytics cookies at any time — read the privacy note.