guides

How to Integrate M-Pesa C2B Payments With Daraja 3.0 (Node.js Guide)

How to Integrate M-Pesa C2B Payments With Daraja 3.0 (Node.js Guide)

If you followed our STK Push guide, you already know how to prompt a customer to pay from your app. C2B (Customer to Business) works differently. Instead of your app initiating the payment request, the customer pays directly to your Paybill or Till number from their phone, and Daraja sends your server a notification when that payment arrives.

This is the integration that powers most Kenyan e-commerce checkout flows, utility bill payments, school fee collection, and any system where a customer pays independently and the business needs to be notified automatically. A customer sends money to your Paybill, your server receives a callback, you update the order status, and you send a confirmation. No polling, no manual reconciliation, no staff checking M-Pesa messages.

This guide covers the complete C2B integration on Daraja 3.0 using Node.js, URL registration, the validation flow, the confirmation flow, handling callbacks, and the production gotchas that the official documentation skips over.

How C2B Works: The Flow

C2B is fundamentally different from STK Push in one critical way: the payment is customer-initiated, not app-initiated. That means you cannot control when the payment arrives, you can only listen for it.

The flow works like this:

1. You register two URLs with Safaricom, a Validation URL and a Confirmation URL, against your shortcode (Paybill or Till number)

2. A customer pays your Paybill or Till from their M-Pesa menu

3. Safaricom hits your Validation URL (if you have enabled validation) asking: "Should I accept this payment?"

4. You respond with an accept or reject decision

5. Safaricom processes the payment and then hits your Confirmation URL with the completed transaction details

6. You update your database, order fulfilled, payment recorded, receipt issued

If you do not enable validation, steps 3 and 4 are skipped. Most integrations start without validation and add it later for cases where they need to verify account numbers or reference codes before accepting payment.

Prerequisites

Before writing a single line of code, you need:

  • A Daraja 3.0 account at developer.safaricom.co.ke

  • A sandbox app created on the portal with the C2B API product enabled

  • Your Consumer Key and Consumer Secret from the sandbox app

  • A publicly accessible HTTPS server for your callback URLs, localhost will not work. Use a tunnelling tool like ngrok for local development, but read the production warning below carefully.

  • Node.js 18 or above

  • The axios and express packages

code
npm init -y
npm install axios express dotenv

Create a .env file:

code
CONSUMER_KEY=your_sandbox_consumer_key

CONSUMER_SECRET=your_sandbox_consumer_secret

SHORTCODE=600986

CONFIRMATION_URL=https://your-domain.com/c2b/confirmation

VALIDATION_URL=https://your-domain.com/c2b/validation

Step 1: Generate an Access Token

Every Daraja API call requires a Bearer token. The token expires after one hour so you need to generate a fresh one before each request, or cache it and refresh when it expires.

code
// auth.js
import axios from 'axios';
import dotenv from 'dotenv';

dotenv.config();

const CONSUMER_KEY = process.env.CONSUMER_KEY;
const CONSUMER_SECRET = process.env.CONSUMER_SECRET;

const SANDBOX_AUTH_URL = 'https://sandbox.safaricom.co.ke/oauth/v2/generate?grant_type=client_credentials';
const PRODUCTION_AUTH_URL = 'https://api.safaricom.co.ke/oauth/v2/generate?grant_type=client_credentials';

export async function getAccessToken(env = 'sandbox') {
const url = env === 'production' ? PRODUCTION_AUTH_URL : SANDBOX_AUTH_URL;

const credentials = Buffer.from(`${CONSUMER_KEY}:${CONSUMER_SECRET}`).toString('base64');

try {
const response = await axios.get(url, {
headers: {
Authorization: `Basic ${credentials}`,
},

});

return response.data.access_token;

} catch (error) {
console.error('Error generating access token:', error.response?.data || error.message);
throw new Error('Failed to generate access token');
}
}

Step 2: Register Your C2B URLs

This is the step most tutorials gloss over and where most C2B integrations fail silently. Before Safaricom can send you any payment notifications, you must register your Validation and Confirmation URLs against your shortcode. This registration must be done once per environment (sandbox and production are separate registrations).

code
// registerUrls.js
import axios from 'axios';
import { getAccessToken } from './auth.js';
import dotenv from 'dotenv';

dotenv.config();

const SANDBOX_URL = 'https://sandbox.safaricom.co.ke/mpesa/c2b/v2/registerurl';

const PRODUCTION_URL = 'https://api.safaricom.co.ke/mpesa/c2b/v2/registerurl';

