This guide sends money from your M-Pesa B2C shortcode to customers’ M-Pesa wallets with the Daraja B2C API. Use it for refunds, payouts to sellers or drivers, salaries and promotions. You’ll build:
- an
mpesa-b2c.jsclient: a token, the SecurityCredential and the payment request, - a
payouts.jsmodule that never sends the same payout twice, - ResultURL and QueueTimeOutURL handlers that only accept results that match a payout you sent,
- a test that runs every case without calling Safaricom.
It uses Node’s built-in fetch and crypto (Node 18+). Every code block was run in a fresh project, and the test at the end passes. Facts were checked in October 2026. Safaricom’s own Daraja pages need a login, so the API details here come from published integrations; points where they disagree are flagged.
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 |
| Status check | STK Push Query | Transaction Status API (also asynchronous) |
| Code 2001 | Wrong PIN | Invalid initiator information |
The first row is the one that matters. With STK push, a duplicate request only sends a second prompt that the customer can ignore. With B2C, a duplicate request sends the money twice.
Before you start
- On the Daraja portal, create a sandbox app and copy its consumer key and secret. The portal’s sandbox test credentials include a shortcode, an initiator name and an initiator password for B2C.
- Download Safaricom’s sandbox certificate from the Daraja documentation. In production you’ll use the production certificate instead.
- Create a
.envfile (load it withnode --env-file=.env) and never commit it:
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
MPESA_B2C_CALLBACK_BASE=https://your-tunnel.example.com/webhooks/b2c/paste-the-same-random-string
MPESA_B2C_COMMAND is one of BusinessPayment, SalaryPayment or PromotionPayment. Generate the callback secret with node -e "console.log(crypto.randomBytes(24).toString('base64url'))". As with STK push, keep “mpesa” and “safaricom” out of your callback URLs; developers report that Safaricom drops callbacks to URLs containing them.
The B2C client
Two details trip people up:
- SecurityCredential is your initiator password encrypted with the public key in Safaricom’s certificate, using RSA with PKCS#1 v1.5 padding, then base64-encoded. A credential made with the sandbox certificate fails in production, and the reverse.
- ResponseCode
0is not a payment. It means Safaricom accepted the request. The result arrives later at yourResultURL.
// mpesa-b2c.js
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 {
constructor(message, { status = 0, ambiguous = false, code = null } = {}) {
super(message);
this.status = status;
this.ambiguous = ambiguous; // true: the payout may have gone through. Never resend it.
this.code = code;
}
}
async function call(path, init = {}) {
try {
return await fetch(`${baseUrl()}${path}`, { ...init, signal: AbortSignal.timeout(30_000) });
} catch (err) {
throw new MpesaError(`Daraja unreachable: ${err.message}`, { ambiguous: true });
}
}
let cached = { token: null, expiresAt: 0 };
export async function getToken() {
if (cached.token && Date.now() < cached.expiresAt - 60_000) return cached.token;
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}` } });
if (!res.ok) throw new MpesaError(`Daraja token failed: ${res.status}`, { status: res.status });
const data = await res.json();
cached = { token: data.access_token, expiresAt: Date.now() + Number(data.expires_in ?? 3599) * 1000 };
return cached.token;
}
/**
* 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(initiatorPassword, certificate = readFileSync(env.MPESA_CERT_PATH)) {
const { publicKey } = new X509Certificate(certificate);
return publicEncrypt({ key: publicKey, padding: constants.RSA_PKCS1_PADDING }, Buffer.from(initiatorPassword)).toString('base64');
}
let credential = null;
/** Sends one B2C payment request. Returns Safaricom's acknowledgement, NOT the result. */
export async function b2cPayment({ id, amount, phone, remarks, occasion = '' }) {
credential ??= securityCredential(env.MPESA_INITIATOR_PASSWORD);
const res = await call('/mpesa/b2c/v3/paymentrequest', {
method: 'POST',
headers: { Authorization: `Bearer ${await getToken()}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
OriginatorConversationID: id, // your own ID, saved before this call
InitiatorName: env.MPESA_INITIATOR_NAME,
SecurityCredential: credential,
CommandID: env.MPESA_B2C_COMMAND ?? 'BusinessPayment',
Amount: amount, // whole shillings
PartyA: env.MPESA_B2C_SHORTCODE,
PartyB: phone, // 2547XXXXXXXX or 2541XXXXXXXX
Remarks: String(remarks).slice(0, 100),
QueueTimeOutURL: `${env.MPESA_B2C_CALLBACK_BASE}/timeout/${encodeURIComponent(id)}`,
ResultURL: `${env.MPESA_B2C_CALLBACK_BASE}/result`,
Occasion: String(occasion).slice(0, 100),
}),
});
const body = await res.json().catch(() => null);
if (res.ok && String(body?.ResponseCode) === '0') return body; // { ConversationID, OriginatorConversationID, ... }
throw new MpesaError(`B2C request failed: ${res.status} ${body?.errorCode ?? body?.ResponseCode ?? ''}`.trim(), {
status: res.status,
ambiguous: res.status >= 500 || !body,
code: body?.errorCode ?? body?.ResponseCode ?? null,
});
}
The client throws an MpesaError that says whether the failure is ambiguous. A network error, a timeout or a 5xx means the request may have reached M-Pesa, so the payout may be on its way. A 4xx with an errorCode is a clear refusal: nothing was sent.
v1 or v3? Published integrations use two paths. Newer ones use /mpesa/b2c/v3/paymentrequest with an OriginatorConversationID you choose; older ones use /mpesa/b2c/v1/paymentrequest 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 from the acknowledgement instead.
Never pay twice
A payout goes through these states:
new ──send──▶ sending ──ResponseCode 0──▶ accepted ──Result 0──▶ paid
│ │ └──Result ≠ 0──▶ failed
├──4xx (clear refusal)──▶ failed
└──timeout / 5xx──▶ unknown ◀──QueueTimeOutURL──┘
└──Result arrives later──▶ paid / failed
Four rules keep it safe:
- 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, even if your server crashed mid-request. - Never send from any state except
new. A payout that issending,acceptedorunknownmay already be on its way. - Never resend automatically. Unlike MTN MoMo, Daraja has no “this request doesn’t exist” answer you can rely on, so there is no safe automatic retry. An
unknownpayout waits for its result, or for a person to confirm it (see below). - A failed payout stays failed. To try again, create a new payout deliberately, after checking why the first one failed.
// payouts.js
import { randomUUID } from 'node:crypto';
import { b2cPayment, MpesaError } from './mpesa-b2c.js';
const OPEN = ['sending', 'accepted', 'unknown'];
/** Create the payout record. Its id is the OriginatorConversationID, fixed for life. */
export function newPayout({ amount, phone, remarks }) {
if (!Number.isInteger(amount) || amount <= 0) throw new Error('B2C amounts are whole shillings');
if (!/^254[17]\d{8}$/.test(phone)) throw new Error('Phone must look like 2547XXXXXXXX or 2541XXXXXXXX');
return { id: randomUUID(), amount, phone, remarks, status: 'new', conversationId: null, receipt: null, resultCode: null, reason: null };
}
/** Sends a payout once. save(payout) must persist the record. Only "new" is ever sent. */
export async function sendPayout(payout, { save, api = { b2cPayment } }) {
if (payout.status !== 'new') return payout; // sent, or maybe sent: never again
await save({ ...payout, status: 'sending' }); // saved BEFORE the network call
try {
const ack = await api.b2cPayment(payout);
return save({ ...payout, status: 'accepted', conversationId: ack.ConversationID });
} catch (err) {
if (err instanceof MpesaError && !err.ambiguous) {
return save({ ...payout, status: 'failed', reason: String(err.code ?? `HTTP_${err.status}`) });
}
return save({ ...payout, status: 'unknown', reason: 'NO_ACKNOWLEDGEMENT' }); // may have gone through
}
}
function resultParams(result) {
let list = result?.ResultParameters?.ResultParameter ?? [];
if (!Array.isArray(list)) list = [list];
return Object.fromEntries(list.map((p) => [p.Key, p.Value]));
}
/**
* Handles the POST to your ResultURL. find(id) loads a payout by its id.
* Returns the payout after the update, or null when the result is ignored.
*/
export async function handleResult(body, { find, save }) {
const result = body?.Result;
if (!result?.OriginatorConversationID) return null;
const payout = await find(String(result.OriginatorConversationID));
if (!payout) return null; // not ours
if (payout.conversationId && result.ConversationID !== payout.conversationId) return null; // doesn't match what Safaricom gave us
if (!OPEN.includes(payout.status)) return payout; // already settled: a repeat
const code = Number(result.ResultCode);
if (code !== 0) {
return save({ ...payout, status: 'failed', resultCode: code, reason: String(result.ResultDesc ?? '') });
}
const params = resultParams(result);
const receipt = params.TransactionReceipt ?? result.TransactionID ?? null;
if (Number(params.TransactionAmount) !== payout.amount) {
return save({ ...payout, status: 'review', resultCode: 0, receipt, reason: `AMOUNT_MISMATCH ${params.TransactionAmount}` });
}
return save({ ...payout, status: 'paid', resultCode: 0, receipt, reason: null });
}
/** Handles the POST to your QueueTimeOutURL: the outcome is unknown, NOT failed. */
export async function handleTimeout(id, { find, save }) {
const payout = id ? await find(String(id)) : null;
if (!payout || !['sending', 'accepted'].includes(payout.status)) return payout ?? null;
return save({ ...payout, status: 'unknown', reason: 'QUEUE_TIMEOUT' });
}
Your save function should update the row by id and refuse to change a payout that is paid, failed or review, for example with WHERE status NOT IN ('paid', 'failed', 'review'). Then a late timeout, a repeated result or a slow acknowledgement can never undo a final result.
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.
Receive results safely
Anyone who finds your ResultURL can post to it. Three checks stop a fake result from changing a payout:
- A secret in the URL. Only Safaricom knows the full URL, because you only send it to them. Requests without the secret get a
404. - The
ConversationIDmust match. Safaricom returned it in the acknowledgement, and you saved it. A forged result doesn’t know it. - The amount must match.
handleResultcomparesTransactionAmountwith the payout.
// server.js (Express)
import express from 'express';
import { timingSafeEqual } from 'node:crypto';
import { handleResult, handleTimeout } from './payouts.js';
import { find, save } from './db.js'; // your database: find(id) and save(payout)
const secret = process.env.MPESA_B2C_CALLBACK_SECRET ?? '';
if (secret.length < 32) throw new Error('Set MPESA_B2C_CALLBACK_SECRET to a random string of 32+ characters');
const app = express();
app.use(express.json());
// MPESA_B2C_CALLBACK_BASE is https://yourdomain.com/webhooks/b2c/<MPESA_B2C_CALLBACK_SECRET>
function secretOk(given) {
const a = Buffer.from(String(given));
const b = Buffer.from(secret);
return a.length === b.length && timingSafeEqual(a, b);
}
app.post('/webhooks/b2c/:secret/result', async (req, res) => {
if (!secretOk(req.params.secret)) return res.sendStatus(404);
try {
await handleResult(req.body, { find, save });
res.json({ ResultCode: 0, ResultDesc: 'Success' });
} catch (err) {
console.error('B2C result not saved', JSON.stringify(req.body), err); // keep the full result: it holds the receipt
res.sendStatus(500);
}
});
app.post('/webhooks/b2c/:secret/timeout/:id', async (req, res) => {
if (!secretOk(req.params.secret)) return res.sendStatus(404);
try {
await handleTimeout(req.params.id, { find, save });
res.json({ ResultCode: 0, ResultDesc: 'Success' });
} catch (err) {
console.error('B2C timeout not saved', req.params.id, err);
res.sendStatus(500);
}
});
app.listen(3000);
The handler saves the result 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 successful result looks like this. The keys in ResultParameters are the same across published examples:
{
"Result": {
"ResultType": 0,
"ResultCode": 0,
"ResultDesc": "The service request is processed successfully.",
"OriginatorConversationID": "c1c55b1f-0d9e-4a4b-9f62-0b8f1c2b8a11",
"ConversationID": "AG_20191219_00004e48cf7e3533f581",
"TransactionID": "NLJ41HAY6Q",
"ResultParameters": {
"ResultParameter": [
{ "Key": "TransactionAmount", "Value": 10 },
{ "Key": "TransactionReceipt", "Value": "NLJ41HAY6Q" },
{ "Key": "B2CRecipientIsRegisteredCustomer", "Value": "Y" },
{ "Key": "B2CChargesPaidAccountAvailableFunds", "Value": -4510.0 },
{ "Key": "ReceiverPartyPublicName", "Value": "254708374149 - John Doe" },
{ "Key": "TransactionCompletedDateTime", "Value": "19.12.2019 11:45:50" },
{ "Key": "B2CUtilityAccountAvailableFunds", "Value": 10116.0 },
{ "Key": "B2CWorkingAccountAvailableFunds", "Value": 900000.0 }
]
}
}
}
B2CUtilityAccountAvailableFunds is the balance payouts are sent from. Log it with each result, and alert yourself before it runs low.
Settle unknown payouts
A payout ends up unknown when you got no acknowledgement or Safaricom called your QueueTimeOutURL. Most still get a result later, and handleResult settles them. For the rest, confirm before doing anything:
- Check your M-Pesa organisation portal statement for the amount and phone number.
- Or call the Transaction Status API:
POST /mpesa/transactionstatus/v1/query. Like B2C, it answers at aResultURL, withTransactionStatus,AmountandReceiptNoinResultParameters.
The Transaction Status request looks like this when you have the M-Pesa receipt number:
{
"Initiator": "your-initiator-name",
"SecurityCredential": "the same credential as B2C",
"CommandID": "TransactionStatusQuery",
"TransactionID": "NLJ41HAY6Q",
"PartyA": "your-b2c-shortcode",
"IdentifierType": "4",
"ResultURL": "https://yourdomain.com/webhooks/b2c/your-secret/status-result",
"QueueTimeOutURL": "https://yourdomain.com/webhooks/b2c/your-secret/status-timeout",
"Remarks": "Status check",
"Occasion": "Status check"
}
For an unknown payout you usually don’t have a receipt yet. Some SDKs send OriginalConversationID (your payout id) instead of TransactionID. That is documented by a single SDK, so test it in the sandbox before relying on it. IdentifierType 4 means an organisation shortcode in published tables, though one sample uses 1; check which your shortcode needs.
Only when you are sure the money did not move should you mark the payout failed and create a new one.
Test it without calling Safaricom
This test replaces the client with a fake Daraja. It checks that a payout is saved before it’s sent and sent only once, that a lost acknowledgement leads to unknown and never a second payment, that forged or unknown results are ignored, that a wrong amount goes to review, that a queue timeout isn’t treated as a failure, and that a result arriving before the acknowledgement still wins.
// payouts.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MpesaError } from './mpesa-b2c.js';
import { handleResult, handleTimeout, newPayout, sendPayout } from './payouts.js';
function makeDb() {
const rows = new Map();
return {
rows,
find: async (id) => rows.get(id) ?? null,
save: async (p) => {
const old = rows.get(p.id);
if (old && ['paid', 'failed', 'review'].includes(old.status)) return old; // final results never change
rows.set(p.id, p);
return p;
},
};
}
function fakeDaraja() {
const sent = [];
return {
sent,
async b2cPayment(p) {
sent.push(p.id);
return { OriginatorConversationID: p.id, ConversationID: `AG_${sent.length}`, ResponseCode: '0', ResponseDescription: 'Accept the service request successfully.' };
},
};
}
function resultBody(p, { code = 0, conversationId = p.conversationId, amount = p.amount, receipt = 'NLJ41HAY6Q' } = {}) {
const Result = { ResultType: 0, ResultCode: code, ResultDesc: code === 0 ? 'The service request is processed successfully.' : 'The initiator information is invalid.', OriginatorConversationID: p.id, ConversationID: conversationId, TransactionID: receipt };
if (code === 0) {
Result.ResultParameters = { ResultParameter: [
{ Key: 'TransactionAmount', Value: amount },
{ Key: 'TransactionReceipt', Value: receipt },
{ Key: 'ReceiverPartyPublicName', Value: '254708374149 - John Doe' },
] };
}
return { Result };
}
const payout = () => newPayout({ amount: 1500, phone: '254708374149', remarks: 'Seller payout 1' });
test('a payout is saved, sent once, and paid from its result', async () => {
const db = makeDb();
const api = fakeDaraja();
const real = api.b2cPayment.bind(api);
api.b2cPayment = async (p) => {
assert.equal(db.rows.get(p.id)?.status, 'sending'); // saved BEFORE the money moves
return real(p);
};
let p = await sendPayout(payout(), { ...db, api });
assert.deepEqual([p.status, p.conversationId], ['accepted', 'AG_1']); // accepted is NOT paid
assert.equal((await sendPayout(p, { ...db, api })).status, 'accepted'); // calling again sends nothing
assert.equal(api.sent.length, 1);
p = await handleResult(resultBody(p), db);
assert.deepEqual([p.status, p.receipt], ['paid', 'NLJ41HAY6Q']);
assert.equal((await handleResult(resultBody(p, { code: 2001 }), db)).status, 'paid'); // a later result can't undo it
});
test('no acknowledgement means unknown, never a second payment', async () => {
const db = makeDb();
const api = { sent: 0, async b2cPayment() { this.sent += 1; throw new MpesaError('Daraja unreachable: timeout', { ambiguous: true }); } };
let p = await sendPayout(payout(), { ...db, api });
assert.equal(p.status, 'unknown');
await sendPayout(p, { ...db, api });
assert.equal(api.sent, 1);
p = await handleResult(resultBody(p, { conversationId: 'AG_late' }), db); // the result can still settle it
assert.equal(p.status, 'paid');
});
test('a clear refusal fails at once', async () => {
const db = makeDb();
const api = { async b2cPayment() { throw new MpesaError('B2C request failed: 400 400.002.02', { status: 400, code: '400.002.02' }); } };
const p = await sendPayout(payout(), { ...db, api });
assert.deepEqual([p.status, p.reason], ['failed', '400.002.02']);
});
test('a failed result is final and keeps its code', async () => {
const db = makeDb();
let p = await sendPayout(payout(), { ...db, api: fakeDaraja() });
p = await handleResult(resultBody(p, { code: 2001 }), db);
assert.deepEqual([p.status, p.resultCode], ['failed', 2001]);
assert.equal((await handleResult(resultBody(p), db)).status, 'failed');
});
test('results that are not ours are ignored', async () => {
const db = makeDb();
const p = await sendPayout(payout(), { ...db, api: fakeDaraja() });
assert.equal(await handleResult(resultBody(p, { conversationId: 'AG_forged' }), db), null);
assert.equal(await handleResult(resultBody({ ...p, id: 'not-a-payout' }), db), null);
assert.equal(await handleResult({ hello: 'world' }, db), null);
assert.equal(db.rows.get(p.id).status, 'accepted');
});
test('a paid result for the wrong amount goes to review', async () => {
const db = makeDb();
let p = await sendPayout(payout(), { ...db, api: fakeDaraja() });
p = await handleResult(resultBody(p, { amount: 15 }), db);
assert.equal(p.status, 'review');
});
test('a queue timeout is unknown, not failed', async () => {
const db = makeDb();
let p = await sendPayout(payout(), { ...db, api: fakeDaraja() });
p = await handleTimeout(p.id, db);
assert.deepEqual([p.status, p.reason], ['unknown', 'QUEUE_TIMEOUT']);
p = await handleResult(resultBody(p), db);
assert.equal(p.status, 'paid');
assert.equal((await handleTimeout(p.id, db)).status, 'paid'); // a late timeout can't undo it
});
test('a result that arrives before the acknowledgement is saved still wins', async () => {
const db = makeDb();
const api = {
async b2cPayment(p) {
await handleResult(resultBody({ ...p, conversationId: 'AG_fast' }), db); // Safaricom was quick
return { ConversationID: 'AG_fast', OriginatorConversationID: p.id, ResponseCode: '0' };
},
};
const p = await sendPayout(payout(), { ...db, api });
assert.equal(p.status, 'paid');
assert.equal(db.rows.get(p.id).status, 'paid');
});
test('only whole shillings and Kenyan numbers are accepted', () => {
assert.throws(() => newPayout({ amount: 100.5, phone: '254708374149', remarks: 'x' }), /whole shillings/);
assert.throws(() => newPayout({ amount: 100, phone: '0708374149', remarks: 'x' }), /2547/);
assert.match(payout().id, /^[0-9a-f-]{36}$/);
});
Run it with node --test. The project needs "type": "module" in package.json (npm pkg set type=module).
Test against the sandbox
With the tests passing, send a small amount from the sandbox B2C shortcode to the test number on the Daraja test credentials page. Expose your server with a tunnel and watch the result arrive. Then try the failure paths: a wrong initiator password should produce 2001, and stopping your server before the result arrives should leave the payout accepted, ready to settle as described in Settle unknown payouts.
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 a valid Kenyan M-Pesa number in 2547… or 2541… format |
4041 |
Recipient is not a registered M-Pesa customer (seen with SalaryPayment) |
Confirm the number; some CommandIDs only pay registered customers |
26 |
System busy or traffic blocked | Leave the payout accepted or unknown and wait for the result; don’t resend |
2033 |
Duplicate OriginatorConversationID (reported for repeats within about a minute) | Expected if you sent the same ID twice. The first request stands; never resend with a new ID |
HTTP 400 with an errorCode |
A field is invalid: amount, phone format or a missing URL | Amounts are whole shillings; phones are 2547… or 2541… |
Every STK push ResultCode is explained in the M-Pesa error code reference. Note that B2C reuses some numbers with different meanings.
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 set
MPESA_ENV=production. - Store payouts in your database with
idas a unique key, and makesaverefuse to change apaid,failedorreviewpayout. - Keep payout sends to one process at a time so two runs can’t pick up the same
newpayout. - Protect the code that creates payouts. Anyone who can call it can send your money. Require an authenticated admin, set limits per payout and per day, and log who created each one.
- Alert on payouts that stay
acceptedorunknownfor more than an hour, and settle them from your statement or the Transaction Status API. - Reconcile daily against your M-Pesa statement.
Collecting payments too? See M-Pesa STK push in Node.js. Paying MTN wallets? See MTN MoMo Disbursements in Node.js.
Frequently asked questions
Is ResponseCode 0 from the B2C request a completed payment?
No. ResponseCode 0 only means Safaricom accepted the request for processing. The payment is complete only when your ResultURL receives a Result with ResultCode 0 and a TransactionReceipt.
How do I generate the M-Pesa SecurityCredential in Node.js?
Encrypt the initiator password with the public key in Safaricom's certificate using RSA with PKCS#1 v1.5 padding, then base64-encode it. In Node.js that is crypto.publicEncrypt with constants.RSA_PKCS1_PADDING. Use the sandbox certificate in the sandbox and the production certificate in production.
What does ResultCode 2001 mean for B2C?
For B2C, 2001 is reported as invalid initiator information: a wrong initiator name or password, or a credential encrypted with the wrong certificate. It is not the wrong-PIN error it means for STK push.
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 the Transaction Status API or your M-Pesa statement before deciding anything.
Do I need go-live approval for B2C?
Yes. B2C runs on a separate B2C shortcode, and the production initiator name and password are set up during go-live. Sandbox credentials only work against the sandbox.