guides

How to Integrate M-Pesa STK Push Using Daraja 3.0 — A Complete Guide for Kenyan Developers

How to Integrate M-Pesa STK Push Using Daraja 3.0 — A Complete Guide for Kenyan Developers

If you are building a product in Kenya that accepts payments, M-Pesa integration is not optional, it is table stakes. Over 100 million transactions move through M-Pesa every day, and 25% of them now flow through APIs built by developers like you on Safaricom's Daraja platform.

This guide covers the most common and most useful integration: the STK Push, the prompt that appears on a customer's phone asking them to enter their M-Pesa PIN to complete a payment. By the end, you will have a working Node.js backend that authenticates with Daraja 3.0, initiates an STK Push, and handles the callback when the transaction completes or fails.

This guide uses the sandbox environment throughout. The same code works in production with your live credentials, you just swap the URLs and keys.

What You Need Before Starting

A Daraja developer account. Register at developer.safaricom.co.ke. The process is now fully self-service, one of Daraja 3.0's most welcome improvements. No paperwork, no back-and-forth with a sales team.

A sandbox app. After logging in, create a new app from your dashboard. You will receive a Consumer Key and Consumer Secret, treat these like passwords, never expose them in client-side code or commit them to a public repository.

Your sandbox credentials. From the APIs section of the developer portal, navigate to M-Pesa Express. You will find your sandbox Business Shortcode and Passkey. Save both, you will need them to generate the payment password.

Node.js 20 or later. If you followed our developer environment setup guide, you are already set.

A way to expose your local server to the internet. The Daraja API sends transaction results to a callback URL on your server. During development, your server is on localhost which Safaricom cannot reach. The easiest solution is ngrok, it creates a public tunnel to your local machine. Install it and run ngrok http 3000 (or whatever port you use) before testing.

Project Setup

Create your project folder and initialise it:

bash
mkdir mpesa-integration
cd mpesa-integration
npm init -y

Install the dependencies:

bash
npm install express axios dotenv
npm install --save-dev nodemon

Update package.json to add a dev script:

json
{
  "scripts": {
    "dev": "nodemon server.js",
    "start": "node server.js"
  }
}

Create a .env file in the root of your project:

bash
# Daraja Sandbox Credentials
CONSUMER_KEY=your_consumer_key_here
CONSUMER_SECRET=your_consumer_secret_here
BUSINESS_SHORTCODE=174379
PASSKEY=bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919
CALLBACK_URL=https://your-ngrok-url.ngrok.io/callback

# Server
PORT=3000

The BUSINESS_SHORTCODE and PASSKEY values above are Safaricom's official sandbox test credentials. Use them for testing — replace with your live credentials before going to production.

Create a .gitignore file immediately, before your first commit:

code
node_modules/
.env

Step 1: Generate an Access Token

Every Daraja API call requires a fresh OAuth access token. Tokens expire after 3,600 seconds (one hour). The cleanest approach is a middleware function that generates a token before each payment request.

Create middleware/auth.js:

javascript
const axios = require("axios");

const generateToken = async (req, res, next) => {
  const consumerKey = process.env.CONSUMER_KEY;
  const consumerSecret = process.env.CONSUMER_SECRET;

  // Encode credentials as Base64
  const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64");

  try {
    const response = await axios.get(
      "https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials",
      {
        headers: {
          Authorization: `Basic ${auth}`,
        },
      }
    );

    // Attach the token to the request object for the next handler
    req.access_token = response.data.access_token;
    next();
  } catch (error) {
    console.error("Token generation failed:", error.message);
    res.status(500).json({ error: "Failed to generate access token" });
  }
};

module.exports = generateToken;

For production: Cache the access token and only regenerate it when it is close to expiry, rather than making an OAuth call before every single transaction. This reduces latency and avoids hitting Safaricom's rate limits under load.

Step 2: Build the STK Push Utility

The STK Push requires a password — a Base64-encoded combination of your Business Shortcode, your Passkey, and the current timestamp in yyyyMMddHHmmss format. This password proves to Safaricom that the request is genuinely from you.

