guides

How to Send Money from Your M-Pesa Business Account to Any Phone Using the Daraja B2C API

How to Send Money from Your M-Pesa Business Account to Any Phone Using the Daraja B2C API

The STK Push gets all the attention because it is what users interact with directly, a prompt appears on their phone, they enter their PIN, money moves. But a large class of business problems requires the reverse: your system needs to push money out to a customer's phone without any action on their end. Refunds, loan disbursements, withdrawal payouts, salary payments, promotional cashbacks, marketplace seller payments, all of these are B2C problems.

The Daraja Business to Customer (B2C) API handles exactly this. You call the endpoint with a phone number and an amount, Safaricom processes the transaction, and the money lands in the recipient's M-Pesa wallet. No STK prompt. No customer action required.

This guide covers the full B2C integration in Node.js, from understanding what makes B2C different from STK Push, through generating the SecurityCredential that trips most developers up, to parsing the async callback correctly. If you have already read our STK Push guide and C2B guide, the patterns here will feel familiar.

How B2C Is Different From STK Push

Before touching any code, it is worth being precise about what B2C does and does not do, because the assumptions people carry from STK Push cause the most common B2C mistakes.

STK Push is customer-initiated in the sense that the customer must actively approve the transaction by entering their PIN. Your system triggers the prompt, but the money does not move until the customer responds.

B2C is business-initiated. Your system sends the request, Safaricom validates it, and the money moves. The recipient gets a notification SMS but takes no action. This means:

  • You need a B2C-specific shortcode — a standard Paybill or Till number does not work. You need to apply specifically for a B2C shortcode through Safaricom.

  • You need an Initiator — an API operator account created on the M-Pesa business portal with the correct role assigned.

  • You need a SecurityCredential — an encrypted version of the Initiator's password, generated using Safaricom's public key certificate. This is the step that blocks most developers.

  • B2C is asynchronous. Unlike STK Push where you wait for the callback, B2C has two callback URLs: a ResultURL for successful responses and a QueueTimeOutURL for when the transaction takes too long. Both must be live HTTPS endpoints when going to production.

Prerequisites

Before writing a single line of code, confirm you have all of the following:

A B2C shortcode — this is different from your standard Paybill or Till number. Log into the M-Pesa Business portal at business.safaricom.co.ke. Under your organisation's shortcodes, check whether a B2C shortcode is available. If not, you need to apply to Safaricom's M-Pesa Business team.

An Initiator account with the correct role — on the M-Pesa Business portal, create an API operator account (not a web operator). Assign it the Org Business Manager role and the B2C Org API Initiator role. The username assigned during creation is your InitiatorName. The password set is the plaintext password you will encrypt into a SecurityCredential.

A Daraja app with B2C enabled — on developer.safaricom.co.ke, create or use an existing app and ensure B2C is among the enabled APIs. Note your Consumer Key and Consumer Secret.

Public HTTPS callback URLs — for sandbox testing, tools like ngrok work. For production, these must be real HTTPS endpoints. Do not use ngrok or any tunneling tool in production.

Generating the SecurityCredential

This is the step where most B2C integrations stall. The SecurityCredential is not your Initiator password in plaintext, it is your Initiator password encrypted with Safaricom's M-Pesa public key certificate, then Base64 encoded.

Safaricom provides two certificates:

  • Sandbox: downloadable from developer.safaricom.co.ke under Test Credentials

  • Production: downloadable from the same portal under your production app

Here is how to generate the SecurityCredential in Node.js:

javascript
const crypto = require('crypto');
const fs = require('fs');

function generateSecurityCredential(initiatorPassword, certificatePath) {
  // Read the certificate file
  const certificate = fs.readFileSync(certificatePath);
  
  // Encrypt the password using the certificate's public key
  const encrypted = crypto.publicEncrypt(
    {
      key: certificate,
      padding: crypto.constants.RSA_PKCS1_PADDING,
    },
    Buffer.from(initiatorPassword)
  );
  
  // Return as Base64 string
  return encrypted.toString('base64');
}

// Usage
const securityCredential = generateSecurityCredential(
  'your_initiator_password',
  './sandbox_cert.cer' // or './production_cert.cer' for live
);

console.log(securityCredential);
// Copy this value into your .env file as MPESA_SECURITY_CREDENTIAL

Important: Generate this once and store it. Do not regenerate it on every request, the certificate changes rarely and regenerating unnecessarily adds latency. Store the generated credential in your environment variables.

Also important: The Initiator password on the M-Pesa Business portal expires periodically, typically every 90 days. When it expires, your B2C requests will fail with "The Initiator Information Is Invalid." Set a calendar reminder to renew the password before expiry and regenerate the SecurityCredential immediately after.

Store credentials in a .env file:

code
MPESA_CONSUMER_KEY=your_consumer_key
MPESA_CONSUMER_SECRET=your_consumer_secret
MPESA_B2C_SHORTCODE=600XXX
MPESA_INITIATOR_NAME=your_initiator_name
MPESA_SECURITY_CREDENTIAL=your_generated_base64_credential
MPESA_RESULT_URL=https://yourdomain.com/api/mpesa/b2c/result
MPESA_TIMEOUT_URL=https://yourdomain.com/api/mpesa/b2c/timeout

