DEV Community

Cover image for M-Pesa B2B API Integration Guide (TypeScript): Build, Run, and Understand It End-to-End
Eddy Odhiambo
Eddy Odhiambo

Posted on

M-Pesa B2B API Integration Guide (TypeScript): Build, Run, and Understand It End-to-End

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 ResultURL and QueueTimeOutURL
  • 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.

API mermaid ilustration image

Important: Your source of truth is the callback payload sent back to (ResultURL / QueueTimeOutURL), not only the initial POST response.


2) Prerequisites

  1. A Daraja app with consumer key , consumer secret and b2b api enabled. You can create and enable these on daraja portal.
  2. A working Paybill from Safaricom (Use test credentials for sandbox)
  3. 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.
  4. Public callback URLs for local development (for example, ngrok)
  5. 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

  1. Start your public tunnel:
    • ngrok example: ngrok http 5000
  2. Put the HTTPS tunnel URL in PUBLIC_BASE_URL inside .env
  3. Start app:
    • npm run dev
  4. Trigger payment:
    • curl -X POST http://localhost:5000/send-b2b
  5. 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 ConversationID and OriginatorConversationID for 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)