Create utils/mpesa.js:

javascript
const axios = require("axios");

/**
 * Generate the timestamp in the format required by Daraja: yyyyMMddHHmmss
 */
const getTimestamp = () => {
  const now = new Date();

  const year = now.getFullYear();
  const month = String(now.getMonth() + 1).padStart(2, "0");
  const day = String(now.getDate()).padStart(2, "0");
  const hours = String(now.getHours()).padStart(2, "0");
  const minutes = String(now.getMinutes()).padStart(2, "0");
  const seconds = String(now.getSeconds()).padStart(2, "0");

  return `${year}${month}${day}${hours}${minutes}${seconds}`;
};

/**
 * Generate the Base64 password required by STK Push
 * Formula: Base64(BusinessShortCode + Passkey + Timestamp)
 */
const generatePassword = (timestamp) => {
  const shortcode = process.env.BUSINESS_SHORTCODE;
  const passkey = process.env.PASSKEY;

  return Buffer.from(`${shortcode}${passkey}${timestamp}`).toString("base64");
};

/**
 * Format a phone number to the 2547XXXXXXXX format Daraja requires.
 * Accepts: 07XXXXXXXX, 7XXXXXXXX, +2547XXXXXXXX, 2547XXXXXXXX
 */
const formatPhoneNumber = (phone) => {
  // Remove any spaces or dashes
  const cleaned = phone.toString().replace(/[\s-]/g, "");

  if (cleaned.startsWith("254")) return cleaned;
  if (cleaned.startsWith("0")) return `254${cleaned.slice(1)}`;
  if (cleaned.startsWith("+254")) return cleaned.slice(1);
  if (cleaned.startsWith("7") || cleaned.startsWith("1")) return `254${cleaned}`;

  throw new Error(`Invalid phone number format: ${phone}`);
};

/**
 * Initiate an STK Push request
 * @param {string} phone - Customer phone number (any common Kenyan format)
 * @param {number} amount - Amount in KES (must be a whole number)
 * @param {string} accountReference - Your order ID or reference
 * @param {string} description - Short description of the transaction
 * @param {string} accessToken - OAuth token from the auth middleware
 */
const initiateSTKPush = async (phone, amount, accountReference, description, accessToken) => {
  const timestamp = getTimestamp();
  const password = generatePassword(timestamp);
  const formattedPhone = formatPhoneNumber(phone);
  const shortcode = process.env.BUSINESS_SHORTCODE;
  const callbackUrl = process.env.CALLBACK_URL;

  const payload = {
    BusinessShortCode: shortcode,
    Password: password,
    Timestamp: timestamp,
    TransactionType: "CustomerPayBillOnline",
    Amount: Math.round(amount), // Must be a whole number — no decimals
    PartyA: formattedPhone,     // Customer's phone number
    PartyB: shortcode,          // Your business shortcode
    PhoneNumber: formattedPhone,
    CallBackURL: callbackUrl,
    AccountReference: accountReference,
    TransactionDesc: description,
  };

  const response = await axios.post(
    "https://sandbox.safaricom.co.ke/mpesa/stkpush/v1/processrequest",
    payload,
    {
      headers: {
        Authorization: `Bearer ${accessToken}`,
        "Content-Type": "application/json",
      },
    }
  );

  return response.data;
};

module.exports = { initiateSTKPush, formatPhoneNumber };

Step 3: Create the Express Server

Create server.js:

javascript
require("dotenv").config();
const express = require("express");
const generateToken = require("./middleware/auth");
const { initiateSTKPush } = require("./utils/mpesa");

const app = express();
app.use(express.json());

/**
 * POST /stkpush
 * Initiates an M-Pesa STK Push to a customer's phone.
 *
 * Request body:
 * {
 *   "phone": "0712345678",
 *   "amount": 100,
 *   "orderId": "ORDER-001"
 * }
 */