Project Setup

bash
mkdir daraja-b2c && cd daraja-b2c
npm init -y
npm install express axios dotenv
touch index.js .env

Step 1: Generate an Access Token

Every Daraja API call requires a Bearer token obtained by authenticating with your Consumer Key and Consumer Secret. This is identical to the STK Push and C2B guides.

javascript
// auth.js
const axios = require('axios');

async function getAccessToken() {
  const consumerKey = process.env.MPESA_CONSUMER_KEY;
  const consumerSecret = process.env.MPESA_CONSUMER_SECRET;
  
  const auth = Buffer.from(`${consumerKey}:${consumerSecret}`).toString('base64');
  
  // Sandbox URL — change to api.safaricom.co.ke for production
  const url = 'https://sandbox.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials';
  
  const response = await axios.get(url, {
    headers: {
      Authorization: `Basic ${auth}`,
    },
  });
  
  return response.data.access_token;
}

module.exports = { getAccessToken };

Step 2: The B2C Request

The B2C API endpoint is:

  • Sandbox: https://sandbox.safaricom.co.ke/mpesa/b2c/v3/paymentrequest

  • Production: https://api.safaricom.co.ke/mpesa/b2c/v3/paymentrequest

javascript
// b2c.js
const axios = require('axios');
const { getAccessToken } = require('./auth');

async function sendB2CPayment({
  phone,
  amount,
  commandID = 'BusinessPayment',
  remarks = 'Payment',
  occasion = '',
}) {
  const token = await getAccessToken();
  
  // Normalise phone number to 254XXXXXXXXX format
  const normalisedPhone = phone.replace(/^0/, '254').replace(/^\+/, '');
  
  const payload = {
    InitiatorName: process.env.MPESA_INITIATOR_NAME,
    SecurityCredential: process.env.MPESA_SECURITY_CREDENTIAL,
    CommandID: commandID,
    Amount: amount,
    PartyA: process.env.MPESA_B2C_SHORTCODE,
    PartyB: normalisedPhone,
    Remarks: remarks,
    QueueTimeOutURL: process.env.MPESA_TIMEOUT_URL,
    ResultURL: process.env.MPESA_RESULT_URL,
    Occasion: occasion,
  };
  
  const response = await axios.post(
    'https://sandbox.safaricom.co.ke/mpesa/b2c/v3/paymentrequest',
    payload,
    {
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
    }
  );
  
  return response.data;
}

module.exports = { sendB2CPayment };

Understanding CommandID

The CommandID field tells Safaricom what type of B2C payment this is. There are three valid values:

CommandID

Use case

BusinessPayment

General payments — refunds, withdrawals, marketplace seller payouts, any ad-hoc payment

SalaryPayment

Employee salary disbursements

PromotionPayment

Cashbacks, loyalty rewards, promotional disbursements

Use BusinessPayment for the majority of use cases unless your specific deployment is salary or promotion related. The CommandID affects how the transaction is reported in M-Pesa statements and how Safaricom classifies your usage pattern.

Step 3: Handling the Callbacks

B2C is fully asynchronous. The initial API response tells you Safaricom has accepted the request. The actual result (success or failure ) arrives later at your ResultURL or QueueTimeOutURL. You must handle both.

The Result Callback (Success or Failure)

javascript
// routes/b2cCallback.js
const express = require('express');
const router = express.Router();

router.post('/result', express.json(), (req, res) => {
  // Always respond to Safaricom immediately — before any async processing
  res.json({ ResultCode: 0, ResultDesc: 'Success' });
  
  const result = req.body?.Result;
  
  if (!result) {
    console.error('B2C callback: empty result body');
    return;
  }
  
  const {
    ResultCode,
    ResultDesc,
    OriginatorConversationID,
    ConversationID,
    TransactionID,
    ResultParameters,
  } = result;
  
  if (ResultCode !== 0) {
    // Transaction failed
    console.error(`B2C failed [${OriginatorConversationID}]: ${ResultDesc}`);
    // Update your database — mark this payout as failed
    // Trigger retry logic if appropriate
    return;
  }
  
  // Transaction succeeded — extract result parameters
  const params = {};
  ResultParameters?.ResultParameter?.forEach(({ Key, Value }) => {
    params[Key] = Value;
  });
  
  console.log('B2C success:', {
    transactionID: TransactionID,
    conversationID: ConversationID,
    originatorConversationID: OriginatorConversationID,
    transactionAmount: params.TransactionAmount,
    transactionReceipt: params.TransactionReceipt,
    receiverPartyPublicName: params.ReceiverPartyPublicName,
    transactionCompletedDateTime: params.TransactionCompletedDateTime,
    b2CUtilityAccountAvailableFunds: params.B2CUtilityAccountAvailableFunds,
    b2CWorkingAccountAvailableFunds: params.B2CWorkingAccountAvailableFunds,
  });
  
  // Update your database — mark payout as complete
  // Store TransactionReceipt as your reference
});

