Skip to content

M-Pesa · Next.js

M-Pesa B2C in Next.js App Router: Payouts That Never Pay Twice

Send M-Pesa B2C payouts from a Next.js App Router app: SecurityCredential, an admin payout route, ResultURL and timeout routes, and no double payments. Tested.

Published

Skip the boilerplate → Building collections too? Generate STK push requests and passwords with the M-Pesa STK Push Builder.

This guide sends money from your M-Pesa B2C shortcode to customers’ M-Pesa wallets from a Next.js App Router app, with the Daraja B2C API: refunds, payouts to sellers or drivers, and promotions. You’ll build:

  • lib/mpesa-b2c.ts: a token, the SecurityCredential and the payment request,
  • lib/b2c-payouts.ts: payouts that are never sent twice, even after a timeout or two clicks at once,
  • an admin route that creates and sends a payout, protected by a token and limits,
  • ResultURL and QueueTimeOutURL routes that only accept results that match a payout you sent,
  • tests that run every case without calling Safaricom, with a real certificate round trip.

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. Safaricom’s own Daraja pages need a login, so the API details come from published integrations, checked in October 2026; points where they disagree are flagged in the Node.js version of this guide. The same design in Laravel and Django.

How B2C differs from STK push

STK push (receive) B2C (send)
Customer approval PIN prompt on their phone None: money leaves your account at once
Credentials Shortcode + passkey Initiator name + SecurityCredential
Result Callback to CallBackURL Result to ResultURL, or a call to QueueTimeOutURL

Four rules keep payouts safe:

  1. Create the ID once, with the payout, and save it before sending. It is the OriginatorConversationID, so a result can always be matched to its payout.
  2. Claim the payout before sending. Move it from new to sending with a conditional update. Only the request that wins the claim calls Safaricom.
  3. Never resend automatically. Daraja has no “this request doesn’t exist” answer you can rely on. An unknown payout waits for its result, or for a person.
  4. Final means final. paid, failed and review never change. To try again, create a new payout deliberately.
new ──claim──▶ sending ──ResponseCode 0──▶ accepted ──Result 0──▶ paid
                  │                            │   └──Result ≠ 0──▶ failed
                  ├──4xx (clear refusal)──▶ failed
                  └──timeout / 5xx──▶ unknown ◀──QueueTimeOutURL──┘
                                        └──Result arrives later──▶ paid / failed

Before you start

  1. On the Daraja portal, create a sandbox app and copy its consumer key and secret. The sandbox test credentials include a shortcode, an initiator name and an initiator password for B2C.
  2. Download Safaricom’s sandbox certificate from the Daraja documentation. In production you’ll use the production certificate.

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_B2C_SHORTCODE=your-b2c-shortcode
MPESA_INITIATOR_NAME=your-initiator-name
MPESA_INITIATOR_PASSWORD=your-initiator-password
MPESA_CERT_PATH=./certs/sandbox.cer
MPESA_B2C_COMMAND=BusinessPayment
MPESA_B2C_CALLBACK_SECRET=paste-a-random-string-of-32-or-more-characters
PUBLIC_URL=https://your-app.example.com
PAYOUT_ADMIN_TOKEN=a-long-random-string-for-your-admin-tools
PAYOUT_MAX_AMOUNT=50000
PAYOUT_DAILY_LIMIT=500000

Generate the secrets with node -e "console.log(crypto.randomBytes(24).toString('base64url'))". MPESA_B2C_COMMAND is one of BusinessPayment, SalaryPayment or PromotionPayment. As with STK push, keep “mpesa” and “safaricom” out of your callback URLs; developers report that Safaricom drops callbacks to URLs containing them. This guide uses /api/webhooks/b2c/<secret>/….

The B2C client

// lib/mpesa-b2c.ts
import { X509Certificate, constants, publicEncrypt } from 'node:crypto';
import { readFileSync } from 'node:fs';

const env = process.env;
const baseUrl = () => (env.MPESA_ENV === 'production' ? 'https://api.safaricom.co.ke' : 'https://sandbox.safaricom.co.ke');