app.post("/stkpush", generateToken, async (req, res) => {
  const { phone, amount, orderId } = req.body;

  // Basic validation
  if (!phone || !amount || !orderId) {
    return res.status(400).json({
      error: "phone, amount, and orderId are required",
    });
  }

  if (amount < 1) {
    return res.status(400).json({
      error: "Amount must be at least KES 1",
    });
  }

  try {
    const result = await initiateSTKPush(
      phone,
      amount,
      orderId,
      `Payment for order ${orderId}`,
      req.access_token
    );

    // MerchantRequestID and CheckoutRequestID identify this specific STK push
    // Save these to your database so you can match the callback to this request
    console.log("STK Push initiated:", result);

    res.status(200).json({
      message: "STK Push sent. Customer should receive a prompt shortly.",
      merchantRequestId: result.MerchantRequestID,
      checkoutRequestId: result.CheckoutRequestID,
    });
  } catch (error) {
    console.error("STK Push failed:", error.response?.data || error.message);
    res.status(500).json({
      error: "Failed to initiate payment",
      details: error.response?.data,
    });
  }
});

/**
 * POST /callback
 * Safaricom calls this endpoint with the transaction result.
 * This is where you confirm payment and update your database.
 */
app.post("/callback", (req, res) => {
  const callbackData = req.body;

  // Always respond with 200 immediately — Safaricom expects a quick acknowledgement
  res.status(200).json({ ResultCode: 0, ResultDesc: "Accepted" });

  // Now process the callback data
  const { Body } = callbackData;

  if (!Body?.stkCallback) {
    console.error("Unexpected callback structure:", callbackData);
    return;
  }

  const { ResultCode, ResultDesc, MerchantRequestID, CheckoutRequestID, CallbackMetadata } =
    Body.stkCallback;

  if (ResultCode === 0) {
    // Payment was successful
    // Extract transaction details from CallbackMetadata
    const metadata = {};
    CallbackMetadata.Item.forEach((item) => {
      metadata[item.Name] = item.Value;
    });

    console.log("✅ Payment successful:", {
      merchantRequestId: MerchantRequestID,
      checkoutRequestId: CheckoutRequestID,
      amount: metadata.Amount,
      mpesaReceiptNumber: metadata.MpesaReceiptNumber,  // The M-Pesa transaction ID
      transactionDate: metadata.TransactionDate,
      phoneNumber: metadata.PhoneNumber,
    });

    // TODO: Update your database here
    // Match using CheckoutRequestID to find the pending order
    // Mark the order as paid
    // Send confirmation to the customer

  } else {
    // Payment failed or was cancelled
    console.log("❌ Payment failed:", {
      merchantRequestId: MerchantRequestID,
      checkoutRequestId: CheckoutRequestID,
      resultCode: ResultCode,
      resultDesc: ResultDesc,  // Human-readable reason
    });

    // TODO: Update your database — mark the order as failed or pending
    // Common ResultCodes:
    // 1032 - Request cancelled by user
    // 1037 - DS timeout (user did not respond)
    // 2001 - Wrong PIN
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Step 4: Test It

Start your server:

bash
npm run dev

Start ngrok in a separate terminal:

bash
ngrok http 3000

Copy the https:// URL ngrok gives you, it looks like https://a1b2c3d4.ngrok.io. Update your .env file:

code
CALLBACK_URL=https://a1b2c3d4.ngrok.io/callback

Restart your server after updating .env.

Trigger a test payment using curl:

bash
curl -X POST http://localhost:3000/stkpush \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "0712345678",
    "amount": 1,
    "orderId": "TEST-001"
  }'

If everything is configured correctly, you will get a response like:

json
{
  "message": "STK Push sent. Customer should receive a prompt shortly.",
  "merchantRequestId": "29115-34620561-1",
  "checkoutRequestId": "ws_CO_191220191020363925"
}

In the sandbox, you do not receive an actual phone prompt. Instead, use Safaricom's Simulator in the developer portal to simulate a successful or failed payment. The simulator will send a callback to your ngrok URL, and you should see the transaction details logged in your terminal.

Understanding the Callback Structure

This is what a successful callback from Safaricom looks like:

json
{
  "Body": {
    "stkCallback": {
      "MerchantRequestID": "29115-34620561-1",
      "CheckoutRequestID": "ws_CO_191220191020363925",
      "ResultCode": 0,
      "ResultDesc": "The service request is processed successfully.",
      "CallbackMetadata": {
        "Item": [
          { "Name": "Amount", "Value": 1.00 },
          { "Name": "MpesaReceiptNumber", "Value": "NLJ7RT61SV" },
          { "Name": "TransactionDate", "Value": 20191219102115 },
          { "Name": "PhoneNumber", "Value": 254712345678 }
        ]
      }
    }
  }
}

And a failed callback (user cancelled):

json
{
  "Body": {
    "stkCallback": {
      "MerchantRequestID": "29115-34620561-1",
      "CheckoutRequestID": "ws_CO_191220191020363925",
      "ResultCode": 1032,
      "ResultDesc": "Request cancelled by user."
    }
  }
}

Note that failed callbacks do not include CallbackMetadata. Always check ResultCode before trying to read metadata.

Common Errors and How to Fix Them

400.002.02 — Bad Request: Invalid PhoneNumberYour phone number is not in the 2547XXXXXXXX format. Use the formatPhoneNumber utility above.

400.002.02 — Bad Request: Invalid Amount. The amount must be a whole number. M-Pesa does not support decimal amounts. Use Math.round(amount).

500.001.1001 — Internal Server Error. Usually means your password is wrong. Double-check that you are concatenating shortcode + passkey + timestamp in that exact order before Base64 encoding.

Callback not arriving. Check that your ngrok URL is running and matches what is in CALLBACK_URL exactly. ngrok URLs change every time you restart ngrok unless you have a paid plan, always update your .env after restarting ngrok.

errorCode: 404.001.04 — App not found. Your Consumer Key and Consumer Secret do not match a valid app in the sandbox. Double-check your .env file.

Going to Production

When you are ready to go live, three things change:

1. Replace sandbox URLs with production URLs:

javascript
// Sandbox
"https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials"
"https://sandbox.safaricom.co.ke/mpesa/stkpush/v1/processrequest"

// Production
"https://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials"
"https://api.safaricom.co.ke/mpesa/stkpush/v1/processrequest"

2. Replace your sandbox credentials with live credentials — your live Consumer Key, Consumer Secret, Shortcode, and Passkey from the Daraja portal.

3. Use a real HTTPS callback URL — ngrok is for development only. Your production callback endpoint must be on a real server with a valid SSL certificate. Safaricom will reject HTTP callback URLs in production.

What to Build Next

With STK Push working, the natural next integrations are:

C2B (Customer to Business) — for accepting payments via Paybill without triggering an STK Push. Useful for cases where customers initiate the payment from their M-Pesa menu directly.

B2C (Business to Customer) — for sending money to customers. Used for refunds, payouts, and disbursements. Requires additional approval from Safaricom.

Transaction Status API — for checking the status of a payment that never received a callback. Network issues can occasionally drop callbacks, this API lets you query the status of any transaction by its CheckoutRequestID.

Ratiba API — Daraja 3.0's newest addition, announced in early 2026. Enables scheduled and recurring payments, standing orders and subscription billing. Documentation is still rolling out on the developer portal.

A Note on Number Masking

As we covered in our M-Pesa number masking piece, the CBK recently approved Safaricom to mask customer phone numbers from merchant-facing SMS confirmations. What remains unconfirmed is whether the PhoneNumber field in the Daraja callback payload will also be masked in the future.

For now, the callback still returns the full customer phone number. Watch the Daraja changelog and the developer forum closely, if this changes, any logic that uses the callback phone number as a customer identifier will need to be updated.

Built something with Daraja? Hit a specific error that is not covered here? Drop a comment below or reach us at [email protected]. We update our guides when the API changes.

Comments

to join the discussion.