export async function registerC2BUrls(env = 'sandbox') {
const url = env === 'production' ? PRODUCTION_URL : SANDBOX_URL;
const token = await getAccessToken(env);

const payload = {
ShortCode: process.env.SHORTCODE,
ResponseType: 'Completed', // or 'Cancelled' — see note below
ConfirmationURL: process.env.CONFIRMATION_URL,
ValidationURL: process.env.VALIDATION_URL,
};

try {
const response = await axios.post(url, payload, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
console.log('URL Registration Response:', response.data);

return response.data;

} catch (error) {
console.error('URL Registration Error:', error.response?.data || error.message);
throw error;
}
}

The ResponseType field explained:

This field tells Safaricom what to do if your Validation URL is unreachable or times out:

  • Completed — Safaricom accepts the payment automatically if your validation URL is down

  • Cancelled — Safaricom rejects the payment if your validation URL is down

For most integrations, Completed is the safer choice in production, a payment that goes through while your server is briefly down is better than a customer whose money bounces. You can reconcile later. A failed payment at checkout means a lost customer.

Run registration once:

code
// run this script once — not on every server start
import { registerC2BUrls } from './registerUrls.js';

registerC2BUrls('sandbox')
.then(res => console.log('Registered:', res))
.catch(err => console.error('Failed:', err));

Step 3: Set Up Your Express Server With Callback Handlers

Now build the server that listens for Safaricom's callbacks.

code
// server.js
import express from 'express';
import dotenv from 'dotenv';

dotenv.config();

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

// Validation Endpoint 
// Safaricom calls this BEFORE processing the payment
// You have a few seconds to respond — accept or reject
app.post('/c2b/validation', (req, res) => {
const payment = req.body;
console.log('Validation request received:', JSON.stringify(payment, null, 2));

// Extract key fields
const { TransID, TransAmount, MSISDN, BillRefNumber, BusinessShortCode } = payment;

//  Your validation logic goes here 
// Examples of what you might check:
// - Does BillRefNumber match a pending order in your database?
// - Is the amount correct for that order?
// - Is the phone number expected?
// To ACCEPT the payment:
return res.json({
ResultCode: 0,
ResultDesc: 'Accepted',
});

// To REJECT the payment (uncomment to use):
// return res.json({
// ResultCode: 'C2B00011',
// ResultDesc: 'Rejected - invalid account',
// });
});

// Confirmation Endpoint 
// Safaricom calls this AFTER the payment has been processed successfully
// This is your source of truth — update your database here
app.post('/c2b/confirmation', async (req, res) => {
const payment = req.body;
console.log('Payment confirmed:', JSON.stringify(payment, null, 2));
const {
TransID, // M-Pesa transaction ID e.g. "RGH7HKXXXX"
TransTime, // Timestamp e.g. "20260314120000"
TransAmount, // Amount paid e.g. "1500.00"
BusinessShortCode, // Your shortcode
BillRefNumber, // Account number / reference the customer entered
MSISDN, // Customer phone number
FirstName, // Customer first name (if available)
MiddleName,
LastName,
} = payment;

// Update your database 
// e.g. mark order as paid, credit user account, send receipt
// await db.payments.create({ transId: TransID, amount: TransAmount, ... });
// await sendReceiptEmail(MSISDN, TransID, TransAmount);

// Always respond with success — even if your DB update fails
// Handle failures internally, do not let them bubble up to Safaricom
return res.json({
ResultCode: 0,
ResultDesc: 'Success',
});
});

const PORT = process.env.PORT || 3000;
app.start = () => app.listen(PORT, () => {
console.log(`C2B server running on port ${PORT}`);
});

export default app;

Step 4: Simulate a C2B Payment in Sandbox

Safaricom provides a simulation endpoint to test your integration without real money. This hits your registered confirmation and validation URLs exactly as a real payment would.

code
// simulate.js
import axios from 'axios';
import { getAccessToken } from './auth.js';
import dotenv from 'dotenv';

dotenv.config();

const SIMULATE_URL = 'https://sandbox.safaricom.co.ke/mpesa/c2b/v2/simulate';

export async function simulateC2B({ amount, phoneNumber, billRefNumber }) {
const token = await getAccessToken('sandbox');

// Normalise phone number to 254 format
const formatPhone = (phone) => {
const cleaned = phone.replace(/\D/g, '');
if (cleaned.startsWith('0')) return `254${cleaned.slice(1)}`;
if (cleaned.startsWith('+')) return cleaned.slice(1);
return cleaned;
};

const payload = {
ShortCode: process.env.SHORTCODE,
CommandID:'CustomerPayBillOnline',// Use 'CustomerBuyGoodsOnline' for Till numbers
Amount: amount,
Msisdn: formatPhone(phoneNumber),
BillRefNumber: billRefNumber, // Account number / order reference
};

try {
const response = await axios.post(SIMULATE_URL, payload, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
console.log('Simulation response:', response.data);
return response.data;
} catch (error) {
console.error('Simulation error:', error.response?.data || error.message);
throw error;
}
}

// Test it
simulateC2B({
amount: 100,
phoneNumber: '0708374149', // Safaricom sandbox test number
billRefNumber: 'ORDER-001',
});

Understanding the Callback Payloads

Validation callback, what Safaricom sends to your Validation URL:

code
{
"TransactionType": "Pay Bill",
"TransID": "RGH7HKXXXX",
"TransTime": "20260314120000",
"TransAmount": "1500.00",
"BusinessShortCode": "600986",
"BillRefNumber": "ORDER-001",
"InvoiceNumber": "",
"OrgAccountBalance": "",
"ThirdPartyTransID": "",
"MSISDN": "254708374149",
"FirstName": "John",
"MiddleName": "",
"LastName": "Doe"
}

Confirmation callback, what Safaricom sends to your Confirmation URL after successful payment:

code
{
"TransactionType": "Pay Bill",
"TransID": "RGH7HKXXXX",
"TransTime": "20260314120000",
"TransAmount": "1500.00",
"BusinessShortCode": "600986",
"BillRefNumber": "ORDER-001",
"InvoiceNumber": "",
"OrgAccountBalance": "25000.00",
"ThirdPartyTransID": "",
"MSISDN": "254708374149",
"FirstName": "John",
"MiddleName": "",
"LastName": "Doe"
}

Note that OrgAccountBalance is populated in the confirmation but empty in the validation, you can use it to log your running balance after each transaction.

The Complete Project Structure

code
daraja-c2b/
├── .env
├── package.json
├── auth.js — access token generation
├── registerUrls.js — one-time URL registration
├── simulate.js — sandbox simulation for testing
└── server.js — Express server with callback handlers

Production Gotchas — Read Before Going Live

1. Never use M-Pesa, Safaricom, or variants in your callback URLs.

Safaricom's system actively filters and blocks URLs containing those words. https://yourapp.com/mpesa/confirmation will be silently rejected. Use neutral paths like /c2b/confirmation or /payments/confirm.

2. Never use ngrok, mockbin, or requestbin in production.

These public tunnelling and inspection services are blocked by Safaricom's API in the production environment. They are fine for local sandbox testing but must be replaced with your actual server URL before go-live.

3. Localhost URLs are always rejected.

Your callback URLs must be publicly accessible HTTPS endpoints. No exceptions.

4. URL registration is per environment.

Registering URLs in sandbox does not carry over to production. You must run the registration script separately for each environment using your production credentials and shortcode.

5. Validation must be explicitly activated.

The C2B Validation feature is not enabled by default. If you want to use the Validation URL to accept or reject payments before processing, you must contact Safaricom M-Pesa Support or API Support to have it activated for your shortcode. Without activation, Safaricom ignores your Validation URL entirely and goes straight to confirmation.

6. Always respond to callbacks immediately.

Safaricom expects a response from your callback URLs within a few seconds. If your server is doing heavy processing ( database writes, email sends, external API calls ) do that work asynchronously after responding. Accept the callback, queue the work, respond immediately. A slow or unresponsive callback URL will cause Safaricom to retry, leading to duplicate processing on your end.

7. Build idempotency into your confirmation handler.

Safaricom may send the same confirmation more than once in rare cases. Use TransID as your unique key and check for duplicates before updating your database. Processing the same payment twice will cause reconciliation headaches you do not want.

8. The number masking update.

As of February 2026, Safaricom masked phone numbers in consumer-facing M-Pesa transactions. Based on current developer community reports, the MSISDN field in C2B API callbacks still returns the full phone number, this is a backend API callback, not a consumer-facing display. However, confirm this behaviour in your sandbox testing before relying on it for customer identification in production.

Switching to Production

When you are ready to go live:

1. Create a production app on Daraja 3.0 and link it to your live Safaricom business shortcode

2. Swap your .env credentials to production Consumer Key and Consumer Secret

3. Update CONFIRMATION_URL and VALIDATION_URL to your live server endpoints

4. Run registerC2BUrls('production') once

5. Change all 'sandbox' references in your code to 'production'

6. Test with a real Ksh 1 payment before going fully live

Running into issues with your C2B integration? Drop your error message in the comments and we will help you debug it. For the full Daraja 3.0 documentation, visit developer.safaricom.co.ke

Comments

to join the discussion.