A practical, beginner-friendly walkthrough for sending M-Pesa B2B payments from your M-Pesa Paybill/Till to another business's Paybill/Till Accounts through Node.js and Express
What You Will Build
By the end of this guide, you will have:
- A minimal TypeScript app that requests a Daraja OAuth token
- A B2B payment request (
BusinessPayBill) sender - Local webhook endpoints for
ResultURLandQueueTimeOutURL - A simple correlation store so you can track request-to-callback flow
This guide intentionally uses a simple standalone structure so you can run it without any custom internal framework.
1) How M-Pesa B2B API Works (Mental Model)
The M-Pesa B2B API is asynchronous (you do not have to wait for response and can proceed to other taks). The first API response you will receive confirms acceptance for processing, not final settlement of the transaction.
Important: Your source of truth is the callback payload sent back to (ResultURL / QueueTimeOutURL), not only the initial POST response.
2) Prerequisites
- A Daraja app with
consumer key,consumer secretand b2b api enabled. You can create and enable these on daraja portal. - A working Paybill from Safaricom (Use test credentials for sandbox)
- Initiator name + security credential (or initiator password if you are generating credential externally) - this is the user set in the mpesa org portal with businessAPIOrgInitiator role enabled for the user.
- Public callback URLs for local development (for example, ngrok)
- Node.js 18+ installed
2.1) Setting up the initiator credentials & certificates for encryption
We have already determined that you need the initiator credentials for you to make B2B payment requests through the API. An initiator is like the user authorized to make transactions on the paybill/till.
To get these credentials, you will have to login to the Mpesa Org Portal with the login credentials you received from Safaricom Mpesa via email and create a new user with the following role:
`BusinessAPIOrgInitiator' - this roles allows the created user to make API requests on the paybill/till i.e. the B2B API requests.
The username for this user becomes the initiator name and password for this user is what will be encrypted to get the securityCredential.
Now onto the security credential encryption, please note that this is not the initiator password itself but an encrypted version of it. To encrypt you must:
Download Safaricom public certificate from the Daraja API documentation page. Here is the link - Getting Started
Encrypt your initiator password using it. The steps for encrytption are on the API Documentation page
- If you're testing you can use sandbox provided credential in the simulator
- In production you will have to generate it using OpenSSL
3) Minimal Project Setup
Go to an empty directory and run the following to create an app directory and setup express and typescript:
shell
mkdir mpesa-b2b-starter
cd mpesa-b2b-starter
npm init -y
npm install axios express dotenv
npm install -D typescript ts-node @types/node @types/express
npx tsc --init
Update your package.json scripts:
json
{
"scripts": {
"dev": "ts-node src/mpesa-b2b.ts"
}
}
Create your environment file and the main file (always remember not to deploy these to GitHub - add the .env to gitignore).
src/mpesa-b2b.ts.env
4) Environment Variables Explained
You can use this .env template and just add your own values:
`env
Daraja credentials
MPESA_ENV=sandbox
MPESA_CONSUMER_KEY=your_consumer_key
MPESA_CONSUMER_SECRET=your_consumer_secret
B2B initiator setup
MPESA_INITIATOR_NAME=testapi (this is the test user for sandbox testing)
MPESA_SECURITY_CREDENTIAL=your_pre_encrypted_security_credential
Shortcodes and payment details
MPESA_PARTY_A=600000
MPESA_PARTY_B=600111
MPESA_AMOUNT=100
MPESA_ACCOUNT_REF=INV10001
MPESA_REMARKS=Sandbox B2B test payment
MPESA_REQUESTER=254700000000
Local webhook server
PORT=5000
PUBLIC_BASE_URL=https://your-ngrok-subdomain.ngrok.io
`
Field meanings
-
MPESA_PARTY_A: Sender shortcode/paybill (your business) -
MPESA_PARTY_B: Receiving shortcode/paybill (destination business) -
MPESA_SECURITY_CREDENTIAL: Encrypted initiator secret expected by Daraja. (for testing use value presented in daraja's simulator.) -
PUBLIC_BASE_URL: Must be publicly reachable for callbacks from Mpesa daraja
5) This is the main file that will create and send the B2B payment request:
Create src/mpesa-b2b.ts if not already:
`typescript
import "dotenv/config";
import axios from "axios";
import express, { Request, Response } from "express";
type PendingTx = {
createdAt: string;
amount: string;
partyA: string;
partyB: string;
accountReference: string;
};
const app = express();
app.use(express.json());
const pendingByOriginatorConversationId = new Map();
const env = process.env.MPESA_ENV === "production" ? "production" : "sandbox";
const baseUrl =
env === "production"
? "https://api.safaricom.co.ke"
: "https://sandbox.safaricom.co.ke";
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(Missing env var: ${name});
return value;
}
async function getAccessToken(): Promise {
const consumerKey = required("MPESA_CONSUMER_KEY");
const consumerSecret = required("MPESA_CONSUMER_SECRET");
const auth = Buffer.from(${consumerKey}:${consumerSecret}).toString("base64");
const response = await axios.get(
${baseUrl}/oauth/v1/generate?grant_type=client_credentials,
{
headers: { Authorization: Basic ${auth} },
timeout: 20000,
}
);
if (!response.data?.access_token) {
throw new Error("No access_token returned from Daraja OAuth endpoint.");
}
return response.data.access_token as string;
}
async function sendB2BPayment() {
const token = await getAccessToken();
const initiator = required("MPESA_INITIATOR_NAME");
const securityCredential = required("MPESA_SECURITY_CREDENTIAL");
const partyA = required("MPESA_PARTY_A");
const partyB = required("MPESA_PARTY_B");
const amount = required("MPESA_AMOUNT");
const accountReference = required("MPESA_ACCOUNT_REF").slice(0, 13);
const remarks = required("MPESA_REMARKS").slice(0, 100);
const requester = required("MPESA_REQUESTER");
const publicBaseUrl = required("PUBLIC_BASE_URL");
const resultUrl = ${publicBaseUrl}/callbacks/mpesa/b2b/result;
const timeoutUrl = ${publicBaseUrl}/callbacks/mpesa/b2b/timeout;
const payload = {
Initiator: initiator,
SecurityCredential: securityCredential,
CommandID: "BusinessPayBill",
SenderIdentifierType: "4",
RecieverIdentifierType: "4",
Amount: String(Math.floor(Number(amount))),
PartyA: partyA,
PartyB: partyB,
AccountReference: accountReference,
Requester: requester,
Remarks: remarks,
QueueTimeOutURL: timeoutUrl,
ResultURL: resultUrl,
};
const response = await axios.post(
${baseUrl}/mpesa/b2b/v1/paymentrequest,
payload,
{
headers: {
Authorization: Bearer ${token},
"Content-Type": "application/json",
},
timeout: 30000,
}
);
const originatorConversationId = response.data?.OriginatorConversationID as
| string
| undefined;
if (originatorConversationId) {
pendingByOriginatorConversationId.set(originatorConversationId, {
createdAt: new Date().toISOString(),
amount: payload.Amount,
partyA,
partyB,
accountReference,
});
}
return response.data;
}
app.post("/callbacks/mpesa/b2b/result", (req: Request, res: Response) => {
const body = req.body?.Result ?? req.body?.Body?.Result ?? req.body;
const originatorConversationId = body?.OriginatorConversationID as
| string
| undefined;
const resultCode = body?.ResultCode;
const resultDesc = body?.ResultDesc;
const pending = originatorConversationId
? pendingByOriginatorConversationId.get(originatorConversationId)
: undefined;
console.log("B2B Result callback:", {
originatorConversationId,
resultCode,
resultDesc,
pending,
body,
});
res.status(200).json({ ResultCode: "0", ResultDesc: "Accepted" });
});
app.post("/callbacks/mpesa/b2b/timeout", (req: Request, res: Response) => {
const body = req.body?.Result ?? req.body?.Body?.Result ?? req.body;
console.log("B2B Timeout callback:", body);
res.status(200).json({ ResultCode: "0", ResultDesc: "Accepted" });
});
app.post("/send-b2b", async (_req: Request, res: Response) => {
try {
const data = await sendB2BPayment();
res.status(200).json({ ok: true, data });
} catch (error: any) {
res.status(500).json({
ok: false,
message: error?.message ?? "Unknown error",
response: error?.response?.data ?? null,
});
}
});
const port = Number(process.env.PORT || 5000);
app.listen(port, () => {
console.log(Server running on http://localhost:${port});
console.log(Trigger B2B by POST http://localhost:${port}/send-b2b);
});
`
6) Run and Test
- Start your public tunnel:
- ngrok example:
ngrok http 5000
- ngrok example:
- Put the HTTPS tunnel URL in
PUBLIC_BASE_URLinside.env - Start app:
npm run dev
- Trigger payment:
curl -X POST http://localhost:5000/send-b2b
- Watch logs for:
- immediate request response (accept/reject)
- later callback payload on
/callbacks/mpesa/b2b/result
7) Common Errors (and What They Mean)
| Symptom | Most likely cause | Fix |
|---|---|---|
401 on token request |
Wrong consumer key/secret | Regenerate and update .env
|
Invalid SecurityCredential |
Credential does not match initiator/certificate | Regenerate credential correctly |
| Initial call succeeds but no callback | Callback URL unreachable | Verify public HTTPS URL and firewall |
ResponseCode: 0 but transfer not complete |
Async model misunderstood | Wait for result callback before marking success |
8) Production Hardening Checklist
- Store secrets in a vault/Secret Key Managers, not in files committed to git
- Make callback handlers idempotent (same callback may retry)
- Persist
ConversationIDandOriginatorConversationIDfor reconciliation - Redact sensitive fields from your logs
- Add retries with backoff only for safe transient failures
- Set system admin alerts on spikes in timeout/error result codes
You can get more detailed info from safaricom's portal on the link below


Top comments (0)