Skip to content
QuantumNest Technologies
QuantumNest
M-PesaDarajaNext.jsPayments

How to integrate M-Pesa Daraja STK Push in Next.js

A practical guide to M-Pesa Express (STK Push) with Next.js route handlers: OAuth tokens, the password, callbacks, status queries and going live.

Philip Egesa 6 min read

M-Pesa Express, better known as STK Push, is the smoothest way to take M-Pesa payments online. Your server asks Safaricom to send a payment prompt to the customer’s phone. The customer enters their PIN, and Safaricom calls your server back with the result.

This guide walks through a production-minded integration using Next.js App Router route handlers. The same flow works in any Node.js backend.

How the flow works

  1. Your server gets an OAuth access token from Daraja.
  2. Your server sends an STK Push request with the amount, phone number and a callback URL.
  3. Daraja replies immediately with a CheckoutRequestID. This only means the request was accepted, not that the customer paid.
  4. The customer sees the prompt and enters their PIN (or cancels, or ignores it).
  5. Safaricom sends the final result to your callback URL.
  6. If the callback never arrives, you query the status using the CheckoutRequestID.

The most common mistake is treating step 3 as a successful payment. Only the callback (or a status query) tells you the money moved.

What you need

  • A Daraja account at developer.safaricom.co.ke and an app with M-Pesa Express enabled. This gives you a consumer key and secret.
  • For testing, the sandbox shortcode 174379 and the sandbox passkey shown in the Daraja simulator.
  • For production, a Paybill or Buy Goods (Till) number registered to the business, and the production passkey Safaricom issues after go-live.
  • A public HTTPS URL for callbacks. On your laptop, use a tunnel such as ngrok or Cloudflare Tunnel.

Keep secrets in environment variables:

MPESA_ENV=sandbox                 # or production
MPESA_CONSUMER_KEY=...
MPESA_CONSUMER_SECRET=...
MPESA_SHORTCODE=174379            # Paybill, or the store/head-office number for a Till
MPESA_PARTY_B=174379              # Paybill number, or the Till number for Buy Goods
MPESA_PASSKEY=...
MPESA_CALLBACK_URL=https://yourdomain.co.ke/api/payments/callback

Avoid putting words like “mpesa”, “safaricom”, “exec” or “sql” in callback and C2B URLs. Safaricom’s documentation warns that URLs with such keywords may be rejected.

1. Get an access token

Tokens last about an hour, so cache them instead of requesting one per payment.

// lib/mpesa.ts
export const BASE =
  process.env.MPESA_ENV === "production" ? "https://api.safaricom.co.ke" : "https://sandbox.safaricom.co.ke";

let cached: { token: string; expiresAt: number } | null = null;

export async function getAccessToken() {
  if (cached && Date.now() < cached.expiresAt) return cached.token;
  const credentials = Buffer.from(`${process.env.MPESA_CONSUMER_KEY}:${process.env.MPESA_CONSUMER_SECRET}`).toString(
    "base64",
  );

  const res = await fetch(`${BASE}/oauth/v1/generate?grant_type=client_credentials`, {
    headers: { Authorization: `Basic ${credentials}` },
    cache: "no-store",
  });
  if (!res.ok) throw new Error(`Daraja auth failed: ${res.status}`);
  const data = (await res.json()) as { access_token: string; expires_in: string };

  // Refresh a minute early to be safe.
  cached = { token: data.access_token, expiresAt: Date.now() + (Number(data.expires_in) - 60) * 1000 };
  return cached.token;
}

2. Build the password and timestamp

The password is a Base64 string of shortcode + passkey + timestamp. The timestamp format is YYYYMMDDHHmmss in Kenyan time.

export function timestamp(date = new Date()) {
  // Format in Africa/Nairobi time regardless of server timezone.
  const parts = new Intl.DateTimeFormat("en-GB", {
    timeZone: "Africa/Nairobi",
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit",
    hour12: false,
  }).formatToParts(date);
  const get = (t: string) => parts.find((p) => p.type === t)!.value;
  return `${get("year")}${get("month")}${get("day")}${get("hour")}${get("minute")}${get("second")}`;
}

export function password(ts: string) {
  return Buffer.from(`${process.env.MPESA_SHORTCODE}${process.env.MPESA_PASSKEY}${ts}`).toString("base64");
}

/** Accepts 07..., 01..., +254..., 254... and returns 2547XXXXXXXX / 2541XXXXXXXX. */
export function normalizePhone(input: string) {
  const digits = input.replace(/\D/g, "");
  if (/^0[17]\d{8}$/.test(digits)) return `254${digits.slice(1)}`;
  if (/^254[17]\d{8}$/.test(digits)) return digits;
  throw new Error("Invalid Kenyan phone number");
}

3. Send the STK Push

Create the order in your database before calling Daraja, then store the CheckoutRequestID on it. That record is what you will match the callback against.

// app/api/pay/route.ts
import { NextResponse } from "next/server";
import { BASE, getAccessToken, normalizePhone, password, timestamp } from "@/lib/mpesa";
import { db } from "@/lib/db"; // your database client