module.exports = router;

The Timeout Callback

This fires when the transaction has been queued but Safaricom has not processed it within the timeout window. This does not necessarily mean the transaction failed, it may still process later.

javascript
router.post('/timeout', express.json(), (req, res) => {
  res.json({ ResultCode: 0, ResultDesc: 'Accepted' });
  
  const result = req.body?.Result;
  const { OriginatorConversationID, ConversationID } = result || {};
  
  console.warn(`B2C timeout [${OriginatorConversationID}]`);
  
  // Do NOT immediately mark the payout as failed
  // Use the Transaction Status API to check the outcome later
  // Schedule a status check after 5-10 minutes
});

Step 4: The Full Express Server

javascript
// index.js
require('dotenv').config();
const express = require('express');
const { sendB2CPayment } = require('./b2c');
const b2cCallbackRouter = require('./routes/b2cCallback');

const app = express();
app.use(express.json());

// Callback routes — must match your MPESA_RESULT_URL and MPESA_TIMEOUT_URL
app.use('/api/mpesa/b2c', b2cCallbackRouter);

// Trigger a B2C payment (protect this route in production — never expose it publicly)
app.post('/api/pay', async (req, res) => {
  const { phone, amount, remarks } = req.body;
  
  try {
    const result = await sendB2CPayment({
      phone,
      amount,
      commandID: 'BusinessPayment',
      remarks: remarks || 'Payment',
    });
    
    res.json({
      success: true,
      conversationID: result.ConversationID,
      originatorConversationID: result.OriginatorConversationID,
      responseDescription: result.ResponseDescription,
    });
  } catch (error) {
    console.error('B2C initiation failed:', error.response?.data || error.message);
    res.status(500).json({
      success: false,
      error: error.response?.data || error.message,
    });
  }
});

app.listen(3000, () => console.log('Server running on port 3000'));

Idempotency and the OriginatorConversationID

The OriginatorConversationID is Safaricom's reference for your request. When your callback arrives, this is how you match it to the payout record in your database.

The critical pattern: store the OriginatorConversationID in your database at the moment you initiate the B2C request, before waiting for the callback. This way, when the callback arrives ( potentially minutes later ) you have a clean record to update.

javascript
// At initiation time
const result = await sendB2CPayment({ phone, amount });

await db.payouts.create({
  originatorConversationID: result.OriginatorConversationID,
  conversationID: result.ConversationID,
  phone,
  amount,
  status: 'pending',
  initiatedAt: new Date(),
});

// In your callback handler
const payout = await db.payouts.findOne({
  where: { originatorConversationID: result.OriginatorConversationID }
});

await payout.update({
  status: ResultCode === 0 ? 'completed' : 'failed',
  transactionID: result.TransactionID,
  completedAt: new Date(),
});

Never process a callback without first confirming the OriginatorConversationID exists in your database. Unknown IDs should be logged and investigated, they may indicate replay attacks.

Common Errors and What They Mean

"The Initiator Information Is Invalid" Your SecurityCredential does not match the Initiator. Either you used the wrong password to generate it, used the sandbox certificate for a production request (or vice versa), or the Initiator password has expired on the M-Pesa Business portal. Renew the password and regenerate the SecurityCredential.

"Invalid Access Token" The Bearer token has expired (tokens last 1 hour). Implement token caching with automatic refresh rather than fetching a new token on every request.

"The request requires authentication" (HTTP 401) Your Consumer Key or Consumer Secret is wrong, or you are using sandbox credentials against the production endpoint.

"System internal error" (or empty response) Usually a Safaricom-side issue. Implement exponential backoff retry logic for the initiation request. Do not retry blindly, check your database first to avoid duplicate payouts.

No callback received Your ResultURL is not publicly accessible. In sandbox, ensure your ngrok tunnel is running and the URL in your .env matches exactly. In production, ensure your HTTPS certificate is valid and the route is live.

Going to Production Checklist

  • Apply for and receive a production B2C shortcode from Safaricom

  • Create the Initiator account on the production M-Pesa Business portal with the correct roles

  • Download the production certificate from the Daraja portal and regenerate SecurityCredential

  • Replace sandbox URLs with production URLs (api.safaricom.co.ke)

  • Replace .env values with production Consumer Key and Secret

  • Ensure ResultURL and QueueTimeOutURL are live HTTPS endpoints, not localhost, not ngrok

  • Whitelist your server IP with Safaricom (required for production)

  • Test with a small amount (Ksh 1) before enabling full amounts

  • Set a reminder to renew the Initiator password before its 90-day expiry

  • Implement Transaction Status API calls for timeout scenarios

What Comes Next in This Series

This guide completes the core M-Pesa payment flows for most applications. The next guide in the series covers the Transaction Status API — how to query the outcome of any B2C, C2B, or STK Push transaction after the fact. This is essential for recovering from timeout scenarios and for building reconciliation tooling.

Read the rest of the Daraja series: STK Push guide | C2B guide | Transaction Status (coming soon)

Questions about your B2C integration? Drop them in the comments, we read every one.

Comments

to join the discussion.