Field Notes · Payments · Kenya

Setting Up M-Pesa STK Push in a Next.js App

September 2026·6 min read·By TechGPT

STK Push — the pop-up prompt on a customer's phone asking them to enter their M-Pesa PIN — is the payment flow Kenyan customers actually trust, because they've used it a thousand times at kiosks and supermarkets. Wiring it into a Next.js app via Safaricom's Daraja API is well documented at a surface level, but most tutorials stop right after the happy path. Here's what actually matters once you're handling real customer money.

The Basic Flow

At a high level: your Next.js API route calls Safaricom's /mpesa/stkpush/v1/processrequest endpoint with the customer's phone number, amount, and a callback URL. Safaricom triggers the STK prompt on the customer's phone, the customer enters their PIN, and Safaricom then sends the result — success or failure — to your callback URL as a separate, asynchronous request. That async callback is where almost every integration mistake happens.

// app/api/mpesa/stkpush/route.js
export async function POST(req) {
  const { phone, amount, accountRef } = await req.json();
  const timestamp = getTimestamp();
  const password = generatePassword(shortcode, passkey, timestamp);

  const res = await fetch(DARAJA_STK_URL, {
    method: 'POST',
    headers: { Authorization: `Bearer ${await getAccessToken()}` },
    body: JSON.stringify({
      BusinessShortCode: shortcode,
      Password: password,
      Timestamp: timestamp,
      TransactionType: 'CustomerPayBillOnline',
      Amount: amount,
      PartyA: phone,
      PartyB: shortcode,
      PhoneNumber: phone,
      CallBackURL: `${BASE_URL}/api/mpesa/callback`,
      AccountReference: accountRef,
      TransactionDesc: 'Payment',
    }),
  });
  return Response.json(await res.json());
}

What Actually Breaks in Production

The callback doesn't always arrive quickly. Most tutorials assume the callback lands within seconds. In practice, network conditions on Safaricom's end or the customer's end can delay it by anywhere from a few seconds to over a minute. If your UI shows a spinner and gives up after 10 seconds, you'll show customers a false failure while the payment actually succeeds moments later — leading to duplicate payment attempts and confused support conversations.

Callbacks can arrive more than once. Safaricom's system will retry a callback delivery if it doesn't get a fast enough 200 response from your endpoint. If your callback handler isn't idempotent — meaning processing the same transaction ID twice doesn't double-charge or double-fulfil an order — you will eventually double-fulfil an order. The fix is straightforward but easy to skip: check whether you've already recorded that CheckoutRequestID before acting on it.

// app/api/mpesa/callback/route.js
export async function POST(req) {
  const { Body } = await req.json();
  const { CheckoutRequestID, ResultCode } = Body.stkCallback;

  const existing = await db.collection('payments').doc(CheckoutRequestID).get();
  if (existing.exists) {
    return Response.json({ ResultCode: 0, ResultDesc: 'Already processed' });
  }

  await db.collection('payments').doc(CheckoutRequestID).set({
    status: ResultCode === 0 ? 'success' : 'failed',
    raw: Body.stkCallback,
    processedAt: Date.now(),
  });

  return Response.json({ ResultCode: 0, ResultDesc: 'Accepted' });
}

The customer can cancel or ignore the prompt. A ResultCode of anything other than 0 doesn't just mean "wrong PIN" — it could mean the customer cancelled, the request timed out on their handset, or they simply didn't respond. Surface a clear, specific message rather than a generic "payment failed," and give an easy "try again" path rather than forcing them to re-enter their order details.

Your callback URL has to be publicly reachable over HTTPS — Safaricom won't call an internal or localhost URL, which trips up anyone testing locally without a tunnelling tool like ngrok pointed at their dev server during the sandbox testing phase.

Reconciliation Is the Part Nobody Talks About

Even with solid callback handling, you'll eventually hit a payment that succeeded on Safaricom's side with a callback that never arrived — a network blip, a server restart at the wrong moment. Safaricom's Daraja API includes a /stkpushquery endpoint specifically to check the status of a transaction after the fact. We run a background job that queries any transaction sitting in a "pending" state for more than five minutes, so a customer who paid isn't left in limbo because of a dropped callback.

None of this is exotic engineering — it's the unglamorous 20% that turns a demo integration into one that survives real customer traffic and real Safaricom network hiccups without losing anyone's money or trust.

Get an Instant Quote → See Our Work
More Field Notes