This guide adds M-Pesa payments to a Next.js App Router app using Safaricom’s Daraja API and STK push, where the customer gets a PIN prompt on their phone. You’ll build:
lib/mpesa.ts: token caching, the Password and Timestamp, STK push, STK query and a safe callback parser,lib/mpesa-payments.ts: start a payment (saved first) and confirm it with STK query, once,- route handlers to start a payment, poll it, and receive Safaricom’s callback,
- a reconcile route for callbacks that are late, missing or ambiguous (
1037), protected by a secret, - tests that run the whole flow without calling Safaricom.
Every code block was run in a fresh Next.js 16 app (create-next-app with TypeScript and the App Router): the tests pass and next build succeeds. The same design in other stacks: Node.js, Laravel and Django.
Before you start
- Create an app on the Daraja portal (developer.safaricom.co.ke) and copy its Consumer Key and Consumer Secret.
- Note the sandbox test shortcode (normally
174379) and its passkey from the test credentials page. - Your callback URL must be public HTTPS. For local development, run a tunnel such as
cloudflared tunnel --url http://localhost:3000.
Put the settings in .env.local (Next.js keeps it out of git by default):
MPESA_ENV=sandbox
MPESA_CONSUMER_KEY=your-consumer-key
MPESA_CONSUMER_SECRET=your-consumer-secret
MPESA_SHORTCODE=174379
MPESA_PASSKEY=your-passkey
MPESA_CALLBACK_URL=https://your-tunnel.example.com/api/webhooks/stk
CRON_SECRET=a-long-random-string
Keep “mpesa” and “safaricom” out of the callback URL. Developers report that Safaricom silently drops callbacks to URLs containing them, which is why this guide uses /api/webhooks/stk.
The Daraja client
Two details trip people up most:
- Password is
base64(BusinessShortCode + Passkey + Timestamp), and the request must send that same Timestamp. - Timestamp is Kenyan time (
Africa/Nairobi) asYYYYMMDDHHmmss, whatever timezone your server or host runs in.
// lib/mpesa.ts
const env = process.env;
const baseUrl = () => (env.MPESA_ENV === 'production' ? 'https://api.safaricom.co.ke' : 'https://sandbox.safaricom.co.ke');
/** Network failures and 5xx answers: Safaricom may or may not have acted. */
export class DarajaError extends Error {}
let token: { value: string; expiresAt: number } | null = null;
async function getToken(): Promise<string> {
if (token && Date.now() < token.expiresAt - 60_000) return token.value;
const auth = Buffer.from(`${env.MPESA_CONSUMER_KEY}:${env.MPESA_CONSUMER_SECRET}`).toString('base64');
const res = await fetch(`${baseUrl()}/oauth/v1/generate?grant_type=client_credentials`, {
headers: { Authorization: `Basic ${auth}` },
cache: 'no-store',
signal: AbortSignal.timeout(20_000),
}).catch((err) => {
throw new DarajaError(`Daraja unreachable: ${(err as Error).message}`);
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.access_token) throw new DarajaError(`Daraja token failed: HTTP ${res.status}`);
token = { value: data.access_token, expiresAt: Date.now() + Number(data.expires_in ?? 3599) * 1000 };
return token.value;
}
/** Kenyan time as YYYYMMDDHHmmss, regardless of the server's timezone. */
export function timestamp(date = new Date()): string {
return new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Africa/Nairobi', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
}).format(date).replace(/\D/g, '');
}
export function password(ts: string): string {
return Buffer.from(`${env.MPESA_SHORTCODE}${env.MPESA_PASSKEY}${ts}`).toString('base64');
}
/** "0712 345 678", "+254712345678", "712345678" -> "254712345678" */
export function normaliseKenyanPhone(raw: unknown): string {
let digits = String(raw ?? '').replace(/\D/g, '');
if (digits.startsWith('00')) digits = digits.slice(2);
if (digits.startsWith('2540')) digits = '254' + digits.slice(4);
if (digits.startsWith('0')) digits = '254' + digits.slice(1);
else if (/^[17]\d{8}$/.test(digits)) digits = '254' + digits;
if (!/^254[17]\d{8}$/.test(digits)) throw new RangeError(`Not a Kenyan mobile number: ${String(raw)}`);
return digits;
}
type Daraja = Record<string, unknown>;
async function post(path: string, body: Daraja): Promise<{ status: number; data: Daraja }> {
const res = await fetch(`${baseUrl()}${path}`, {
method: 'POST',
headers: { Authorization: `Bearer ${await getToken()}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
signal: AbortSignal.timeout(30_000),
}).catch((err) => {
throw new DarajaError(`Daraja unreachable: ${(err as Error).message}`);
});
const data = (await res.json().catch(() => ({}))) as Daraja;
return { status: res.status, data: data && typeof data === 'object' ? data : {} };
}
/** Sends the PIN prompt. Returns the CheckoutRequestID, or throws. */
export async function stkPush(amount: number, phone: string, reference: string): Promise<string> {
const ts = timestamp();
const { status, data } = await post('/mpesa/stkpush/v1/processrequest', {
BusinessShortCode: env.MPESA_SHORTCODE,
Password: password(ts),
Timestamp: ts, // the same value used in the Password
TransactionType: env.MPESA_TRANSACTION_TYPE ?? 'CustomerPayBillOnline',
Amount: amount, // whole shillings
PartyA: phone,
PartyB: env.MPESA_PARTY_B ?? env.MPESA_SHORTCODE, // till number for Buy Goods
PhoneNumber: phone,
CallBackURL: env.MPESA_CALLBACK_URL,
AccountReference: reference.slice(0, 12),
TransactionDesc: 'Payment',
});
if (status < 300 && String(data.ResponseCode) === '0' && data.CheckoutRequestID) return String(data.CheckoutRequestID);
throw new Error(`STK push rejected: ${String(data.errorCode ?? data.ResponseCode ?? status)} ${String(data.errorMessage ?? data.ResponseDescription ?? '')}`.trim());
}
/** The result of a push, straight from Safaricom: { ResultCode: "0", ... } or an errorCode. */
export async function stkQuery(checkoutRequestId: string): Promise<Daraja> {
const ts = timestamp();
const { data } = await post('/mpesa/stkpushquery/v1/query', {
BusinessShortCode: env.MPESA_SHORTCODE,
Password: password(ts),
Timestamp: ts,
CheckoutRequestID: checkoutRequestId,
});
return data;
}
export interface Callback {
checkoutRequestId: string;
resultCode: string;
receipt: string;
}
/** Reads a callback safely. Failed payments have no CallbackMetadata. */
export function parseCallback(body: unknown): Callback | null {
const cb = (body as { Body?: { stkCallback?: Record<string, unknown> } } | null)?.Body?.stkCallback;
if (!cb || typeof cb.CheckoutRequestID !== 'string') return null;
const items = (cb.CallbackMetadata as { Item?: unknown } | undefined)?.Item;
const meta = new Map((Array.isArray(items) ? items : []).map((i) => [i?.Name, i?.Value]));
return { checkoutRequestId: cb.CheckoutRequestID, resultCode: String(cb.ResultCode ?? ''), receipt: String(meta.get('MpesaReceiptNumber') ?? '') };
}
/** paid | failed | unknown. "unknown" means: ask again before deciding. */
export function outcome(resultCode: unknown): 'paid' | 'failed' | 'unknown' {
const code = resultCode == null ? '' : String(resultCode);
if (code === '0') return 'paid';
if (['1', '1032', '2001', '1019'].includes(code)) return 'failed';
return 'unknown'; // 1037, 1001, 1025, "still processing", and anything new
}
For a Buy Goods till, set MPESA_TRANSACTION_TYPE=CustomerBuyGoodsOnline, MPESA_SHORTCODE to your store number and MPESA_PARTY_B to the till number. Every ResultCode is explained in the M-Pesa error code reference, and why callbacks go missing in M-Pesa callbacks and ResultCode 1037.
Start and confirm payments
Your database goes here. This file keeps payments in a Map so the guide runs on its own. When you replace the store functions, keep the rule in settle: change a row only if it is still pending, in one statement, for example UPDATE payments SET status = $2, receipt = $3 WHERE checkout_request_id = $1 AND status = 'pending'.
// lib/mpesa-payments.ts
import { randomUUID } from 'node:crypto';
import { DarajaError, normaliseKenyanPhone, outcome, stkPush, stkQuery } from './mpesa';
export interface Payment {
id: string;
orderId: string;
amount: number; // whole shillings
phone: string;
status: 'pending' | 'paid' | 'failed';
checkoutRequestId?: string;
resultCode?: string;
receipt?: string;
createdAt: number;
}
// ── Replace with your database ──────────────────────────────────────────────
const rows = new Map<string, Payment>();
async function insert(p: Payment): Promise<void> {
rows.set(p.id, { ...p });
}
async function patch(id: string, fields: Partial<Payment>): Promise<void> {
const p = rows.get(id);
if (p) rows.set(id, { ...p, ...fields });
}
export async function findPayment(id: string): Promise<Payment | null> {
const p = rows.get(id);
return p ? { ...p } : null;
}
export async function findByCheckout(checkoutRequestId: string): Promise<Payment | null> {
for (const p of rows.values()) if (p.checkoutRequestId === checkoutRequestId) return { ...p };
return null;
}
export async function pendingOlderThan(ms: number): Promise<Payment[]> {
const cutoff = Date.now() - ms;
return [...rows.values()].filter((p) => p.status === 'pending' && p.checkoutRequestId && p.createdAt < cutoff).slice(0, 100);
}
/** Changes a payment only while it is still pending. True if this call changed it. */
async function settle(id: string, fields: Partial<Payment>): Promise<boolean> {
const p = rows.get(id);
if (!p || p.status !== 'pending') return false;
rows.set(id, { ...p, ...fields });
return true;
}
// ────────────────────────────────────────────────────────────────────────────
/** Saves the payment, then sends the prompt. Throws RangeError for bad input. */
export async function startPayment(orderId: string, amount: number, rawPhone: unknown): Promise<Payment> {
if (!Number.isInteger(amount) || amount < 1) throw new RangeError('Amount must be a whole number of shillings, at least 1');
const phone = normaliseKenyanPhone(rawPhone);
const payment: Payment = { id: randomUUID(), orderId: orderId.slice(0, 64), amount, phone, status: 'pending', createdAt: Date.now() };
await insert(payment); // saved BEFORE Safaricom is called
try {
await patch(payment.id, { checkoutRequestId: await stkPush(amount, phone, payment.orderId) });
} catch (err) {
// Without a CheckoutRequestID there's nothing to query later, so the attempt is over.
// If a prompt did go out, the customer sees a second one when they retry and pays only once.
await settle(payment.id, { status: 'failed', resultCode: err instanceof DarajaError ? 'no_answer' : 'push_rejected' });
console.error('STK push failed', payment.id, (err as Error).message);
}
return (await findPayment(payment.id))!;
}
/** Asks Safaricom for the real result and settles the payment once. Returns 'paid' only on the call that settled it. */
export async function confirmPayment(checkoutRequestId: string, receipt = ''): Promise<Payment['status'] | null> {
const payment = await findByCheckout(checkoutRequestId);
if (!payment || payment.status !== 'pending') return null; // unknown, or already settled
const result = await stkQuery(checkoutRequestId);
const state = outcome(result.ResultCode);
if (state === 'unknown') return null; // 1037, "still processing": reconcile asks again later
const fields: Partial<Payment> = { status: state, resultCode: String(result.ResultCode), receipt: state === 'paid' ? receipt : '' };
return (await settle(payment.id, fields)) ? state : null;
}
The query answer doesn’t include the receipt number, so confirmPayment takes it from the callback, but only after the query has confirmed the payment. A forged callback can never mark an order paid.
The route handlers
Start a payment from your checkout page with fetch('/api/payments/stk', …), then poll /api/payments/stk/<id> every few seconds until the status is no longer pending.
// app/api/payments/stk/route.ts
import { startPayment } from '@/lib/mpesa-payments';
export async function POST(request: Request): Promise<Response> {
const body = await request.json().catch(() => null);
// In a real shop, take the amount from your order record, never from the browser.
const amount = Number(body?.amount);
try {
const payment = await startPayment(String(body?.orderId ?? ''), amount, body?.phone);
if (payment.status === 'failed') {
return Response.json({ error: 'M-Pesa could not send the payment prompt. Please try again.' }, { status: 502 });
}
return Response.json({ id: payment.id, message: 'Check your phone and enter your M-Pesa PIN.' }, { status: 202 });
} catch (err) {
if (err instanceof RangeError) return Response.json({ error: err.message }, { status: 422 });
throw err;
}
}
// app/api/payments/stk/[id]/route.ts
import { findPayment } from '@/lib/mpesa-payments';
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }): Promise<Response> {
const payment = await findPayment((await params).id);
if (!payment) return Response.json({ error: 'Not found' }, { status: 404 });
return Response.json({ status: payment.status, receipt: payment.receipt ?? null });
}
In Next.js 15 and 16, params is a Promise, so it’s awaited.
// app/api/webhooks/stk/route.ts
import { DarajaError, parseCallback } from '@/lib/mpesa';
import { confirmPayment } from '@/lib/mpesa-payments';
export async function POST(request: Request): Promise<Response> {
const cb = parseCallback(await request.json().catch(() => null));
if (cb) {
try {
const changed = await confirmPayment(cb.checkoutRequestId, cb.resultCode === '0' ? cb.receipt : '');
if (changed === 'paid') {
// Fulfil the order here, or queue a job that does. This runs once per payment.
console.info('M-Pesa payment confirmed', cb.checkoutRequestId);
}
} catch (err) {
if (!(err instanceof DarajaError)) throw err;
console.warn('Could not confirm; the reconcile route will retry', cb.checkoutRequestId);
}
}
return Response.json({ ResultCode: 0, ResultDesc: 'Accepted' }); // always acknowledge
}
Safaricom doesn’t retry callbacks the way Paystack retries webhooks, so the handler always answers 200 and leaves anything it couldn’t settle to the reconcile route.
Catch callbacks that never arrive
Callbacks can be late or lost, and 1037 payments need another look. This route asks Safaricom about every payment still pending after a minute. It refuses requests without the secret, so nobody else can make your app hammer Daraja.
// app/api/cron/reconcile-mpesa/route.ts
import { timingSafeEqual } from 'node:crypto';
import { DarajaError } from '@/lib/mpesa';
import { confirmPayment, pendingOlderThan } from '@/lib/mpesa-payments';
function authorised(header: string | null): boolean {
const secret = process.env.CRON_SECRET ?? '';
const given = Buffer.from(header ?? '');
const expected = Buffer.from(`Bearer ${secret}`);
return secret.length >= 16 && given.length === expected.length && timingSafeEqual(given, expected);
}
export async function GET(request: Request): Promise<Response> {
if (!authorised(request.headers.get('authorization'))) return new Response('Unauthorized', { status: 401 });
const counts: Record<string, number> = { paid: 0, failed: 0, pending: 0 };
for (const p of await pendingOlderThan(60_000)) {
try {
const changed = await confirmPayment(p.checkoutRequestId!);
counts[changed ?? 'pending'] += 1;
// A payment settled here has no receipt yet (the query doesn't return it); look it up in your M-Pesa statement if needed.
} catch (err) {
if (!(err instanceof DarajaError)) throw err;
counts.pending += 1;
}
}
return Response.json(counts);
}
Call it every few minutes with Authorization: Bearer <CRON_SECRET>. On Vercel, add the path to crons in vercel.json; Vercel sends that header when CRON_SECRET is set. Anywhere else, any scheduler that can send the header works:
curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-app.com/api/cron/reconcile-mpesa
Test it without calling Safaricom
The test calls the exported handlers directly with standard Request objects and replaces fetch with a fake Daraja. Install a TypeScript test runner that understands the @/ path alias:
npm install --save-dev tsx
npx tsx --test tests/mpesa.test.ts
// tests/mpesa.test.ts
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { POST as start } from '@/app/api/payments/stk/route';
import { GET as status } from '@/app/api/payments/stk/[id]/route';
import { POST as callback } from '@/app/api/webhooks/stk/route';
import { GET as reconcile } from '@/app/api/cron/reconcile-mpesa/route';
import { findPayment } from '@/lib/mpesa-payments';
import { normaliseKenyanPhone, password, timestamp } from '@/lib/mpesa';
Object.assign(process.env, { MPESA_SHORTCODE: '174379', MPESA_PASSKEY: 'test-passkey', MPESA_CALLBACK_URL: 'https://shop.test/api/webhooks/stk', CRON_SECRET: 'cron-secret-for-tests' });
let query: Record<string, unknown> = {};
let push: 'ok' | 'rejected' | 'down' = 'ok';
let sent: Record<string, unknown>[] = [];
let queries = 0;
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
const path = new URL(String(url)).pathname;
if (path === '/oauth/v1/generate') return Response.json({ access_token: 'test-token', expires_in: '3599' });
const body = JSON.parse(String(init?.body ?? '{}'));
if (path === '/mpesa/stkpush/v1/processrequest') {
if (push === 'down') throw new TypeError('fetch failed');
sent.push(body);
if (push === 'rejected') return Response.json({ errorCode: '400.002.02', errorMessage: 'Bad Request - Invalid PhoneNumber' }, { status: 400 });
return Response.json({ MerchantRequestID: 'm-1', CheckoutRequestID: `ws_CO_${sent.length}_${Date.now()}`, ResponseCode: '0' });
}
if (path === '/mpesa/stkpushquery/v1/query') {
queries += 1;
return Response.json(query, { status: 'errorCode' in query ? 500 : 200 });
}
throw new Error(`unexpected ${path}`);
}) as typeof fetch;
const STILL = { errorCode: '500.001.1001', errorMessage: 'The transaction is being processed' };
const json = (body: unknown) => ({ method: 'POST', body: JSON.stringify(body), headers: { 'content-type': 'application/json' } });
async function pay(phone = '0712 345 678', amount: unknown = 150) {
const res = await start(new Request('http://localhost/api/payments/stk', json({ orderId: 'order-1001', amount, phone })));
const body = await res.json();
return { res, body, payment: body.id ? await findPayment(body.id) : null };
}
function stkCallback(checkoutRequestId: string, resultCode: number, receipt?: string) {
const meta = receipt ? { CallbackMetadata: { Item: [{ Name: 'Amount', Value: 150 }, { Name: 'MpesaReceiptNumber', Value: receipt }] } } : {};
return callback(new Request('http://localhost/api/webhooks/stk', json({ Body: { stkCallback: { MerchantRequestID: 'm-1', CheckoutRequestID: checkoutRequestId, ResultCode: resultCode, ResultDesc: 'test', ...meta } } })));
}
beforeEach(() => {
query = STILL;
push = 'ok';
sent = [];
queries = 0;
});
test('the push carries the Password, Timestamp and phone Daraja expects', async () => {
const { res, payment } = await pay();
assert.equal(res.status, 202);
const body = sent[0];
assert.equal(body.Password, password(String(body.Timestamp)));
assert.match(String(body.Timestamp), /^\d{14}$/);
assert.deepEqual([body.PartyA, body.PhoneNumber, body.Amount, body.CallBackURL], ['254712345678', '254712345678', 150, 'https://shop.test/api/webhooks/stk']);
assert.ok(payment?.checkoutRequestId);
});
test('a forged success callback is checked with STK query and changes nothing', async () => {
const { payment } = await pay();
const res = await stkCallback(payment!.checkoutRequestId!, 0, 'FAKE000000');
assert.deepEqual(await res.json(), { ResultCode: 0, ResultDesc: 'Accepted' });
assert.equal(queries, 1);
assert.equal((await findPayment(payment!.id))?.status, 'pending');
});
test('a real success is paid once, with the receipt from the callback', async () => {
const { payment } = await pay();
query = { ResultCode: '0', ResultDesc: 'The service request is processed successfully.' };
const logged: unknown[] = [];
const info = console.info;
console.info = (...args: unknown[]) => void logged.push(args);
try {
await Promise.all([1, 2, 3].map(() => stkCallback(payment!.checkoutRequestId!, 0, 'TJR1234567')));
} finally {
console.info = info;
}
assert.equal(logged.length, 1);
const res = await status(new Request('http://localhost'), { params: Promise.resolve({ id: payment!.id }) });
assert.deepEqual(await res.json(), { status: 'paid', receipt: 'TJR1234567' });
});
test('1037 stays pending; cancelled fails; final means final', async () => {
const { payment } = await pay();
query = { ResultCode: '1037', ResultDesc: 'DS timeout user cannot be reached' };
await stkCallback(payment!.checkoutRequestId!, 1037);
assert.equal((await findPayment(payment!.id))?.status, 'pending');
query = { ResultCode: '1032', ResultDesc: 'Request cancelled by user' };
await stkCallback(payment!.checkoutRequestId!, 1032);
assert.equal((await findPayment(payment!.id))?.status, 'failed');
query = { ResultCode: '0' };
await stkCallback(payment!.checkoutRequestId!, 0, 'TJR7654321');
assert.equal((await findPayment(payment!.id))?.status, 'failed');
});
test('rejected or unanswered pushes fail; bad input is refused before Daraja', async () => {
push = 'rejected';
assert.equal((await pay()).res.status, 502);
push = 'down';
assert.equal((await pay()).res.status, 502);
push = 'ok';
assert.equal((await pay('12345')).res.status, 422);
assert.equal((await pay('0712345678', 10.5)).res.status, 422);
assert.equal(sent.length, 1); // only the rejected one reached the push endpoint with a body
});
test('callbacks for unknown payments, or junk, are acknowledged and ignored', async () => {
assert.equal((await stkCallback('ws_CO_not_ours', 0, 'TJR0000000')).status, 200);
const junk = await callback(new Request('http://localhost/api/webhooks/stk', { method: 'POST', body: 'not json' }));
assert.equal(junk.status, 200);
assert.equal(queries, 0);
});
test('the reconcile route needs the secret and settles old pending payments', async () => {
const { payment } = await pay();
const old = Date.now;
Date.now = () => old() + 5 * 60_000; // five minutes later
try {
assert.equal((await reconcile(new Request('http://localhost/api/cron/reconcile-mpesa'))).status, 401);
assert.equal((await reconcile(new Request('http://localhost', { headers: { authorization: 'Bearer wrong' } }))).status, 401);
process.env.CRON_SECRET = 'short';
assert.equal((await reconcile(new Request('http://localhost', { headers: { authorization: 'Bearer short' } }))).status, 401); // weak secret: refuse
process.env.CRON_SECRET = 'cron-secret-for-tests';
query = { ResultCode: '0' };
const res = await reconcile(new Request('http://localhost', { headers: { authorization: 'Bearer cron-secret-for-tests' } }));
assert.equal(res.status, 200);
assert.ok((await res.json()).paid >= 1);
} finally {
Date.now = old;
}
assert.equal((await findPayment(payment!.id))?.status, 'paid');
});
test('phone numbers and timestamps', () => {
assert.deepEqual(['0712 345 678', '+254712345678', '712345678', '0110 123 456'].map(normaliseKenyanPhone), ['254712345678', '254712345678', '254712345678', '254110123456']);
assert.equal(timestamp(new Date('2026-11-06T21:30:05Z')), '20261107003005'); // Nairobi is UTC+3
});
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
500.001.1001 on the push |
Wrong Password: Timestamp mismatch, stray space in the passkey, or another shortcode’s passkey | Decode it in the STK Push Builder |
| Callback never arrives | Not public HTTPS, “mpesa” in the URL, or auth middleware redirects it | Use /api/webhooks/stk and exclude /api/webhooks from the middleware matcher; the reconcile route settles payments meanwhile |
Payments stuck on pending |
1037, or no callback |
Keep the reconcile route running; it asks again until Safaricom answers |
Route segment config "runtime" is not compatible with nextConfig.cacheComponents |
export const runtime in a Next.js 16 app |
Delete it: route handlers use Node.js by default, which Buffer and node:crypto need |
| Token works then fails on Vercel | Each serverless instance has its own cached token | That’s fine: each one fetches a token when it needs one. Don’t store tokens in the browser |
params is a Promise error |
Next.js 15+ made route params async |
await params as in the status route |
Going to production
- Switch
MPESA_ENV=productionwith your production consumer key, secret, shortcode and passkey, after Safaricom’s go-live. - Set
MPESA_CALLBACK_URLto your production HTTPS URL. - Replace the
Mapinlib/mpesa-payments.tswith your database, keeping the conditional update. - Take the amount from your order, never from the request body.
- Schedule the reconcile route, and reconcile daily against your M-Pesa statement.
Want this already built, combined with MTN MoMo, Airtel Money, Flutterwave, Paystack, Pesapal and payouts? The Next.js starter kit has it ready.
Frequently asked questions
Can I call the Daraja API from a Next.js client component?
No. The consumer secret and passkey must stay on the server. Start the STK push from a route handler (or a server action) and let the browser poll your own route for the status.
Why does my M-Pesa callback never reach my Next.js app?
Common causes: the URL is localhost or not HTTPS, it contains "mpesa" or "safaricom" (developers report those are dropped), or middleware redirects the request to a login page before your handler runs. Use a neutral path like /api/webhooks/stk and exclude it from auth middleware.
Should I trust the ResultCode in the M-Pesa callback?
No. Daraja callbacks are not signed. Use the callback to find the payment and to read the receipt number, and confirm the result with STK Push Query before you mark the order paid.
What should I do with ResultCode 1037 in Next.js?
1037 means Safaricom could not reach the phone in time, and the payment can still complete. Keep it pending and let the reconcile route ask again. Don't start a second push for the same order until it resolves.