export class MpesaError extends Error {
  /** ambiguous: the payout may have gone through. Never resend it. */
  constructor(message: string, readonly ambiguous: boolean, readonly code = '') {
    super(message);
  }
}

async function call(path: string, init: RequestInit = {}): Promise<Response> {
  try {
    return await fetch(`${baseUrl()}${path}`, { ...init, cache: 'no-store', signal: AbortSignal.timeout(30_000) });
  } catch (err) {
    throw new MpesaError(`Daraja unreachable: ${(err as Error).message}`, true);
  }
}

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 call('/oauth/v1/generate?grant_type=client_credentials', { headers: { Authorization: `Basic ${auth}` } });
  const data = await res.json().catch(() => ({}));
  if (!res.ok || !data.access_token) throw new MpesaError(`Daraja token failed: HTTP ${res.status}`, false);
  token = { value: data.access_token, expiresAt: Date.now() + Number(data.expires_in ?? 3599) * 1000 };
  return token.value;
}

/**
 * SecurityCredential = base64(RSA PKCS#1 v1.5 encrypt(initiator password, Safaricom's certificate)).
 * Use the sandbox certificate in the sandbox and the production certificate in production.
 */
export function securityCredential(password: string, certificate: string | Buffer): string {
  const { publicKey } = new X509Certificate(certificate);
  return publicEncrypt({ key: publicKey, padding: constants.RSA_PKCS1_PADDING }, Buffer.from(password)).toString('base64');
}

function certificate(): string | Buffer {
  if (env.MPESA_CERT_PEM) return env.MPESA_CERT_PEM;
  if (env.MPESA_CERT_PATH) return readFileSync(env.MPESA_CERT_PATH);
  throw new MpesaError('Set MPESA_CERT_PEM or MPESA_CERT_PATH', false);
}

export interface B2cRequest {
  id: string; // the OriginatorConversationID, saved before this call
  amount: number; // whole shillings
  phone: string; // 2547XXXXXXXX or 2541XXXXXXXX
  remarks: string;
}