export async function POST(req: Request) {
  const { orderId, phone } = await req.json();
  const order = await db.order.findUnique({ where: { id: orderId } });
  if (!order || order.status === "paid") {
    return NextResponse.json({ error: "Order not payable" }, { status: 400 });
  }

  const ts = timestamp();
  const res = await fetch(`${BASE}/mpesa/stkpush/v1/processrequest`, {
    method: "POST",
    headers: { Authorization: `Bearer ${await getAccessToken()}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      BusinessShortCode: process.env.MPESA_SHORTCODE,
      Password: password(ts),
      Timestamp: ts,
      TransactionType: "CustomerPayBillOnline", // "CustomerBuyGoodsOnline" for a Till
      Amount: Math.ceil(order.total), // whole shillings only
      PartyA: normalizePhone(phone),
      PartyB: process.env.MPESA_PARTY_B,
      PhoneNumber: normalizePhone(phone),
      CallBackURL: process.env.MPESA_CALLBACK_URL,
      AccountReference: order.reference.slice(0, 12),
      TransactionDesc: "Order payment",
    }),
  });

  const data = await res.json();
  if (data.ResponseCode !== "0") {
    return NextResponse.json({ error: data.errorMessage ?? data.ResponseDescription }, { status: 502 });
  }

  await db.order.update({
    where: { id: order.id },
    data: { status: "awaiting_payment", checkoutRequestId: data.CheckoutRequestID },
  });
  return NextResponse.json({ checkoutRequestId: data.CheckoutRequestID });
}

Paybill or Till? For a Paybill, BusinessShortCode and PartyB are both the Paybill number and the type is CustomerPayBillOnline. For Buy Goods, BusinessShortCode is the store (head office) number, PartyB is the till number, and the type is CustomerBuyGoodsOnline. Mixing these up is the number one cause of “invalid shortcode” errors after going live.

4. Handle the callback

Safaricom posts a JSON body to your callback URL. A successful payment looks like this (trimmed):

{
  "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 },
          { "Name": "MpesaReceiptNumber", "Value": "NLJ7RT61SV" },
          { "Name": "TransactionDate", "Value": 20191219102115 },
          { "Name": "PhoneNumber", "Value": 254708374149 }
        ]
      }
    }
  }
}

A non-zero ResultCode means no money moved. Common ones: 1032 (customer cancelled), 1037 (phone unreachable or the prompt timed out), 1 (insufficient balance) and 2001 (wrong PIN).

// app/api/payments/callback/route.ts
import { NextResponse } from "next/server";
import { db } from "@/lib/db";

export async function POST(req: Request) {
  const payload = await req.json().catch(() => null);
  const cb = payload?.Body?.stkCallback;
  if (!cb?.CheckoutRequestID) return NextResponse.json({ ResultCode: 0, ResultDesc: "Ignored" });

  const order = await db.order.findFirst({ where: { checkoutRequestId: cb.CheckoutRequestID } });

  // Idempotency: callbacks can arrive more than once.
  if (order && order.status !== "paid") {
    if (cb.ResultCode === 0) {
      const meta = Object.fromEntries(
        (cb.CallbackMetadata?.Item ?? []).map((i: { Name: string; Value?: unknown }) => [i.Name, i.Value]),
      );
      // Defence in depth: the amount must match what we asked for.
      if (Number(meta.Amount) >= Math.ceil(order.total)) {
        await db.order.update({
          where: { id: order.id },
          data: { status: "paid", mpesaReceipt: String(meta.MpesaReceiptNumber), paidAt: new Date() },
        });
      } else {
        await db.order.update({ where: { id: order.id }, data: { status: "review" } });
      }
    } else {
      await db.order.update({ where: { id: order.id }, data: { status: "failed", failureCode: cb.ResultCode } });
    }
  }

  // Always acknowledge quickly so Safaricom does not keep retrying.
  return NextResponse.json({ ResultCode: 0, ResultDesc: "Accepted" });
}

Daraja callbacks are not signed. Protect the endpoint by matching on a CheckoutRequestID that you created and stored, by checking the amount, and, for high-value orders, by confirming with a status query before fulfilling.

5. Query the status when the callback is late

Callbacks occasionally arrive late or not at all. Run a scheduled job (every minute or two) for orders stuck in awaiting_payment:

// lib/mpesa.ts (continued)
export async function queryStatus(checkoutRequestId: string) {
  const ts = timestamp();
  const res = await fetch(`${BASE}/mpesa/stkpushquery/v1/query`, {
    method: "POST",
    headers: { Authorization: `Bearer ${await getAccessToken()}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      BusinessShortCode: process.env.MPESA_SHORTCODE,
      Password: password(ts),
      Timestamp: ts,
      CheckoutRequestID: checkoutRequestId,
    }),
  });
  return res.json(); // ResultCode "0" means paid; anything else, check ResultDesc
}

On the frontend, show a “Check your phone” state and poll your own API (not Daraja) for the order status every few seconds, for up to about two minutes.

6. Going live

  1. In the Daraja portal, start the Go Live process for your app and choose M-Pesa Express.
  2. Verify ownership of the shortcode. Safaricom sends a one-time PIN to the business’s registered admin.
  3. Safaricom emails the production passkey. Swap your environment variables and set MPESA_ENV=production.
  4. Test with a small real payment (for example KES 1) before announcing the launch.

A production checklist

  • Create the order before the STK request, and never mark it paid from the client.
  • Make the callback idempotent and respond quickly.
  • Run a status-query job for stuck payments.
  • Log every request and callback (without secrets) so you can reconcile later.
  • Reconcile daily against your M-Pesa statement or the Transaction Status API.
  • Rate-limit the “pay” endpoint so nobody can spam customers with prompts.

Need this built, or have an integration that “mostly works”? We build and fix M-Pesa integrations every week.

Related service

from KES 6,000

Payments & M-Pesa Integration

Daraja STK Push, C2B, B2C and reconciliation, plus Paystack, Flutterwave, Pesapal, Stripe and PayPal.

Learn more

Have a project in mind?

Tell us what you need. You'll get a reply within one working day, with honest advice and a clear next step.

Mon–Sat, 8:00–18:00 EAT · Nairobi, Kenya