M-Pesa is one of the most widely used payment systems in Kenya. For a web application, the Daraja API provides the interface that lets your backend request payments, receive payment notifications, and keep your own transaction records in sync with Safaricom.
In this tutorial, we will build the integration from the ground up with Node.js and Express for the API server and MongoDB for transaction storage. The goal is not only to trigger a payment prompt, but to design the complete flow around authentication, callbacks, verification, reconciliation, and security.
Important payment rule
A successful STK Push initiation response only means that Safaricom accepted your request for processing. It is not proof that the customer entered a PIN or that money was received. Your application must process the callback and, where necessary, verify and reconcile the transaction before treating it as paid.
What is the M-Pesa Daraja API?
Daraja is Safaricom’s developer platform for integrating M-Pesa services into applications. Your server communicates with Daraja over HTTPS using JSON requests and responses. Depending on the product you use, the API can support customer-initiated payments, business-to-customer payments, business-to-business payments, transaction queries, and payment notifications.
This guide focuses on the common web checkout flow: a customer enters a phone number, your Express server requests an STK Push, the customer responds on their phone, and Safaricom calls your callback endpoint with the result. Later chapters will add C2B, persistence, verification, and reconciliation.
The payment flow we will build
- 1
Customer starts checkout
The customer submits an order and a valid Kenyan M-Pesa phone number to your application.
- 2
Express requests an access token
Your backend authenticates with Daraja using the Consumer Key and Consumer Secret stored in environment variables.
- 3
Express requests an STK Push
Your backend creates the required password and timestamp, then sends the payment request to Daraja. The customer sees the payment prompt on their phone.
- 4
Safaricom sends the callback
Daraja calls the configured callback URL with the result. Your server must validate the payload, record the outcome, and avoid assuming success before checking the result code.
- 5
The application reconciles the transaction
Your system uses its stored identifiers and, when needed, a transaction-status query to resolve delayed, duplicated, or uncertain payment states.
Sandbox and production are different environments
| Environment | Purpose | Credentials and money |
|---|---|---|
| Sandbox | Build and test request formats, callbacks, and error handling. | Uses sandbox app credentials and test configuration. Do not treat sandbox responses as live customer payments. |
| Production | Process real customer payments after the business and integration are approved for live use. | Uses production credentials and live account configuration. Protect credentials and monitor every transaction. |
The endpoints, credentials, account identifiers, callback configuration, and test behavior must be checked against the current Daraja documentation for the product you are integrating. Never copy sandbox credentials into production or commit either environment’s secrets to source control.
What we will build in the remaining chapters
- A small Express API with clear environment configuration and error handling.
- OAuth token generation using the Daraja Consumer Key and Consumer Secret.
- STK Push password and timestamp generation without exposing secrets.
- Callback endpoints that distinguish accepted requests from completed payments.
- MongoDB transaction records that support retries, status checks, and reconciliation.
- Security controls for validation, HTTPS callbacks, idempotency, logging, and secret management.
Use the official portal to create applications, review current API documentation, obtain environment-specific credentials, and confirm the requirements for the product you plan to use.
Chapter 2: Going live on Safaricom Daraja
Integrating M-Pesa payments into a web or mobile app is an important milestone for a Kenyan business. Moving from sandbox testing to real payments requires the correct business payment service, live credentials, and a production-ready backend. We will prepare those foundations before implementing STK Push, C2B, or callbacks in later chapters.
Part 1: Getting your Daraja credentials
Step 1: Create a Daraja developer account
Open the official Safaricom Daraja Developer Portal and choose the registration option. Provide the information requested by the portal, verify your account if prompted, and sign in. The portal is where you create applications, view their credentials, and access the current API documentation.
Use this official portal for registration, sign-in, application creation, sandbox credentials, and current Daraja documentation.
Step 2: Create a sandbox application
After signing in, create a new application from the developer dashboard. Select the M-Pesa products your application needs, such as M-Pesa Express (STK Push) and C2B where they are available. STK Push requests a customer payment prompt; C2B handles payments made to a business shortcode and sends payment notifications to your validation and confirmation endpoints.
Follow the current dashboard
The products and options shown in the portal can change. Follow the current dashboard labels and API documentation rather than assuming that every product is enabled for every application.
Step 3: Retrieve your API credentials
Open the credentials or application details for the sandbox app. Store the values in a password manager or a local environment file excluded from Git. Never paste real values into source code, screenshots, tutorials, or support requests.
| Value | What it does |
|---|---|
| Consumer Key | Identifies your application when the backend requests an OAuth access token. |
| Consumer Secret | Authenticates your application together with the Consumer Key. Keep it private and server-side. |
| Sandbox shortcode | The test business shortcode used by the sandbox payment product and examples. |
| Sandbox passkey | A sandbox secret combined with the shortcode and timestamp to create the STK Push password. |
The Access Token is generated later through OAuth using the Consumer Key and Consumer Secret. It is temporary, so your application should request it when needed rather than storing it permanently.
Step 4: Understand sandbox versus production
Sandbox is for testing request formats, authentication, callback handling, and error paths. Production is for real payments and requires the appropriate business M-Pesa service, live credentials, correct account configuration, and Safaricom go-live approval. Keep the two environments separate and never reuse sandbox secrets in production.
A developer does not need to personally own a live Paybill or Buy Goods Till Number to learn the integration in the sandbox. Paybill and Buy Goods Till are business payment services with different operating contexts; use the service that matches the business arrangement and Daraja product being implemented.
Review Safaricom’s official business payment information when deciding whether a Paybill or Buy Goods Till service fits your business.
Part 2: Setting up our Node.js project
We will use Node.js for the runtime, Express for HTTP routes, MongoDB for transaction data, Mongoose for the MongoDB object model, Axios for Daraja HTTP requests, and dotenv for loading local environment variables.
Step 5: Create the project
Create a new folder, open a terminal in it, and initialize a Node.js project.
mkdir mpesa-daraja-apicd mpesa-daraja-apinpm init -yStep 6: Install dependencies
npm install express mongoose axios dotenvnpm install --save-dev nodemon| Dependency | Purpose |
|---|---|
| express | Creates the HTTP server and API routes. |
| mongoose | Connects to MongoDB and defines document models. |
| axios | Sends HTTP requests to Daraja endpoints. |
| dotenv | Loads local variables from .env. |
| nodemon | Restarts the development server when files change. |
Step 7: Create the project structure
Create these folders now. We will fill them progressively as the tutorial adds OAuth, payment requests, callbacks, transaction models, and reconciliation.
mpesa-daraja-api/├── src/│ ├── config/database.js│ ├── controllers/│ ├── models/│ ├── routes/│ ├── services/│ ├── utils/│ └── server.js├── .env├── .env.example├── .gitignore├── package-lock.json└── package.jsonStep 8: Configure environment variables
Create the example file below and copy it to .env for local development. Replace placeholders only in your private .env. DARAJA_ENV will later select sandbox or production configuration.
PORT=3000MONGODB_URI=mongodb://127.0.0.1:27017/mpesa_darajaDARAJA_CONSUMER_KEY=replace_with_your_consumer_keyDARAJA_CONSUMER_SECRET=replace_with_your_consumer_secretDARAJA_SHORTCODE=replace_with_your_shortcodeDARAJA_PASSKEY=replace_with_your_passkeyDARAJA_CALLBACK_URL=https://your-domain.example/api/mpesa/callbackDARAJA_VALIDATION_URL=https://your-domain.example/api/c2b/validationDARAJA_CONFIRMATION_URL=https://your-domain.example/api/c2b/confirmationDARAJA_INITIATOR_NAME=replace_with_transaction_status_initiatorDARAJA_SECURITY_CREDENTIAL=replace_with_transaction_status_security_credentialDARAJA_RESULT_URL=https://your-domain.example/api/mpesa/status/resultDARAJA_TIMEOUT_URL=https://your-domain.example/api/mpesa/status/timeoutDARAJA_ENV=sandbox.envnode_modules/logs/npm-debug.log*Keep credentials server-side
Consumer secrets, passkeys, database connection strings, and access tokens must never be exposed to a browser or committed to Git. Rotate a credential immediately if it is accidentally published.
Part 3: Connect to MongoDB
Step 9: Configure the database
Use a local MongoDB server for development or create a MongoDB Atlas deployment. Put the connection string in MONGODB_URI. This helper fails clearly when the variable is missing and rethrows connection errors instead of allowing the server to start in an unknown state.
const mongoose = require('mongoose');async function connectToDatabase() { const { MONGODB_URI } = process.env; if (!MONGODB_URI) { throw new Error('MONGODB_URI is not configured'); } try { await mongoose.connect(MONGODB_URI); console.log('MongoDB connected'); } catch (error) { console.error('MongoDB connection failed:', error.message); throw error; }}module.exports = { connectToDatabase };Use the official documentation for installation, connection strings, authentication, and deployment guidance.
Atlas provides a managed MongoDB deployment. Follow its current setup and network-access instructions.
Step 10: Verify the connection
Add a minimal Express server that loads dotenv, connects to MongoDB before accepting requests, and reports startup errors. This verifies the database before we add payment routes.
require('dotenv').config();const express = require('express');const { connectToDatabase } = require('./config/database');const app = express();const port = Number(process.env.PORT || 3000);app.use(express.json());app.get('/health', (req, res) => { res.json({ status: 'ok' });});async function startServer() { try { await connectToDatabase(); app.listen(port, () => { console.log(`API listening on port ${port}`); }); } catch (error) { console.error('Application startup failed:', error.message); process.exitCode = 1; }}startServer();Run node src/server.js. A successful startup prints MongoDB connected and API listening on port 3000, or the port configured in .env. In another terminal, request the health endpoint. The expected response is { "status": "ok" }. If startup fails, check MONGODB_URI, the local MongoDB service, or Atlas network access rules.
node src/server.jscurl http://localhost:3000/healthWhat comes next
The project now has server-side configuration, a database connection, and a health endpoint. The next chapter will add OAuth authentication. STK Push, C2B, callback processing, and reconciliation remain separate later parts.
Chapter 3: Implement OAuth authentication
Chapter 2 stored the Consumer Key and Consumer Secret in environment variables. We will now use those values to request a temporary Daraja access token. This token is sent in the Authorization header of protected API requests; it is not the same thing as either application credential.
Part 4: Generate a Daraja access token
Step 11: Select the Daraja environment
Use the sandbox host while learning and testing. Use the production host only after your application has the appropriate live approval and credentials. Keeping the host selection in one service prevents a sandbox request from accidentally being sent with production settings.
const environments = { sandbox: 'https://sandbox.safaricom.co.ke', production: 'https://api.safaricom.co.ke',};function getDarajaBaseUrl() { const environment = process.env.DARAJA_ENV || 'sandbox'; const baseUrl = environments[environment]; if (!baseUrl) { throw new Error('DARAJA_ENV must be sandbox or production'); } return baseUrl;}module.exports = { getDarajaBaseUrl };Step 12: Request the OAuth token
Create an Axios service that sends the Consumer Key and Consumer Secret as HTTP Basic Authentication. The grant_type query parameter tells Daraja that this is a client-credentials request. The service returns only the access token and leaves token caching for a later improvement.
const axios = require('axios');const { getDarajaBaseUrl } = require('../config/daraja');async function getAccessToken() { const consumerKey = process.env.DARAJA_CONSUMER_KEY; const consumerSecret = process.env.DARAJA_CONSUMER_SECRET; if (!consumerKey || !consumerSecret) { throw new Error('Daraja consumer credentials are not configured'); } try { const response = await axios.get( `${getDarajaBaseUrl()}/oauth/v1/generate`, { params: { grant_type: 'client_credentials' }, auth: { username: consumerKey, password: consumerSecret, }, timeout: 15000, } ); if (!response.data.access_token) { throw new Error('Daraja did not return an access token'); } return response.data.access_token; } catch (error) { const message = error.response?.data?.errorMessage || error.message; throw new Error(`Daraja OAuth request failed: ${message}`); }}module.exports = { getAccessToken };Run this service only from your backend. A browser or mobile client must never receive the Consumer Secret. The OAuth response commonly contains an access_token and an expires_in value; the exact response should be checked against the current Daraja documentation for your environment.
node -e "require('dotenv').config(); require('./src/services/daraja-auth').getAccessToken().then(token => console.log(token ? 'OAuth token received' : 'No token')).catch(error => { console.error(error.message); process.exitCode = 1; })"Do not log the token
The command above prints only a success message. Do not log the access token in normal application logs, send it to the frontend, or commit it to a file. If OAuth fails, inspect the HTTP status and Daraja error message without exposing credentials.
Chapter 4: Generate the STK Push password and timestamp
An STK Push request needs two values that change for every request: a timestamp and a Base64 password. The password is generated by concatenating the Business Shortcode, the Passkey, and the timestamp in that order. The passkey is never sent as a plain value in the request body.
Part 5: Build the request security values
Step 13: Create the timestamp and password utility
function getDarajaTimestamp(date = new Date()) { const parts = [ date.getFullYear(), date.getMonth() + 1, date.getDate(), date.getHours(), date.getMinutes(), date.getSeconds(), ]; return parts .map((part) => String(part).padStart(2, '0')) .join('');}function createStkPassword(shortcode, passkey, timestamp) { if (!shortcode || !passkey || !timestamp) { throw new Error('Shortcode, passkey, and timestamp are required'); } return Buffer.from(`${shortcode}${passkey}${timestamp}`).toString('base64');}module.exports = { getDarajaTimestamp, createStkPassword };The timestamp format is YYYYMMDDHHmmss using the server’s local clock. In production, synchronize the server clock with a reliable time source. A clock that is significantly wrong can produce a password that Daraja rejects.
node -e "const u = require('./src/utils/stk-password'); const t = u.getDarajaTimestamp(); console.log({ timestamp: t, password: u.createStkPassword('174379', 'SANDBOX_PASSKEY', t) })"Use placeholders only
The command uses the documented sandbox shortcode format and a placeholder passkey for demonstration. Replace it only in your private environment or application configuration. Never publish a real passkey or generated password.
Chapter 5: Implement STK Push with Express
We now have the three ingredients needed to submit an STK Push request: an OAuth access token, a timestamp, and a Base64 password. The next service validates the input, formats the Daraja payload, and returns Daraja’s initiation response. It does not process the payment callback yet; that belongs to a later chapter.
Part 6: Send the STK Push request
Step 14: Normalize and validate the customer phone number
Daraja examples use the international Kenyan format beginning with 254. The helper below accepts 07XXXXXXXX, 01XXXXXXXX, or an already normalized 254XXXXXXXXX number and rejects other input before making an external request.
function normalizeKenyanPhoneNumber(value) { const input = String(value || '').replace(/[\s-]/g, ''); let normalized = input; if (/^07\d{8}$/.test(input) || /^01\d{8}$/.test(input)) { normalized = `254${input.slice(1)}`; } if (!/^254\d{9}$/.test(normalized)) { throw new Error('PhoneNumber must be a valid Kenyan number'); } return normalized;}module.exports = { normalizeKenyanPhoneNumber };Step 15: Create the STK Push service
const axios = require('axios');const { getDarajaBaseUrl } = require('../config/daraja');const { getAccessToken } = require('./daraja-auth');const { getDarajaTimestamp, createStkPassword } = require('../utils/stk-password');const { normalizeKenyanPhoneNumber } = require('../utils/phone-number');async function initiateStkPush({ amount, phoneNumber, accountReference, description }) { const shortcode = process.env.DARAJA_SHORTCODE; const passkey = process.env.DARAJA_PASSKEY; const callbackUrl = process.env.DARAJA_CALLBACK_URL; if (!shortcode || !passkey || !callbackUrl) { throw new Error('Daraja shortcode, passkey, and callback URL are required'); } if (!Number.isInteger(Number(amount)) || Number(amount) <= 0) { throw new Error('Amount must be a positive whole number'); } if (!accountReference || !description) { throw new Error('Account reference and description are required'); } const timestamp = getDarajaTimestamp(); const normalizedPhone = normalizeKenyanPhoneNumber(phoneNumber); const accessToken = await getAccessToken(); const payload = { BusinessShortCode: shortcode, Password: createStkPassword(shortcode, passkey, timestamp), Timestamp: timestamp, TransactionType: 'CustomerPayBillOnline', Amount: Number(amount), PartyA: normalizedPhone, PartyB: shortcode, PhoneNumber: normalizedPhone, CallBackURL: callbackUrl, AccountReference: String(accountReference).slice(0, 12), TransactionDesc: String(description).slice(0, 13), }; try { const response = await axios.post( `${getDarajaBaseUrl()}/mpesa/stkpush/v1/processrequest`, payload, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, timeout: 15000, } ); return response.data; } catch (error) { const message = error.response?.data?.errorMessage || error.message; throw new Error(`STK Push request failed: ${message}`); }}module.exports = { initiateStkPush };Step 16: Expose the service through an Express route
const express = require('express');const { initiateStkPush } = require('../services/stk-push');const router = express.Router();router.post('/stk-push', async (req, res) => { try { const result = await initiateStkPush(req.body); res.status(202).json({ message: 'STK Push request accepted', data: result, }); } catch (error) { console.error('STK Push initiation error:', error.message); res.status(400).json({ message: error.message }); }});module.exports = router;require('dotenv').config();const express = require('express');const { connectToDatabase } = require('./config/database');const mpesaRouter = require('./routes/mpesa');const app = express();const port = Number(process.env.PORT || 3000);app.use(express.json());app.use('/api/mpesa', mpesaRouter);app.get('/health', (req, res) => { res.json({ status: 'ok' });});async function startServer() { try { await connectToDatabase(); app.listen(port, () => console.log(`API listening on port ${port}`)); } catch (error) { console.error('Application startup failed:', error.message); process.exitCode = 1; }}startServer();Step 17: Test the initiation request
curl -X POST http://localhost:3000/api/mpesa/stk-push ^ -H "Content-Type: application/json" ^ -d "{"amount":1,"phoneNumber":"0712345678","accountReference":"Order101","description":"Test payment"}"A successful initiation response normally contains a response code, a checkout request identifier, a customer message, and a merchant request identifier. The exact field values depend on the environment and current API response. Store the identifiers when the transaction model is added, but do not mark the order as paid from this response.
{ "MerchantRequestID": "request-id-from-daraja", "CheckoutRequestID": "checkout-id-from-daraja", "ResponseCode": "0", "ResponseDescription": "Success. Request accepted for processing", "CustomerMessage": "Success. Request accepted for processing"}Accepted is not paid
ResponseCode 0 means that Daraja accepted the STK Push initiation request. It does not prove that the customer completed the payment. The callback result, and later verification or reconciliation where needed, determine the final transaction status.
This request has not been tested against an external Daraja sandbox from this article build. Before using it with real customers, configure valid sandbox credentials, use the current official Daraja documentation, expose a reachable HTTPS callback URL, and test the complete callback flow in the sandbox.
Chapter 6: Configure the callback URL
An STK Push is asynchronous. Daraja accepts the initiation request first, then sends the customer result to the callback URL in DARAJA_CALLBACK_URL. This endpoint must belong to your server, accept JSON, and be reachable from outside your development machine. Localhost is not reachable by Safaricom; use a controlled HTTPS development tunnel for local testing and a real HTTPS domain in production.
Part 7: Receive STK Push callbacks
Step 18: Add a callback route
Add a separate callback route to the same Express router used by the initiation endpoint. The handler responds quickly with HTTP 200 after checking that the expected STK callback object exists. The callback is persisted asynchronously and idempotently; the next chapter defines the transaction service used below.
const express = require('express');const { initiateStkPush } = require('../services/stk-push'); const { createPendingTransaction, saveStkCallback } = require('../services/transaction-service');const router = express.Router();router.post('/stk-push', async (req, res) => { try { const result = await initiateStkPush(req.body); try { await createPendingTransaction({ checkoutRequestId: result.CheckoutRequestID, merchantRequestId: result.MerchantRequestID, amount: Number(req.body.amount), phoneNumber: req.body.phoneNumber, accountReference: req.body.accountReference, }); } catch (error) { console.error('Pending transaction persistence error:', error.message); } res.status(202).json({ message: 'STK Push request accepted', data: result, }); } catch (error) { console.error('STK Push initiation error:', error.message); res.status(400).json({ message: error.message }); }});router.post('/callback', (req, res) => { const callback = req.body?.Body?.stkCallback; if (!callback) { console.error('Invalid STK callback payload'); return res.status(400).json({ message: 'Invalid STK callback payload' }); } void saveStkCallback(req.body).catch((error) => { console.error('STK callback persistence error:', error.message); }); return res.status(200).json({ ResultCode: 0, ResultDesc: 'Accepted' });});module.exports = router;Step 19: Understand successful and failed callbacks
Read the callback from Body.stkCallback. A ResultCode of 0 indicates a successful customer transaction. A non-zero ResultCode indicates a failure, cancellation, timeout, or another unsuccessful result. Do not treat the presence of CallbackMetadata alone as proof of payment; check the result code and later persist the identifiers and receipt details.
{ "Body": { "stkCallback": { "MerchantRequestID": "merchant-request-id", "CheckoutRequestID": "checkout-request-id", "ResultCode": 0, "ResultDesc": "The service request is processed successfully.", "CallbackMetadata": { "Item": [ { "Name": "Amount", "Value": 1 }, { "Name": "MpesaReceiptNumber", "Value": "receipt-number" }, { "Name": "TransactionDate", "Value": 20261104094500 }, { "Name": "PhoneNumber", "Value": 254712345678 } ] } } }}{ "Body": { "stkCallback": { "MerchantRequestID": "merchant-request-id", "CheckoutRequestID": "checkout-request-id", "ResultCode": 1032, "ResultDesc": "Request cancelled by user" } }}Acknowledge, then process safely
The callback endpoint should respond quickly so Daraja does not wait for database work or slow downstream services. First validate the shape and acknowledge the request; then persist and process it with idempotency in the transaction layer. Never charge or fulfil an order merely because the initiation endpoint returned ResponseCode 0.
Step 20: Test the callback locally
Before connecting a real sandbox callback, post a representative payload to your local route. This tests your JSON parser and result-code handling without claiming that Safaricom sent the request.
curl -X POST http://localhost:3000/api/mpesa/callback ^ -H "Content-Type: application/json" ^ -d "{"Body":{"stkCallback":{"MerchantRequestID":"local-test","CheckoutRequestID":"local-checkout","ResultCode":0,"ResultDesc":"Local test"}}}"Chapter 7: Handle callbacks without false payment success
The callback is the first place where the application can evaluate the customer’s final STK result. Keep the two stages separate: initiation tells you that Daraja accepted a request, while the callback tells you what happened to that request. A later verification step is still useful for uncertain or delayed transactions.
Part 8: Extract callback metadata safely
Step 21: Add a metadata helper
Successful callbacks contain an Item array whose fields can vary by product and response. Use a name-based lookup instead of assuming that every item is present or always at the same index.
function getCallbackMetadata(items = []) { return items.reduce((metadata, item) => { if (item?.Name) { metadata[item.Name] = item.Value; } return metadata; }, {});}function parseStkCallback(payload) { const callback = payload?.Body?.stkCallback; if (!callback?.CheckoutRequestID || callback.ResultCode === undefined) { throw new Error('Incomplete STK callback payload'); } const metadata = getCallbackMetadata(callback.CallbackMetadata?.Item); return { merchantRequestId: callback.MerchantRequestID, checkoutRequestId: callback.CheckoutRequestID, resultCode: Number(callback.ResultCode), resultDescription: callback.ResultDesc || '', successful: Number(callback.ResultCode) === 0, amount: metadata.Amount, receiptNumber: metadata.MpesaReceiptNumber, transactionDate: metadata.TransactionDate, phoneNumber: metadata.PhoneNumber, };}module.exports = { getCallbackMetadata, parseStkCallback };Use receiptNumber, amount, phoneNumber, and transactionDate only after the result code confirms success. For failed callbacks, retain the result code and description so the customer-support and reconciliation workflows can explain what happened.
Chapter 8: Implement C2B validation and confirmation URLs
C2B is a different flow from STK Push. A customer pays a business shortcode, and Safaricom sends your system a validation request where external validation is enabled, followed by a confirmation notification after processing. C2B requires a suitable business shortcode and the current Daraja product configuration; it is not a replacement for STK Push checkout.
Part 9: Register the C2B URLs
Step 22: Create validation and confirmation handlers
The validation endpoint must respond quickly with the response format required by the current Daraja C2B documentation. The example below accepts the request after basic shape checks. In a real system, validation should also check the account reference and business rules before accepting a payment.
const express = require('express');const router = express.Router();router.post('/validation', (req, res) => { const { TransAmount, BillRefNumber } = req.body; if (!TransAmount || !BillRefNumber) { return res.status(400).json({ ResultCode: 'C2B00011', ResultDesc: 'Invalid payment details', }); } console.log('C2B validation request received', { amount: TransAmount, accountReference: BillRefNumber, }); return res.status(200).json({ ResultCode: 0, ResultDesc: 'Accepted', });});router.post('/confirmation', (req, res) => { console.log('C2B confirmation received', { transactionId: req.body?.TransID, amount: req.body?.TransAmount, shortcode: req.body?.BusinessShortCode, accountReference: req.body?.BillRefNumber, phoneNumber: req.body?.MSISDN, }); return res.status(200).json({ ResultCode: 0, ResultDesc: 'Accepted' });});module.exports = router;Mount the C2B router alongside the existing M-Pesa router. Keep validation and confirmation as separate URLs so Safaricom can call the correct stage of the payment flow.
const c2bRouter = require('./routes/c2b');app.use('/api/c2b', c2bRouter);Step 23: Register the URLs with Daraja
Register the public validation and confirmation URLs using the C2B register URL endpoint. The request uses the same server-side OAuth helper from Chapter 3. Use the sandbox host and sandbox shortcode while testing; switch to production only with the approved live configuration.
const axios = require('axios');const { getDarajaBaseUrl } = require('../config/daraja');const { getAccessToken } = require('./daraja-auth');async function registerC2bUrls() { const shortcode = process.env.DARAJA_SHORTCODE; const validationUrl = process.env.DARAJA_VALIDATION_URL; const confirmationUrl = process.env.DARAJA_CONFIRMATION_URL; if (!shortcode || !validationUrl || !confirmationUrl) { throw new Error('C2B shortcode and callback URLs are required'); } const accessToken = await getAccessToken(); try { const response = await axios.post( `${getDarajaBaseUrl()}/mpesa/c2b/v2/registerurl`, { ShortCode: shortcode, ResponseType: 'Completed', ConfirmationURL: confirmationUrl, ValidationURL: validationUrl, }, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, timeout: 15000, } ); return response.data; } catch (error) { const message = error.response?.data?.errorMessage || error.message; throw new Error(`C2B URL registration failed: ${message}`); }}module.exports = { registerC2bUrls };DARAJA_VALIDATION_URL=https://your-domain.example/api/c2b/validationDARAJA_CONFIRMATION_URL=https://your-domain.example/api/c2b/confirmationC2B operational notes
Validation is a product and account configuration concern; confirm from the current Daraja documentation whether external validation is enabled for your setup. Confirmation processing should be fast and idempotent. Do not treat a received notification as permission to insert duplicate transactions, and do not claim C2B registration succeeded until you have tested it in your configured environment.
Check the current STK Push callback fields, endpoint behavior, and environment-specific requirements.
Check the current C2B validation, confirmation, URL registration, and response requirements before production use.
Chapter 9: Save transactions in MongoDB
The callback is the source of the payment result, but it is not a database. Store every initiation and callback with a stable provider identifier so you can display status, prevent duplicate fulfilment, and investigate disputes.
Part 10: Create a transaction model
const mongoose = require('mongoose');const transactionSchema = new mongoose.Schema( { checkoutRequestId: { type: String, unique: true, sparse: true }, merchantRequestId: String, amount: { type: Number, required: true }, phoneNumber: { type: String, required: true }, accountReference: { type: String, required: true }, status: { type: String, enum: ['pending', 'paid', 'failed', 'unknown'], default: 'pending', }, resultCode: Number, resultDescription: String, receiptNumber: String, transactionDate: String, }, { timestamps: true });module.exports = mongoose.model('Transaction', transactionSchema);Step 24: Save callback results idempotently
Create a pending record when the STK initiation is accepted, then use CheckoutRequestID as the idempotency key for callbacks. A repeated callback should update the existing transaction, not create a second payment. The update below is intentionally separate from the callback response so the route can acknowledge quickly after validation.
const Transaction = require('../models/transaction');const { parseStkCallback } = require('../utils/callback-metadata');async function createPendingTransaction({ checkoutRequestId, merchantRequestId, amount, phoneNumber, accountReference }) { return Transaction.create({ checkoutRequestId, merchantRequestId, amount, phoneNumber, accountReference, status: 'pending', });}async function saveStkCallback(payload) { const payment = parseStkCallback(payload); return Transaction.findOneAndUpdate( { checkoutRequestId: payment.checkoutRequestId }, { $set: { merchantRequestId: payment.merchantRequestId, resultCode: payment.resultCode, resultDescription: payment.resultDescription, status: payment.successful ? 'paid' : 'failed', amount: payment.amount, receiptNumber: payment.receiptNumber, transactionDate: payment.transactionDate, phoneNumber: payment.phoneNumber, }, }, { new: true, upsert: false, runValidators: true } );}module.exports = { createPendingTransaction, saveStkCallback };Fulfil only once
Use the paid transition as the trigger for fulfilment, and make fulfilment idempotent too. A payment callback can be retried, delayed, or received more than once.
Chapter 10: Verify status and reconcile payments
Callbacks are normally sufficient for the first result, but networks fail and customers may close a prompt before your server receives the notification. Keep pending or unknown transactions and reconcile them with the current Daraja transaction-status API and your own records.
Part 11: Query an uncertain transaction
const axios = require('axios');const { getDarajaBaseUrl } = require('../config/daraja');const { getAccessToken } = require('./daraja-auth');async function queryTransactionStatus({ transactionId, shortcode, identifierType = 1 }) { const accessToken = await getAccessToken(); const response = await axios.post( `${getDarajaBaseUrl()}/mpesa/transactionstatus/v1/query`, { Initiator: process.env.DARAJA_INITIATOR_NAME, SecurityCredential: process.env.DARAJA_SECURITY_CREDENTIAL, CommandID: 'TransactionStatusQuery', TransactionID: transactionId, PartyA: shortcode, IdentifierType: identifierType, ResultURL: process.env.DARAJA_RESULT_URL, QueueTimeOutURL: process.env.DARAJA_TIMEOUT_URL, Remarks: 'Payment reconciliation', Occasion: 'Payment status check', }, { headers: { Authorization: `Bearer ${accessToken}` }, timeout: 15000, } ); return response.data;}module.exports = { queryTransactionStatus };The transaction-status product has additional production credentials and security fields. Confirm the current official requirements before enabling it; never invent SecurityCredential values. Reconciliation should compare the provider result with your order, amount, shortcode, and stored identifiers before marking an order paid.
Step 25: Use a reconciliation policy
- 1
Keep pending transactions
Do not delete an unanswered payment. Record when it was initiated and retry status checks with a bounded schedule.
- 2
Match identifiers and amount
Compare the checkout or receipt identifier, expected amount, shortcode, and customer reference before fulfilment.
- 3
Resolve uncertainty explicitly
Use pending, paid, failed, and unknown states instead of treating a timeout as a successful payment.
Chapter 11: Secure the integration
Payment endpoints are high-value targets. Validate all client input, keep Daraja secrets server-side, use HTTPS for public callbacks, redact tokens and passkeys from logs, and restrict administrative routes. Never trust a client-submitted paid flag or amount.
| Risk | Protection |
|---|---|
| Credential leakage | Use .env or a secret manager, never source control or frontend code. |
| Fake payment callbacks | Use HTTPS, validate payloads, match identifiers, and reconcile uncertain records. |
| Duplicate fulfilment | Use unique provider IDs and idempotent updates. |
| Amount tampering | Load the amount from the server-side order, not directly from the browser. |
| Sensitive logs | Redact access tokens, passkeys, full payloads, and customer data. |
Chapter 12: Troubleshoot common problems
| Symptom | First checks |
|---|---|
| 401 or invalid token | Confirm environment, Consumer Key, Consumer Secret, Basic Auth, and server time. |
| Invalid password | Recheck shortcode, passkey, timestamp format, and Base64 concatenation order. |
| Callback never arrives | Confirm the URL is public, reachable, correctly configured, and accepts POST JSON. |
| Customer receives no prompt | Check phone format, amount, shortcode, sandbox configuration, and Daraja response details. |
| Duplicate payment records | Use CheckoutRequestID or TransID as a unique key and make updates idempotent. |
| MongoDB connection fails | Check MONGODB_URI, the local service, Atlas network access, and database credentials. |
Log correlation IDs and response codes, not secrets. When an error remains unclear, compare your request with the current official Daraja example for the exact product and environment instead of copying a sample credential or endpoint from an old tutorial.
Chapter 13: Test in sandbox and prepare for production
Part 12: Use a controlled release checklist
- 1
Test sandbox success and failure
Test accepted initiation, customer cancellation, timeout, invalid input, callback retries, and database failures.
- 2
Verify observability
Confirm that every transaction has a correlation ID, status, timestamps, provider identifiers, and redacted logs.
- 3
Separate production configuration
Use production credentials, shortcode, passkey, callback URLs, and approved Daraja products only after go-live approval.
- 4
Run a small live test
After approval, use a controlled amount and verify the callback, receipt, database record, and reconciliation result before wider release.
Final reminder
Sandbox testing is not production approval, and an STK Push initiation response is not a payment receipt. Move to production only with the correct Safaricom business service, live credentials, approved callback configuration, monitoring, and a tested reconciliation process.
Use the official MongoDB documentation for indexes, transactions, connection security, and deployment operations.