/** Sends one B2C payment request. Returns Safaricom's ConversationID: an acknowledgement, NOT the result. */
export async function b2cPayment(p: B2cRequest): Promise<string> {
  const callbacks = `${env.PUBLIC_URL}/api/webhooks/b2c/${env.MPESA_B2C_CALLBACK_SECRET}`;
  const res = await call('/mpesa/b2c/v3/paymentrequest', {
    method: 'POST',
    headers: { Authorization: `Bearer ${await getToken()}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      OriginatorConversationID: p.id,
      InitiatorName: env.MPESA_INITIATOR_NAME,
      SecurityCredential: securityCredential(env.MPESA_INITIATOR_PASSWORD ?? '', certificate()),
      CommandID: env.MPESA_B2C_COMMAND ?? 'BusinessPayment',
      Amount: p.amount,
      PartyA: env.MPESA_B2C_SHORTCODE,
      PartyB: p.phone,
      Remarks: p.remarks.slice(0, 100),
      QueueTimeOutURL: `${callbacks}/timeout/${encodeURIComponent(p.id)}`,
      ResultURL: `${callbacks}/result`,
      Occasion: '',
    }),
  });
  const body = await res.json().catch(() => null);
  if (res.ok && String(body?.ResponseCode) === '0' && body?.ConversationID) return String(body.ConversationID);
  const code = String(body?.errorCode ?? body?.ResponseCode ?? `HTTP_${res.status}`);
  throw new MpesaError(`B2C request failed: ${code}`, res.status >= 500 || !body, code);
}

v1 or v3? Newer integrations use /mpesa/b2c/v3/paymentrequest with an OriginatorConversationID you choose; older ones use v1 without it. This guide uses v3, because choosing the ID yourself lets you save it before sending. If your Daraja app only accepts v1, change the path and match results on the ConversationID instead.

Never pay twice

Your database goes here. This file keeps payouts in a Map so the guide runs on its own. When you replace the store functions, keep the rules they enforce:

  • claim moves a payout from one status to another only if it is still in the first one, in one statement: UPDATE payouts SET status = 'sending' WHERE id = $1 AND status = 'new', then check that one row changed.
  • update refuses to change a final payout: ... WHERE status NOT IN ('paid', 'failed', 'review').
// lib/b2c-payouts.ts
import { randomUUID } from 'node:crypto';
import { b2cPayment, MpesaError } from './mpesa-b2c';

export type Status = 'new' | 'sending' | 'accepted' | 'unknown' | 'paid' | 'failed' | 'review';
export interface Payout {
  id: string; // the OriginatorConversationID, fixed for life
  amount: number;
  phone: string;
  remarks: string;
  createdBy: string;
  status: Status;
  conversationId?: string;
  receipt?: string;
  reason?: string;
  createdAt: number;
}

const FINAL: Status[] = ['paid', 'failed', 'review'];

// ── Replace with your database ──────────────────────────────────────────────
const rows = new Map<string, Payout>();

export async function findPayout(id: string): Promise<Payout | null> {
  const p = rows.get(id);
  return p ? { ...p } : null;
}
async function sentToday(): Promise<number> {
  const midnight = new Date().setUTCHours(0, 0, 0, 0);
  return [...rows.values()].filter((p) => p.status !== 'failed' && p.createdAt >= midnight).reduce((sum, p) => sum + p.amount, 0);
}
/** Moves a payout between statuses, only if it is still in one of `from`. True if this call moved it. */
async function claim(id: string, from: Status[], fields: Partial<Payout>): Promise<boolean> {
  const p = rows.get(id);
  if (!p || !from.includes(p.status)) return false;
  rows.set(id, { ...p, ...fields });
  return true;
}
// ────────────────────────────────────────────────────────────────────────────

/** Checks the amount, phone and limits, then saves the payout as "new". Throws RangeError when it isn't allowed. */
export async function createPayout(input: { amount: unknown; phone: unknown; remarks?: unknown; createdBy: string }): Promise<Payout> {
  const amount = Number(input.amount);
  if (!Number.isInteger(amount) || amount <= 0) throw new RangeError('B2C amounts are whole shillings');
  const phone = String(input.phone ?? '');
  if (!/^254[17]\d{8}$/.test(phone)) throw new RangeError('Phone must look like 2547XXXXXXXX or 2541XXXXXXXX');
  const max = Number(process.env.PAYOUT_MAX_AMOUNT ?? 0);
  const daily = Number(process.env.PAYOUT_DAILY_LIMIT ?? 0);
  if (max > 0 && amount > max) throw new RangeError(`More than the KES ${max} limit per payout`);
  if (daily > 0 && (await sentToday()) + amount > daily) throw new RangeError(`More than the KES ${daily} daily limit`);
  const payout: Payout = {
    id: randomUUID(), amount, phone, remarks: String(input.remarks ?? 'Payout').slice(0, 100), createdBy: input.createdBy, status: 'new', createdAt: Date.now(),
  };
  rows.set(payout.id, payout); // saved, with its id, BEFORE anything is sent
  return { ...payout };
}

/** Sends a payout once. Only the caller that claims it from "new" calls Safaricom. */
export async function sendPayout(id: string): Promise<Payout | null> {
  if (!(await claim(id, ['new'], { status: 'sending' }))) return findPayout(id); // sent, or maybe sent: never again
  const payout = (await findPayout(id))!;
  try {
    const conversationId = await b2cPayment(payout);
    // A result can arrive before this line runs: only move on from "sending".
    await claim(id, ['sending'], { status: 'accepted', conversationId });
  } catch (err) {
    if (!(err instanceof MpesaError)) throw err;
    if (err.ambiguous) await claim(id, ['sending'], { status: 'unknown', reason: 'NO_ACKNOWLEDGEMENT' }); // may have gone through
    else await claim(id, ['sending'], { status: 'failed', reason: err.code });
  }
  return findPayout(id);
}

interface B2cResult {
  ResultCode?: unknown;
  ResultDesc?: unknown;
  OriginatorConversationID?: unknown;
  ConversationID?: unknown;
  TransactionID?: unknown;
  ResultParameters?: { ResultParameter?: { Key: string; Value: unknown } | { Key: string; Value: unknown }[] };
}

/** Handles the POST to your ResultURL. Returns the new status, or null when the result is ignored. */
export async function handleResult(body: unknown): Promise<Status | null> {
  const result = (body as { Result?: B2cResult } | null)?.Result;
  if (!result || typeof result.OriginatorConversationID !== 'string') return null;
  const payout = await findPayout(result.OriginatorConversationID);
  if (!payout || FINAL.includes(payout.status) || payout.status === 'new') return null; // not ours, a repeat, or never sent
  if (payout.conversationId && result.ConversationID !== payout.conversationId) return null; // doesn't match what Safaricom gave us
  const open: Status[] = ['sending', 'accepted', 'unknown'];
  if (Number(result.ResultCode) !== 0) {
    return (await claim(payout.id, open, { status: 'failed', reason: `${result.ResultCode} ${String(result.ResultDesc ?? '')}`.slice(0, 190) })) ? 'failed' : null;
  }
  const list = result.ResultParameters?.ResultParameter ?? [];
  const params = new Map((Array.isArray(list) ? list : [list]).map((p) => [p.Key, p.Value]));
  const receipt = String(params.get('TransactionReceipt') ?? result.TransactionID ?? '');
  const to: Status = Number(params.get('TransactionAmount')) === payout.amount ? 'paid' : 'review'; // a different amount: a person must look
  const fields: Partial<Payout> = { status: to, receipt, reason: to === 'review' ? `AMOUNT ${String(params.get('TransactionAmount'))}` : undefined };
  return (await claim(payout.id, open, fields)) ? to : null;
}

/** Handles the POST to your QueueTimeOutURL: the outcome is unknown, NOT failed. */
export async function handleTimeout(id: string): Promise<void> {
  await claim(id, ['sending', 'accepted'], { status: 'unknown', reason: 'QUEUE_TIMEOUT' });
}

review means M-Pesa reported a payment for a different amount than you sent. That should never happen, so a person should look at it.

The routes

Both kinds of route check a secret in constant time. Put the helper in its own file:

// lib/secrets.ts
import { timingSafeEqual } from 'node:crypto';

/** Constant-time comparison; refuses secrets shorter than `min`. */
export function sameSecret(given: string | null | undefined, secret: string | undefined, min = 16): boolean {
  if (!secret || secret.length < min) return false;
  const a = Buffer.from(given ?? '');
  const b = Buffer.from(secret);
  return a.length === b.length && timingSafeEqual(a, b);
}

The admin route creates and sends a payout. In a real app, call createPayout and sendPayout from your own admin pages after your normal login check, and record who did it; the token stands in for that.

// app/api/admin/b2c-payouts/route.ts
import { createPayout, sendPayout } from '@/lib/b2c-payouts';
import { sameSecret } from '@/lib/secrets';

export async function POST(request: Request): Promise<Response> {
  const auth = request.headers.get('authorization') ?? '';
  if (!sameSecret(auth.replace(/^Bearer /, ''), process.env.PAYOUT_ADMIN_TOKEN)) return new Response('Unauthorized', { status: 401 });
  const body = await request.json().catch(() => null);
  try {
    const payout = await createPayout({ amount: body?.amount, phone: body?.phone, remarks: body?.remarks, createdBy: 'admin-token' });
    const sent = await sendPayout(payout.id);
    return Response.json({ id: payout.id, status: sent?.status, reason: sent?.reason ?? null }, { status: 202 });
  } catch (err) {
    if (err instanceof RangeError) return Response.json({ error: err.message }, { status: 422 });
    throw err;
  }
}

Anyone who finds your ResultURL can post to it. Three checks stop a fake result from changing a payout: the secret in the URL (only Safaricom gets the full URL), the ConversationID Safaricom returned and you saved, and the amount.

// app/api/webhooks/b2c/[secret]/result/route.ts
import { handleResult } from '@/lib/b2c-payouts';
import { sameSecret } from '@/lib/secrets';

export async function POST(request: Request, { params }: { params: Promise<{ secret: string }> }): Promise<Response> {
  if (!sameSecret((await params).secret, process.env.MPESA_B2C_CALLBACK_SECRET, 32)) return new Response(null, { status: 404 });
  const raw = await request.text();
  try {
    const changed = await handleResult(JSON.parse(raw));
    if (changed === 'paid') console.info('B2C payout paid', raw.length);
  } catch (err) {
    console.error('B2C result not saved', raw, (err as Error).message); // keep the full result: it holds the receipt
    return new Response(null, { status: 500 });
  }
  return Response.json({ ResultCode: 0, ResultDesc: 'Success' });
}
// app/api/webhooks/b2c/[secret]/timeout/[id]/route.ts
import { handleTimeout } from '@/lib/b2c-payouts';
import { sameSecret } from '@/lib/secrets';

export async function POST(_request: Request, { params }: { params: Promise<{ secret: string; id: string }> }): Promise<Response> {
  const { secret, id } = await params;
  if (!sameSecret(secret, process.env.MPESA_B2C_CALLBACK_SECRET, 32)) return new Response(null, { status: 404 });
  await handleTimeout(id);
  return Response.json({ ResultCode: 0, ResultDesc: 'Success' });
}

The result route saves before acknowledging. If saving fails, it answers 500 and logs the full result, which holds the receipt number, so you can settle the payout by hand.

A payout ends up unknown when you got no acknowledgement or Safaricom called your QueueTimeOutURL. Most still get a result later, and the result route settles them. For the rest, check your M-Pesa statement or the Transaction Status API (described in the Node.js guide) before marking anything failed.

Test it without calling Safaricom

The test uses a throwaway certificate, so it also proves the SecurityCredential decrypts back to the password. Make one with OpenSSL, then run the test:

npm install --save-dev tsx
openssl req -x509 -newkey rsa:2048 -nodes -keyout tests/b2c-test.key -out tests/b2c-test.cer -days 2 -subj "/CN=b2c-test"
npx tsx --test tests/b2c.test.ts
// tests/b2c.test.ts
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { constants, privateDecrypt } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { POST as admin } from '@/app/api/admin/b2c-payouts/route';
import { POST as resultRoute } from '@/app/api/webhooks/b2c/[secret]/result/route';
import { POST as timeoutRoute } from '@/app/api/webhooks/b2c/[secret]/timeout/[id]/route';
import { createPayout, findPayout, sendPayout } from '@/lib/b2c-payouts';

const SECRET = 'callback-secret-for-tests-0123456789';
Object.assign(process.env, {
  MPESA_B2C_SHORTCODE: '600000', MPESA_INITIATOR_NAME: 'testapi', MPESA_INITIATOR_PASSWORD: 'Safaricom999!*!', MPESA_CERT_PATH: 'tests/b2c-test.cer',
  MPESA_B2C_CALLBACK_SECRET: SECRET, PUBLIC_URL: 'https://shop.test', PAYOUT_ADMIN_TOKEN: 'admin-token-for-tests', PAYOUT_MAX_AMOUNT: '50000', PAYOUT_DAILY_LIMIT: '10000000',
});

let mode: 'ok' | 'refused' | 'lost' = 'ok';
let sent: Record<string, string | number>[] = [];

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' });
  if (path === '/mpesa/b2c/v3/paymentrequest') {
    const body = JSON.parse(String(init?.body));
    sent.push(body);
    if (mode === 'lost') throw new TypeError('fetch failed');
    if (mode === 'refused') return Response.json({ errorCode: '400.002.02', errorMessage: 'Bad Request - Invalid PartyB' }, { status: 400 });
    return Response.json({ ConversationID: `AG_${sent.length}`, OriginatorConversationID: body.OriginatorConversationID, ResponseCode: '0' });
  }
  throw new Error(`unexpected ${path}`);
}) as typeof fetch;

const pay = (body: unknown, token = 'admin-token-for-tests') =>
  admin(new Request('http://localhost/api/admin/b2c-payouts', { method: 'POST', body: JSON.stringify(body), headers: { authorization: `Bearer ${token}` } }));

function result(id: string, { code = 0, conversationId = 'AG_1', amount = 1500, secret = SECRET } = {}) {
  const Result = {
    ResultType: 0, ResultCode: code, ResultDesc: code ? 'The initiator information is invalid.' : 'The service request is processed successfully.',
    OriginatorConversationID: id, ConversationID: conversationId, TransactionID: 'NLJ41HAY6Q',
    ResultParameters: { ResultParameter: [{ Key: 'TransactionAmount', Value: amount }, { Key: 'TransactionReceipt', Value: 'NLJ41HAY6Q' }] },
  };
  return resultRoute(new Request('https://shop.test/r', { method: 'POST', body: JSON.stringify({ Result }) }), { params: Promise.resolve({ secret }) });
}

beforeEach(() => {
  mode = 'ok';
  sent = [];
});

test('the request carries a SecurityCredential that decrypts to the initiator password, and secret callback URLs', async () => {
  const { id, status } = await (await pay({ amount: 1500, phone: '254708374149', remarks: 'Refund 1042' })).json();
  assert.equal(status, 'accepted');
  const body = sent[0];
  const password = privateDecrypt({ key: readFileSync('tests/b2c-test.key'), padding: constants.RSA_PKCS1_PADDING }, Buffer.from(String(body.SecurityCredential), 'base64'));
  assert.equal(password.toString(), 'Safaricom999!*!');
  assert.deepEqual([body.OriginatorConversationID, body.Amount, body.PartyA, body.PartyB, body.CommandID], [id, 1500, '600000', '254708374149', 'BusinessPayment']);
  assert.equal(body.ResultURL, `https://shop.test/api/webhooks/b2c/${SECRET}/result`);
  assert.equal(body.QueueTimeOutURL, `https://shop.test/api/webhooks/b2c/${SECRET}/timeout/${id}`);
});

test('a matching result pays once; later results change nothing', async () => {
  const { id } = await (await pay({ amount: 1500, phone: '254708374149' })).json();
  assert.equal((await result(id)).status, 200);
  assert.deepEqual([(await findPayout(id))?.status, (await findPayout(id))?.receipt], ['paid', 'NLJ41HAY6Q']);
  await result(id, { code: 2001 });
  assert.equal((await findPayout(id))?.status, 'paid');
});

test('forged results change nothing: wrong secret, wrong ConversationID, unknown id', async () => {
  const { id } = await (await pay({ amount: 1500, phone: '254708374149' })).json();
  assert.equal((await result(id, { secret: 'wrong-secret-of-the-same-length-0000' })).status, 404);
  await result(id, { conversationId: 'AG_forged' });
  await result('not-a-payout-of-ours');
  assert.equal((await findPayout(id))?.status, 'accepted');
});

test('a different amount goes to review; a failure code fails', async () => {
  const a = (await (await pay({ amount: 1500, phone: '254708374149' })).json()).id;
  await result(a, { amount: 15000 });
  assert.equal((await findPayout(a))?.status, 'review');
  const b = (await (await pay({ amount: 1500, phone: '254708374149' })).json()).id;
  await result(b, { code: 2001, conversationId: 'AG_2' }); // the second request this test sent
  assert.equal((await findPayout(b))?.status, 'failed');
});

test('two sends at once call Safaricom once; a lost acknowledgement is never resent', async () => {
  const p = await createPayout({ amount: 100, phone: '254708374149', createdBy: 'test' });
  await Promise.all([sendPayout(p.id), sendPayout(p.id), sendPayout(p.id)]);
  assert.equal(sent.length, 1);
  mode = 'lost';
  const q = await createPayout({ amount: 100, phone: '254708374149', createdBy: 'test' });
  assert.equal((await sendPayout(q.id))?.status, 'unknown');
  await sendPayout(q.id);
  assert.equal(sent.length, 2);
  mode = 'ok';
  await result(q.id, { amount: 100 }); // the result still settles it, with no ConversationID saved
  assert.equal((await findPayout(q.id))?.status, 'paid');
});

test('a queue timeout means unknown, not failed; a later result still wins', async () => {
  const { id } = await (await pay({ amount: 1500, phone: '254708374149' })).json();
  const res = await timeoutRoute(new Request('https://shop.test/t', { method: 'POST' }), { params: Promise.resolve({ secret: SECRET, id }) });
  assert.equal(res.status, 200);
  assert.equal((await findPayout(id))?.status, 'unknown');
  await result(id);
  assert.equal((await findPayout(id))?.status, 'paid');
});

test('a refusal fails; the admin route needs the token and enforces limits', async () => {
  mode = 'refused';
  const { status, reason } = await (await pay({ amount: 1500, phone: '254708374149' })).json();
  assert.deepEqual([status, reason], ['failed', '400.002.02']);
  mode = 'ok';
  assert.equal((await pay({ amount: 1500, phone: '254708374149' }, 'wrong-token-of-the-same')).status, 401);
  assert.equal((await pay({ amount: 60000, phone: '254708374149' })).status, 422);
  assert.equal((await pay({ amount: 10.5, phone: '254708374149' })).status, 422);
  assert.equal((await pay({ amount: 100, phone: '0708374149' })).status, 422);
  assert.equal(sent.length, 1);
});

Common errors and fixes

These codes are reported by integration guides rather than Safaricom’s public pages, so treat the descriptions as likely causes.

Code Reported meaning Fix
2001 Initiator information is invalid Check the initiator name and password, and that the credential was encrypted with the certificate for this environment
17 PartyA is not permitted to make this transaction Confirm B2C is enabled for your shortcode and the CommandID you use
18 Invalid receiver Check PartyB is 2547… or 2541…
2033 Duplicate OriginatorConversationID Expected if the same ID was sent twice. The first request stands; never resend with a new ID
ENOENT reading the certificate on Vercel The file isn’t in the deployment Commit the public certificate and point MPESA_CERT_PATH at it, or use MPESA_CERT_PEM
Route segment config "runtime" is not compatible with nextConfig.cacheComponents export const runtime in a Next.js 16 app Delete it: node:crypto needs the default Node.js runtime anyway

Going to production

  • Apply for go-live with your B2C shortcode. Safaricom sets up the production initiator during go-live. Use the production certificate and MPESA_ENV=production.
  • Replace the Map in lib/b2c-payouts.ts with your database: id unique, claim as a conditional update.
  • Replace the admin token with your real login, and record who created each payout.
  • Set PAYOUT_MAX_AMOUNT and PAYOUT_DAILY_LIMIT to what your business needs.
  • Alert on payouts that stay accepted or unknown for more than an hour, and reconcile daily against your M-Pesa statement.

Collecting payments too? See M-Pesa STK push in Next.js. Paying MTN wallets? See MTN MoMo Disbursements in Next.js. Want payouts already built with an audit trail and admin view? The Next.js starter kit has them.

Frequently asked questions

How do I generate the M-Pesa SecurityCredential in Next.js?

On the server only: encrypt the initiator password with the public key in Safaricom's certificate using RSA with PKCS#1 v1.5 padding (crypto.publicEncrypt with constants.RSA_PKCS1_PADDING), then base64-encode it. It needs node:crypto, so keep the route on the default Node.js runtime.

Where do I keep the Safaricom certificate on Vercel?

Either commit the public certificate file (it is public) and point MPESA_CERT_PATH at it, or paste its PEM text into the MPESA_CERT_PEM environment variable. This guide reads either. Never commit the initiator password.

Is ResponseCode 0 from the B2C request a completed payment?

No. It only means Safaricom accepted the request. The payment is complete only when your ResultURL receives a Result with ResultCode 0 and a TransactionReceipt.

My B2C request timed out. Should I send it again?

No. A timeout or a call to your QueueTimeOutURL does not mean the money stayed put. Mark the payout unknown and confirm it with your M-Pesa statement or the Transaction Status API before deciding anything.

Need this built for you?

I integrate MTN MoMo, M-Pesa and Airtel Money into Laravel / PHP, Python / Django, Node.js / Next.js apps: payments, callbacks, payouts and go-live, with tests.