M-Pesa & Fintech

M-Pesa integration in Next.js — a production walkthrough

Victor Chumo portrait

Victor Chumo

Managing Director, Raven Tech Group

14 min read
M-Pesa integration in Next.js — a production walkthrough article hero image

What you need before you start

You need a Safaricom Daraja production app (consumer key and secret), a short code or till number cleared for the APIs you plan to use, and a publicly reachable HTTPS callback URL. Safaricom rejects HTTP callbacks in production — plan for that before you wire anything locally. I am writing this from the R4 Automotive build: same patterns we ship on Vercel with Next.js App Router route handlers.

Keep secrets in server-only environment variables. Never expose consumer secret or passkey to the browser. The browser should only trigger an API route that runs on the server.

The M-Pesa Daraja API — what each endpoint actually does

OAuth — You exchange consumer key and secret for an access token. Tokens expire (typically around an hour). Treat the token as cacheable but time-bounded.

STK Push — Lipa na M-Pesa online: you send amount, Party A phone, Party B till/paybill, AccountReference, TransactionDesc, and a CallBackURL. The customer gets the STK prompt on their handset. Your callback receives the result asynchronously.

C2B — Customer sends money to your paybill or till; you register validation and confirmation URLs. Different flow from STK; most product checkouts use STK Push first.

The docs name the endpoints; they do not always explain failure modes. Read the error payloads in practice — the difference between a declined STK and a timeout matters for your order state machine.

Setting up environment variables safely

Minimum server keys: MPESA_CONSUMER_KEY, MPESA_CONSUMER_SECRET, MPESA_SHORTCODE (or till), MPESA_PASSKEY for STK, MPESA_CALLBACK_URL (your API route URL), and MPESA_ENV (sandbox vs production base URL).

In Next.js, only reference these inside Route Handlers or Server Actions — never in client components. If you need a public key for something, Safaricom still expects the secret server-side for OAuth.

Building the OAuth token service

Tokens expire. If you request a fresh token on every STK call you will add latency and occasionally hit rate limits. Cache the token in memory (per server instance) with an expiry buffer — refresh when less than two minutes remain. On 401 from an API call, clear cache and retry once.

type TokenCache = { token: string; expiresAt: number }
 
let cache: TokenCache | null = null
 
export async function getMpesaAccessToken(): Promise<string> {
  const now = Date.now()
  if (cache && cache.expiresAt - now > 120_000) return cache.token
 
  const key = process.env.MPESA_CONSUMER_KEY
  const secret = process.env.MPESA_CONSUMER_SECRET
  const auth = Buffer.from(key + ':' + secret).toString('base64')
  const url = process.env.MPESA_BASE_URL + '/oauth/v1/generate?grant_type=client_credentials'
 
  const res = await fetch(url, {
    headers: { Authorization: 'Basic ' + auth },
  })
  if (!res.ok) throw new Error('Daraja OAuth failed: ' + res.status)
  const data = (await res.json()) as { access_token: string; expires_in: string }
  cache = {
    token: data.access_token,
    expiresAt: now + Number(data.expires_in) * 1000,
  }
  return cache.token
}

Implementing STK Push

Generate a password from the shortcode, passkey, and timestamp (format in Daraja docs — follow the exact string layout). Post to /mpesa/stkpush/v1/processrequest with Bearer token.

export async function stkPush(params: {
  phone: string
  amount: number
  accountRef: string
  desc: string
}) {
  const token = await getMpesaAccessToken()
  const timestamp = new Date().toISOString().replace(/[^0-9]/g, '').slice(0, -3)
  const short = process.env.MPESA_SHORTCODE ?? ''
  const pass = process.env.MPESA_PASSKEY ?? ''
  const password = Buffer.from(short + pass + timestamp).toString('base64')
 
  const body = {
    BusinessShortCode: short,
    Password: password,
    Timestamp: timestamp,
    TransactionType: 'CustomerPayBillOnline',
    Amount: params.amount,
    PartyA: params.phone,
    PartyB: short,
    PhoneNumber: params.phone,
    CallBackURL: process.env.MPESA_CALLBACK_URL,
    AccountReference: params.accountRef,
    TransactionDesc: params.desc,
  }
 
  const base = process.env.MPESA_BASE_URL ?? ''
  const res = await fetch(base + '/mpesa/stkpush/v1/processrequest', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + token,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  })
  const json = await res.json()
  if (!res.ok || json.ResponseCode !== '0') {
    throw new Error(json.errorMessage || json.CustomerMessage || 'STK failed')
  }
  return json as { CheckoutRequestID: string; MerchantRequestID: string }
}

Handling the callback the right way

Safaricom POSTs JSON to your CallBackURL. The payload nests ResultCode and ResultDesc inside Body.stkCallback. A ResultCode of 0 is success; anything else is a failed or cancelled payment.

Verify the callback: check that the request is HTTPS, reject unknown hosts, and compare CheckoutRequestID to what you stored when you initiated STK. Log the raw body before parsing edge cases — duplicate callbacks happen.

Respond with HTTP 200 quickly after you enqueue work. Long database transactions in the callback handler can cause Safaricom to retry while you are still processing.

Error handling and retry logic

Common ResultCodes: insufficient funds, user cancelled, wrong PIN, timeout. Map them to user-visible copy — never show raw Daraja strings to customers.

Implement idempotency on order completion: same CheckoutRequestID should not create two paid orders. Use a unique constraint or idempotency key in your database.

For transient failures (network blips talking to your DB), use a small retry queue — not infinite loops on the callback thread.

Testing with the Safaricom sandbox

Use sandbox credentials and test numbers from the Daraja portal. For local callback testing, expose your dev server with ngrok (HTTPS) and register that URL in the app settings. Plain localhost HTTP will not receive production-style callbacks from Safaricom.

Going to production — what changes

Switch base URL to production, rotate credentials, tighten IP allowlisting if you use it, and turn logging to structured JSON with request IDs. Monitor STK success rate and callback latency. Set alerts when error rates spike — often the first sign of a credential expiry or passkey rotation on the Safaricom side.

Common production issues and how to debug them

Callback never arrives — Firewall, wrong URL in Daraja app, or TLS chain issues. Hit the URL yourself with a signed test POST.

Intermittent 401 on OAuth — Clock skew on the server, or stale cache after secret rotation.

STK succeeds but your DB does not update — Callback handled on a different server instance without shared state — fix with idempotent DB writes and queue.

This is the pattern we use on every M-Pesa integration, including the R4 Automotive case study. If you want this wired for your product, book a discovery call — we will map STK to your order model and ship with logging you can audit.

Victor Chumo portrait

About the author

Victor Chumo

Managing Director, Raven Tech Group

Victor founded Raven Tech Group in 2024 after a decade building software across East Africa. He leads engagements for SACCOs, fintechs, and growth-stage businesses from Westlands, Nairobi.

Get field notes monthly

One email per month — notes from delivery in Nairobi. No spam.

Contact

Want this pattern applied to your systems? Send a brief.

Stack, users, and regulatory context — enough to propose a sensible next step.

We store your details to follow up on this request only. See our privacy policy.