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 • Edited 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.
Below is the roles your user should have to be able to make B2B API requests:

Mpesa Roles

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:
`

   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
Enter fullscreen mode Exit fullscreen mode


`

Update your package.json scripts:

`

{
  "scripts": {
    "dev": "ts-node src/mpesa-b2b.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode


`

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:

`

# 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
Enter fullscreen mode Exit fullscreen mode


`

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 and add the following code:

`

// IMPORTANT: The M-Pesa API is ASYNCHRONOUS. The initial POST response only tells you if the request was accepted for processing. 
//The final settlement status comes via a callback (ResultURL) a few seconds later.
// ------------------------------------------

// Load environment variables from .env file
import "dotenv/config";

// Axios for HTTP requests to Daraja
import axios from "axios";

// Express for our local webhook server (receives callbacks)
import express, { Request, Response } from "express";

// --------------------------------------------------------------
// 1. TYPE DEFINITIONS
// --------------------------------------------------------------

// We store pending transactions in memory so we can match incoming callbacks with the original request details.
type PendingTx = {
  createdAt: string;      // When the payment request was sent
  amount: string;         // Amount requested (string for logging)
  partyA: string;         // Your shortcode (sender)
  partyB: string;         // Destination shortcode (receiver)
  accountReference: string; // Invoice or order reference
};

// --------------------------------------------------------------
// 2. SETUP EXPRESS SERVER
// --------------------------------------------------------------
const app = express();
app.use(express.json()); // Automatically parse JSON request bodies

// In-memory store: maps OriginatorConversationID -> PendingTx
// This allows us to correlate the callback with the original request.
const pendingByOriginatorConversationId = new Map<string, PendingTx>();

// Determine if we are in sandbox or production mode
const env = process.env.MPESA_ENV === "production" ? "production" : "sandbox";

// Daraja base URL changes depending on environment
const baseUrl =
  env === "production"
    ? "https://api.safaricom.co.ke"
    : "https://sandbox.safaricom.co.ke";

// --------------------------------------------------------------
// 3. HELPER FUNCTION: SAFELY READ ENVIRONMENT VARIABLES
// --------------------------------------------------------------
function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing env var: ${name}`);
  return value;
}

// --------------------------------------------------------------
// 4. OBTAIN OAUTH ACCESS TOKEN FROM DARAJA
// --------------------------------------------------------------
/**
 * Daraja requires a Bearer token for all API calls (except OAuth itself).
 * The token is obtained by sending your Consumer Key and Consumer Secret
 * as a Basic Auth header.
 *
 * Token expires after ~1 hour; in production you should cache and refresh it.
 */
async function getAccessToken(): Promise<string> {
  const consumerKey = required("MPESA_CONSUMER_KEY");
  const consumerSecret = required("MPESA_CONSUMER_SECRET");

  // Combine key and secret with a colon, then Base64 encode
  const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString("base64");

  // Make GET request to the OAuth endpoint
  const response = await axios.get(
    `${baseUrl}/oauth/v1/generate?grant_type=client_credentials`,
    {
      headers: { Authorization: `Basic ${auth}` },
      timeout: 20000, // 20 seconds timeout
    }
  );

  if (!response.data?.access_token) {
    throw new Error("No access_token returned from Daraja OAuth endpoint.");
  }

  return response.data.access_token as string;
}

// --------------------------------------------------------------
// 5. CORE B2B PAYMENT FUNCTION
// --------------------------------------------------------------
/**
 * Sends a B2B payment request to Daraja.
 * Steps:
 *   1. Get a fresh access token.
 *   2. Build the B2B request payload according to Daraja spec.
 *   3. POST to /mpesa/b2b/v1/paymentrequest.
 *   4. Store pending transaction details for later callback correlation.
 *   5. Return the immediate API response.
 */
async function sendB2BPayment() {
  // ---- Step 1: Get token ----
  const token = await getAccessToken();

  // ---- Step 2: Read required fields from .env ----
  const initiator = required("MPESA_INITIATOR_NAME");
  const securityCredential = required("MPESA_SECURITY_CREDENTIAL");
  const partyA = required("MPESA_PARTY_A");          // Your shortcode
  const partyB = required("MPESA_PARTY_B");          // Destination shortcode
  const amount = required("MPESA_AMOUNT");
  const accountReference = required("MPESA_ACCOUNT_REF").slice(0, 13); // Max 13 chars
  const remarks = required("MPESA_REMARKS").slice(0, 100);             // Max 100 chars
  const requester = required("MPESA_REQUESTER");     // Mobile number of person requesting
  const publicBaseUrl = required("PUBLIC_BASE_URL"); // e.g., https://abc123.ngrok.io

  // Build callback URLs that M-Pesa will call after processing
  const resultUrl = `${publicBaseUrl}/callbacks/mpesa/b2b/result`;
  const timeoutUrl = `${publicBaseUrl}/callbacks/mpesa/b2b/timeout`;

  // ---- Step 3: Construct the B2B payload ----
  // Field explanations:
  // - CommandID: "BusinessPayBill" means Paybill to Paybill.
  // - SenderIdentifierType: "4" = Shortcode (Paybill/Till)
  // - RecieverIdentifierType: "4" = Shortcode (note the typo in API spec "Reciever")
  // - SecurityCredential: Encrypted initiator password (NOT plain text)
  // - Initiator: Username of the B2B initiator (set in M-Pesa Org Portal)
  // - Requester: Optional mobile number for audit
  const payload = {
    Initiator: initiator,
    SecurityCredential: securityCredential,
    CommandID: "BusinessPayBill",
    SenderIdentifierType: "4",
    RecieverIdentifierType: "4",
    Amount: String(Math.floor(Number(amount))), // Ensure integer
    PartyA: partyA,
    PartyB: partyB,
    AccountReference: accountReference,
    Requester: requester,
    Remarks: remarks,
    QueueTimeOutURL: timeoutUrl,
    ResultURL: resultUrl,
  };

  // ---- Step 4: Send the request ----
  const response = await axios.post(
    `${baseUrl}/mpesa/b2b/v1/paymentrequest`,
    payload,
    {
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
      },
      timeout: 30000, // 30 seconds
    }
  );

  // ---- Step 5: Store pending transaction for callback correlation ----
  // The immediate response contains an OriginatorConversationID.
  // When the callback arrives, it will include the same ID, allowing us to
  // link the callback to the original request.
  const originatorConversationId = response.data?.OriginatorConversationID as
    | string
    | undefined;

  if (originatorConversationId) {
    pendingByOriginatorConversationId.set(originatorConversationId, {
      createdAt: new Date().toISOString(),
      amount: payload.Amount,
      partyA,
      partyB,
      accountReference,
    });
  }

  // Return the raw API response (for logging / debugging)
  return response.data;
}

// --------------------------------------------------------------
// 6. WEBHOOK: RESULT URL (FINAL TRANSACTION STATUS)
// --------------------------------------------------------------
/**
 * M-Pesa calls this endpoint after processing the B2B payment.
 * It contains the final ResultCode (0 = success, others = error).
 *
 * IMPORTANT: This is your source of truth for transaction settlement.
 * Do NOT rely only on the initial POST response.
 */
app.post("/callbacks/mpesa/b2b/result", (req: Request, res: Response) => {
  // Daraja sometimes nests the result inside Result or Body.Result.
  // This line tries all common locations.
  const body = req.body?.Result ?? req.body?.Body?.Result ?? req.body;

  const originatorConversationId = body?.OriginatorConversationID as
    | string
    | undefined;
  const resultCode = body?.ResultCode;   // "0" = success
  const resultDesc = body?.ResultDesc;   // Human-readable message

  // Look up the pending transaction using the ID from the callback
  const pending = originatorConversationId
    ? pendingByOriginatorConversationId.get(originatorConversationId)
    : undefined;

  // Log everything – in production you'd store this in a database
  console.log("B2B Result callback:", {
    originatorConversationId,
    resultCode,
    resultDesc,
    pending,          // Shows original request details
    body,             // Full raw payload
  });

  // You must respond with a specific JSON object to acknowledge receipt.
  // ResultCode "0" means your server accepted the callback.
  res.status(200).json({ ResultCode: "0", ResultDesc: "Accepted" });
});

// --------------------------------------------------------------
// 7. WEBHOOK: TIMEOUT URL (WHEN DARAJA CAN'T REACH YOUR RESULT URL)
// --------------------------------------------------------------
/**
 * If M-Pesa attempts to call your ResultURL but gets a timeout or network error,
 * it will call this TimeoutURL instead. This usually indicates a problem with
 * your server's availability.
 */
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 – check your ResultURL server!", body);
  // Still acknowledge to prevent repeated retries
  res.status(200).json({ ResultCode: "0", ResultDesc: "Accepted" });
});

// --------------------------------------------------------------
// 8. EXPRESS ROUTE: TRIGGER A B2B PAYMENT
// --------------------------------------------------------------
/**
 * This endpoint is called by you (or your frontend) to initiate a B2B payment.
 * POST /send-b2b
 *
 * Example using curl:
 *   curl -X POST http://localhost:5000/send-b2b
 */
app.post("/send-b2b", async (_req: Request, res: Response) => {
  try {
    const data = await sendB2BPayment();
    res.status(200).json({ ok: true, data });
  } catch (error: any) {
    // Log and return a user-friendly error
    res.status(500).json({
      ok: false,
      message: error?.message ?? "Unknown error",
      response: error?.response?.data ?? null, // Often contains Daraja's error details
    });
  }
});

// --------------------------------------------------------------
// 9. START THE SERVER
// --------------------------------------------------------------
const port = Number(process.env.PORT || 5000);
app.listen(port, () => {
  console.log(`M-Pesa B2B server running on http://localhost:${port}`);
  console.log(`Trigger B2B by POST http://localhost:${port}/send-b2b`);
  console.log(`ResultURL: ${process.env.PUBLIC_BASE_URL}/callbacks/mpesa/b2b/result`);
  console.log(`TimeoutURL: ${process.env.PUBLIC_BASE_URL}/callbacks/mpesa/b2b/timeout`);
});
Enter fullscreen mode Exit fullscreen mode


`


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)