The Complete Developer Guide to M-Pesa POS Integration
Integrating M-Pesa into a POS system is one of the most critical tasks for any developer building payment solutions for the Kenyan market. Safaricom's Daraja API documentation covers the raw API endpoints, but it leaves a gap between "here is the API" and "here is how you build a production-ready POS integration that handles real money reliably." This guide bridges that gap. We cover everything from architecture decisions and authentication through STK Push, C2B, and B2C integrations, to production hardening, security, and a complete troubleshooting reference. Whether you are building a custom M-Pesa POS system or integrating M-Pesa into an existing platform, this is your definitive technical resource.
Who is this guide for?
Software developers, technical leads, and system architects building or maintaining POS systems that accept M-Pesa payments in Kenya. Assumes working knowledge of Node.js/JavaScript, REST APIs, and basic understanding of payment processing.
Architecture Overview
How M-Pesa Payments Flow in a POS System
Understanding the end-to-end payment flow is essential before writing any code. Here is how an M-Pesa STK Push payment moves through a POS system:
Choosing Your Integration Type: STK Push vs C2B vs B2C
Daraja offers three primary APIs relevant to POS systems. Each serves a different purpose, and most production POS systems use a combination of all three.
| API | Direction | POS Use Case | Speed |
|---|---|---|---|
| STK Push (Lipa Na M-Pesa Online) | Customer → Business | Primary checkout payment. Merchant-initiated, fastest experience. | 10-15 seconds |
| C2B (Customer to Business) | Customer → Business | Fallback when STK Push fails. Customer dials USSD or uses M-Pesa app to pay manually via Till/Paybill. | 30-60 seconds |
| B2C (Business to Customer) | Business → Customer | Refunds, change disbursement, loyalty payouts. | 5-30 seconds |
When to use each in a POS context:
- STK Push should be your default payment method. It is the fastest and minimizes human error because the amount is pre-filled on the customer phone.
- C2B serves as a fallback. If the customer phone does not support STK Push (rare but possible with very old devices) or if the STK Push times out, the customer can pay manually using your Till Number. Your POS needs to match incoming C2B payments to open orders.
- B2C handles refunds. When a customer returns an item or a transaction needs to be reversed, B2C sends money back to the customer M-Pesa account programmatically.
For a deeper comparison of STK Push and Till Number payment flows from a business perspective, see our STK Push vs Till Number comparison.
Prerequisites and Setup
Daraja API Registration
- Go to developer.safaricom.co.ke and create an account.
- Create a new app in the dashboard. Select the APIs you need: Lipa Na M-Pesa Online (STK Push), C2B, B2C, and Transaction Status.
- Note your Consumer Key and Consumer Secret from the app details page. These are used for OAuth authentication.
Getting API Credentials
You will receive two sets of credentials:
- Consumer Key and Consumer Secret - Used to generate OAuth access tokens. Treat these like database passwords.
- Passkey (for STK Push) - Provided by Safaricom for Lipa Na M-Pesa Online. Used to generate the transaction password.
- Initiator Name and Security Credential (for B2C) - Provided separately when B2C is approved.
Sandbox vs Production
| Environment | Base URL | Purpose |
|---|---|---|
| Sandbox | https://sandbox.safaricom.co.ke | Development and testing. Uses test credentials, no real money. |
| Production | https://api.safaricom.co.ke | Live transactions with real money. Requires go-live approval. |
Callback URL Setup
M-Pesa sends payment results to your server via HTTP POST callbacks. Your callback URL must:
- Be publicly accessible (no localhost, no private IPs)
- Use HTTPS with a valid SSL certificate from a trusted CA
- Respond within 5 seconds (Safaricom times out and retries)
- Return an HTTP 200 response to acknowledge receipt
- Be idempotent (same callback may arrive more than once)
Important: SSL/TLS Requirements
Self-signed certificates are not accepted by Safaricom. Use a certificate from a trusted CA. Free options like Let's Encrypt work perfectly. During development, use a tunneling service like ngrok to expose your local server over HTTPS.
Authentication
OAuth 2.0 Token Generation
Every Daraja API call requires a Bearer token. Tokens are obtained via the OAuth endpoint using your Consumer Key and Consumer Secret encoded as a Base64 string. Tokens expire after 3599 seconds (approximately 1 hour).
Token Caching Strategy
Generating a new token for every API call is wasteful and can hit rate limits. Cache the token in memory (or Redis for multi-server setups) and refresh it 5 minutes before expiry:
const axios = require('axios')
const Buffer = require('buffer').Buffer
// Token cache
let cachedToken = null
let tokenExpiry = 0
const CONSUMER_KEY = process.env.MPESA_CONSUMER_KEY
const CONSUMER_SECRET = process.env.MPESA_CONSUMER_SECRET
const BASE_URL = process.env.MPESA_ENV === 'production'
? 'https://api.safaricom.co.ke'
: 'https://sandbox.safaricom.co.ke'
async function getAccessToken() {
// Return cached token if still valid (5-min buffer)
if (cachedToken && Date.now() < tokenExpiry - 300000) {
return cachedToken
}
const credentials = Buffer.from(
`${CONSUMER_KEY}:${CONSUMER_SECRET}`
).toString('base64')
const response = await axios.get(
`${BASE_URL}/oauth/v1/generate?grant_type=client_credentials`,
{
headers: {
Authorization: `Basic ${credentials}`
}
}
)
cachedToken = response.data.access_token
tokenExpiry = Date.now() + (3599 * 1000)
return cachedToken
}
module.exports = { getAccessToken }STK Push Integration (Lipa Na M-Pesa Online)
How It Works
STK Push is the primary payment method for POS. The technical flow is:
- Your POS backend sends a POST request to the STK Push endpoint with the customer phone number, amount, and account reference.
- Safaricom validates the request and sends a payment prompt (USSD push) to the customer phone.
- The customer sees the merchant name and amount, then enters their M-Pesa PIN.
- Safaricom processes the payment (debit customer, credit merchant).
- Safaricom sends an HTTP POST callback to your CallBackURL with the result (success or failure).
- Your backend processes the callback, updates the order status, and notifies the POS terminal.
Request Payload Reference
| Field | Type | Description | Example |
|---|---|---|---|
BusinessShortCode | String | Your Lipa Na M-Pesa Online shortcode (Paybill or Till) | 174379 |
Password | String | Base64 encoded string of BusinessShortCode + Passkey + Timestamp | (generated) |
Timestamp | String | Format: YYYYMMDDHHmmss (e.g., 20260225143000) | 20260225143000 |
TransactionType | String | Always "CustomerPayBillOnline" for Paybill or "CustomerBuyGoodsOnline" for Till | CustomerPayBillOnline |
Amount | Number | Transaction amount in whole KES (no decimals) | 1500 |
PartyA | String | Customer phone number (format: 2547XXXXXXXX) | 254712345678 |
PartyB | String | Same as BusinessShortCode | 174379 |
PhoneNumber | String | Customer phone number to receive STK Push | 254712345678 |
CallBackURL | String | Your HTTPS endpoint to receive the payment result | https://api.yoursite.com/mpesa/callback |
AccountReference | String | Identifier shown on customer phone. Use your POS order ID. | ORD-20260225-001 |
TransactionDesc | String | Description of the transaction | Payment for Order ORD-001 |
Password Generation
The Password field is a Base64-encoded concatenation of three values: your BusinessShortCode, the Passkey provided by Safaricom, and the current Timestamp. This must be generated fresh for every request because the Timestamp changes:
function generatePassword(shortcode, passkey) {
const timestamp = new Date()
.toISOString()
.replace(/[-T:.Z]/g, '')
.slice(0, 14)
const password = Buffer.from(
`${shortcode}${passkey}${timestamp}`
).toString('base64')
return { password, timestamp }
}Code Example: Initiating STK Push
const { getAccessToken } = require('./auth')
const axios = require('axios')
const SHORTCODE = process.env.MPESA_SHORTCODE
const PASSKEY = process.env.MPESA_PASSKEY
const CALLBACK_URL = process.env.MPESA_CALLBACK_URL
const BASE_URL = process.env.MPESA_BASE_URL
async function initiateSTKPush(phoneNumber, amount, orderId) {
const token = await getAccessToken()
const { password, timestamp } = generatePassword(SHORTCODE, PASSKEY)
const payload = {
BusinessShortCode: SHORTCODE,
Password: password,
Timestamp: timestamp,
TransactionType: 'CustomerPayBillOnline',
Amount: Math.round(amount), // Must be whole number
PartyA: formatPhone(phoneNumber),
PartyB: SHORTCODE,
PhoneNumber: formatPhone(phoneNumber),
CallBackURL: `${CALLBACK_URL}/api/mpesa/stk-callback`,
AccountReference: orderId,
TransactionDesc: `Payment for ${orderId}`
}
try {
const response = await axios.post(
`${BASE_URL}/mpesa/stkpush/v1/processrequest`,
payload,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
)
// Store CheckoutRequestID for status queries
const { MerchantRequestID, CheckoutRequestID } = response.data
await saveTransaction({
orderId,
checkoutRequestId: CheckoutRequestID,
merchantRequestId: MerchantRequestID,
phoneNumber: formatPhone(phoneNumber),
amount: Math.round(amount),
status: 'pending',
createdAt: new Date()
})
return {
success: true,
checkoutRequestId: CheckoutRequestID,
merchantRequestId: MerchantRequestID
}
} catch (error) {
console.error('STK Push failed:', error.response?.data || error.message)
return {
success: false,
error: error.response?.data?.errorMessage || 'STK Push request failed'
}
}
}
// Format phone number to 2547XXXXXXXX format
function formatPhone(phone) {
let cleaned = phone.replace(/\D/g, '')
if (cleaned.startsWith('0')) cleaned = '254' + cleaned.slice(1)
if (cleaned.startsWith('+')) cleaned = cleaned.slice(1)
if (!cleaned.startsWith('254')) cleaned = '254' + cleaned
return cleaned
}Handling the Callback Response
Safaricom sends the payment result to your CallBackURL as an HTTP POST with a JSON body. There are two distinct structures depending on whether the payment succeeded or failed:
Successful callback structure:
{ // ResultCode 0 = Success
"Body": {
"stkCallback": {
"MerchantRequestID": "29115-34620561-1",
"CheckoutRequestID": "ws_CO_191220191020363925",
"ResultCode": 0,
"ResultDesc": "The service request is processed successfully.",
"CallbackMetadata": {
"Item": [
{ "Name": "Amount", "Value": 1500 },
{ "Name": "MpesaReceiptNumber", "Value": "SFH7TQ4OLE" },
{ "Name": "TransactionDate", "Value": 20260225143025 },
{ "Name": "PhoneNumber", "Value": 254712345678 }
]
}
}
}
}Failed callback structure:
{ // ResultCode != 0 = Failure
"Body": {
"stkCallback": {
"MerchantRequestID": "29115-34620561-1",
"CheckoutRequestID": "ws_CO_191220191020363925",
"ResultCode": 1032,
"ResultDesc": "Request cancelled by user"
// Note: No CallbackMetadata on failure
}
}
}Code Example: Callback Handler
const express = require('express')
const router = express.Router()
// POST /api/mpesa/stk-callback
router.post('/stk-callback', async (req, res) => {
// ALWAYS respond 200 immediately to prevent Safaricom retries
res.status(200).json({ ResultCode: 0, ResultDesc: 'Accepted' })
try {
const callback = req.body.Body.stkCallback
const { CheckoutRequestID, ResultCode, ResultDesc } = callback
if (ResultCode === 0) {
// Payment successful - extract metadata
const metadata = callback.CallbackMetadata.Item
const getValue = (name) =>
metadata.find(i => i.Name === name)?.Value
const paymentData = {
amount: getValue('Amount'),
receiptNumber: getValue('MpesaReceiptNumber'),
transactionDate: getValue('TransactionDate'),
phoneNumber: getValue('PhoneNumber')
}
// Update transaction in database
await updateTransaction(CheckoutRequestID, {
status: 'completed',
mpesaReceiptNumber: paymentData.receiptNumber,
completedAt: new Date(),
...paymentData
})
// Notify POS terminal via WebSocket or SSE
await notifyPOS(CheckoutRequestID, {
status: 'paid',
receiptNumber: paymentData.receiptNumber
})
} else {
// Payment failed
await updateTransaction(CheckoutRequestID, {
status: 'failed',
resultCode: ResultCode,
resultDesc: ResultDesc,
failedAt: new Date()
})
await notifyPOS(CheckoutRequestID, {
status: 'failed',
reason: ResultDesc
})
}
} catch (error) {
console.error('Callback processing error:', error)
// Do NOT re-throw - we already sent 200
}
})Querying Transaction Status
If your callback does not arrive (network issues, server downtime), use the STK Push Query endpoint to check the transaction status:
async function querySTKStatus(checkoutRequestId) {
const token = await getAccessToken()
const { password, timestamp } = generatePassword(SHORTCODE, PASSKEY)
const response = await axios.post(
`${BASE_URL}/mpesa/stkpushquery/v1/query`,
{
BusinessShortCode: SHORTCODE,
Password: password,
Timestamp: timestamp,
CheckoutRequestID: checkoutRequestId
},
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
)
return response.data
// ResultCode 0 = success, 1032 = cancelled, 1037 = timeout
}Common STK Push Error Codes
| ResultCode | Description | How to Handle in POS |
|---|---|---|
0 | Success | Mark order as paid, print receipt |
1 | Insufficient balance | Show "Insufficient M-Pesa balance" message |
1032 | Request cancelled by user | Show "Payment cancelled" - offer retry |
1037 | DS timeout (user took too long) | Show "Payment timed out" - offer retry |
1025 | Transaction limit exceeded | Customer has hit daily/transaction limit - suggest smaller amount or alternative |
1019 | Transaction expired | STK prompt expired before customer acted - re-initiate |
2001 | Wrong PIN entered | Show "Incorrect PIN" - customer can retry on their phone |
1001 | Unable to lock subscriber | Another transaction is pending on this number - wait and retry |
1 | General error / insufficient balance | Log the full response, display generic error, offer retry |
17 | System internal error | Safaricom-side issue - retry after 30 seconds |
C2B API Integration
How C2B Works in a POS Context
C2B (Customer to Business) handles payments where the customer initiates the transaction manually, either by dialing the USSD code or using the M-Pesa app to pay to your Till Number or Paybill. In a POS context, C2B acts as a fallback for STK Push failures and also captures payments that arrive outside your POS workflow (e.g., a customer pays before arriving at the counter).
Registering Validation and Confirmation URLs
Before receiving C2B payments, you must register two callback URLs with Safaricom:
- Validation URL - Called before the transaction is processed. You can accept or reject the payment (e.g., reject if the account reference does not match an open order).
- Confirmation URL - Called after the transaction is completed. This is where you update your POS records.
async function registerC2BUrls() {
const token = await getAccessToken()
const response = await axios.post(
`${BASE_URL}/mpesa/c2b/v1/registerurl`,
{
ShortCode: SHORTCODE,
ResponseType: 'Completed', // or "Cancelled" to reject by default
ConfirmationURL: `${CALLBACK_URL}/api/mpesa/c2b-confirm`,
ValidationURL: `${CALLBACK_URL}/api/mpesa/c2b-validate`
},
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
)
console.log('C2B URLs registered:', response.data)
return response.data
}Matching C2B Payments to POS Transactions
The key challenge with C2B is matching incoming payments to open POS orders. Strategies include:
- Account Reference matching: Instruct the customer to enter the order ID as the account number. In validation, check if the order exists and the amount matches.
- Amount + Phone matching: Match by the combination of phone number and exact amount within a time window.
- Manual reconciliation: Show unmatched C2B payments in a POS dashboard for the cashier to manually assign to orders.
// Validation handler - accept or reject payment
router.post('/c2b-validate', async (req, res) => {
const { BillRefNumber, TransAmount, MSISDN } = req.body
// Check if order exists and amount matches
const order = await findOrder(BillRefNumber)
if (!order) {
return res.json({
ResultCode: 1,
ResultDesc: 'Order not found'
})
}
if (parseFloat(TransAmount) < order.totalAmount) {
return res.json({
ResultCode: 1,
ResultDesc: 'Insufficient amount'
})
}
// Accept payment
res.json({ ResultCode: 0, ResultDesc: 'Accepted' })
})
// Confirmation handler - payment completed
router.post('/c2b-confirm', async (req, res) => {
res.status(200).json({ ResultCode: 0 })
const {
TransID, TransAmount, BillRefNumber,
MSISDN, TransTime
} = req.body
await recordC2BPayment({
transactionId: TransID,
amount: parseFloat(TransAmount),
accountReference: BillRefNumber,
phoneNumber: MSISDN,
transactionTime: TransTime,
status: 'completed'
})
// Try to match with open POS order
const order = await findOrder(BillRefNumber)
if (order) {
await markOrderPaid(order.id, TransID)
await notifyPOS(order.id, { status: 'paid', via: 'c2b' })
}
})B2C API Integration (Refunds)
When to Use B2C in POS
B2C (Business to Customer) sends money from your M-Pesa business account to a customer's personal M-Pesa account. In a POS context, the primary use cases are:
- Refunds: Customer returns an item and you need to refund the M-Pesa payment.
- Overpayment correction: Customer paid more than the order total via C2B.
- Change disbursement: In rare cases, sending M-Pesa "change" when the customer overpaid.
B2C requires separate approval
B2C uses different credentials from STK Push. You need an Initiator Name and Security Credential, which are obtained through a separate approval process with Safaricom. Apply early in your development timeline.
Security Credential Generation
The B2C Security Credential is your Initiator Password encrypted with Safaricom's public certificate. The certificate differs between sandbox and production:
const crypto = require('crypto')
const fs = require('fs')
function generateSecurityCredential(initiatorPassword) {
// Use sandbox cert for testing, production cert for live
const certPath = process.env.MPESA_ENV === 'production'
? './certs/ProductionCertificate.cer'
: './certs/SandboxCertificate.cer'
const cert = fs.readFileSync(certPath, 'utf8')
const buffer = Buffer.from(initiatorPassword)
const encrypted = crypto.publicEncrypt(
{
key: cert,
padding: crypto.constants.RSA_PKCS1_PADDING
},
buffer
)
return encrypted.toString('base64')
}Code Example: Processing a Refund
async function processRefund(phoneNumber, amount, originalTransactionId) {
const token = await getAccessToken()
const securityCredential = generateSecurityCredential(
process.env.MPESA_INITIATOR_PASSWORD
)
const payload = {
InitiatorName: process.env.MPESA_INITIATOR_NAME,
SecurityCredential: securityCredential,
CommandID: 'BusinessPayment',
Amount: Math.round(amount),
PartyA: SHORTCODE,
PartyB: formatPhone(phoneNumber),
Remarks: `Refund for ${originalTransactionId}`,
QueueTimeOutURL: `${CALLBACK_URL}/api/mpesa/b2c-timeout`,
ResultURL: `${CALLBACK_URL}/api/mpesa/b2c-result`,
Occasion: 'POS Refund'
}
try {
const response = await axios.post(
`${BASE_URL}/mpesa/b2c/v1/paymentrequest`,
payload,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
)
await saveRefund({
conversationId: response.data.ConversationID,
originatorConversationId: response.data.OriginatorConversationID,
originalTransactionId,
phoneNumber: formatPhone(phoneNumber),
amount: Math.round(amount),
status: 'pending'
})
return { success: true, conversationId: response.data.ConversationID }
} catch (error) {
console.error('B2C refund failed:', error.response?.data)
return { success: false, error: error.response?.data?.errorMessage }
}
}Transaction Status and Reversal APIs
Checking Transaction Status Programmatically
The Transaction Status API lets you check the status of any M-Pesa transaction using its receipt number or conversation ID. This is essential for reconciliation and for cases where callbacks are missed:
async function checkTransactionStatus(transactionId) {
const token = await getAccessToken()
const securityCredential = generateSecurityCredential(
process.env.MPESA_INITIATOR_PASSWORD
)
const response = await axios.post(
`${BASE_URL}/mpesa/transactionstatus/v1/query`,
{
Initiator: process.env.MPESA_INITIATOR_NAME,
SecurityCredential: securityCredential,
CommandID: 'TransactionStatusQuery',
TransactionID: transactionId,
PartyA: SHORTCODE,
IdentifierType: '4', // 4 = shortcode
ResultURL: `${CALLBACK_URL}/api/mpesa/status-result`,
QueueTimeOutURL: `${CALLBACK_URL}/api/mpesa/status-timeout`,
Remarks: 'POS status check',
Occasion: 'StatusQuery'
},
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
)
return response.data
}Reversing Transactions
The Reversal API reverses a completed M-Pesa transaction. This is useful when you need to reverse a charge without using B2C (e.g., the customer was charged the wrong amount). Note that reversals are subject to Safaricom approval and the funds must be available in your account:
async function reverseTransaction(transactionId, amount) {
const token = await getAccessToken()
const securityCredential = generateSecurityCredential(
process.env.MPESA_INITIATOR_PASSWORD
)
const response = await axios.post(
`${BASE_URL}/mpesa/reversal/v1/request`,
{
Initiator: process.env.MPESA_INITIATOR_NAME,
SecurityCredential: securityCredential,
CommandID: 'TransactionReversal',
TransactionID: transactionId,
Amount: amount,
ReceiverParty: SHORTCODE,
RecieverIdentifierType: '11', // 11 = reversal
ResultURL: `${CALLBACK_URL}/api/mpesa/reversal-result`,
QueueTimeOutURL: `${CALLBACK_URL}/api/mpesa/reversal-timeout`,
Remarks: 'POS transaction reversal',
Occasion: 'Reversal'
},
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
)
return response.data
}Production Best Practices
Idempotency (Preventing Double Charges)
Double charges are the most damaging bug in any payment integration. Implement idempotency at multiple levels:
- POS level: Generate a unique transaction ID for each order. Before initiating STK Push, check if a pending or completed M-Pesa transaction already exists for this order.
- Callback level: Use the CheckoutRequestID as a unique key. If you receive a callback for a CheckoutRequestID you have already processed, ignore it.
- Cooldown period: Prevent re-initiating STK Push to the same phone number within 60 seconds.
async function safeInitiateSTKPush(phoneNumber, amount, orderId) {
// Check 1: Does this order already have a payment?
const existing = await findTransaction({ orderId })
if (existing && ['pending', 'completed'].includes(existing.status)) {
return {
success: false,
error: 'Payment already in progress for this order'
}
}
// Check 2: Cooldown per phone number
const recentTx = await findRecentTransaction({
phoneNumber: formatPhone(phoneNumber),
createdAfter: new Date(Date.now() - 60000) // 60 seconds
})
if (recentTx) {
return {
success: false,
error: 'Please wait before retrying. A recent payment was sent to this number.'
}
}
// Safe to proceed
return initiateSTKPush(phoneNumber, amount, orderId)
}Callback Security (IP Whitelisting)
Anyone who knows your callback URL could send fake payment confirmations. Protect your callback endpoints:
- IP whitelisting: Only accept callbacks from Safaricom IP ranges (196.201.214.0/24, 196.201.214.200/28). Verify the current list from Safaricom documentation.
- Cross-reference: After receiving a callback, query the Transaction Status API to independently verify the payment.
- HTTPS only: Never accept callbacks over plain HTTP.
Retry Logic with Exponential Backoff
When Daraja API calls fail (network errors, 5xx responses, rate limits), implement retries with exponential backoff to avoid overwhelming the API:
async function withRetry(fn, maxRetries = 3, baseDelay = 1000) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn()
} catch (error) {
const isRetryable =
error.response?.status >= 500 ||
error.response?.status === 429 ||
error.code === 'ECONNRESET'
if (!isRetryable || attempt === maxRetries - 1) {
throw error
}
const delay = baseDelay * Math.pow(2, attempt)
const jitter = Math.random() * 1000
await new Promise(r => setTimeout(r, delay + jitter))
}
}
}
// Usage
const result = await withRetry(
() => initiateSTKPush(phone, amount, orderId),
3, // max 3 retries
2000 // start at 2 second delay
)Database Transaction Recording
Every M-Pesa interaction must be recorded in your database for reconciliation, auditing, and dispute resolution. Minimum fields to store:
- CheckoutRequestID - Links the STK Push initiation to the callback
- MerchantRequestID - Safaricom internal reference
- MpesaReceiptNumber - The customer-facing receipt (e.g., SFH7TQ4OLE)
- Phone number, amount, timestamp - Transaction details
- Status - pending, completed, failed, reversed
- Raw callback payload - Store the full JSON for debugging
- POS order ID - Links to your internal order
Queue-Based Architecture for High Volume
For POS systems processing more than 10 transactions per second, process callbacks through a message queue (Redis, RabbitMQ, or AWS SQS):
- Callback endpoint receives the payload and immediately returns HTTP 200.
- Push the raw payload onto the queue.
- Worker processes consume from the queue and handle database updates, POS notifications, and reconciliation.
- Failed processing goes to a dead-letter queue for manual review.
This architecture ensures you never miss a callback (Safaricom gets the 200 response) and decouples receipt from processing.
Logging and Monitoring
In production, log every step of the M-Pesa flow:
- Log STK Push initiations with phone number (masked), amount, orderId, and timestamp.
- Log all callbacks with full payload (but mask sensitive data).
- Log status queries and their results.
- Set up alerts for: callback failure rate above 5%, average callback delay above 30 seconds, any B2C failures, and authentication token refresh failures.
- Dashboard metrics: transactions per minute, success rate, average completion time, and revenue processed.
Testing
Sandbox Testing Walkthrough
- Log in to developer.safaricom.co.ke and go to your app.
- Copy the sandbox Consumer Key and Consumer Secret.
- Set your environment to sandbox:
MPESA_ENV=sandbox - Use sandbox base URL:
https://sandbox.safaricom.co.ke - Set up a tunnel (e.g., ngrok) to expose your local callback endpoint.
- Initiate an STK Push to the test phone number.
- Verify you receive the callback on your endpoint.
Test Credentials
| Credential | Sandbox Value |
|---|---|
| Test Phone Number | 254708374149 |
| Test Shortcode (Lipa Na M-Pesa) | 174379 |
| Test Passkey | bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919 |
| Test Shortcode (C2B) | 600000 |
| Test Initiator (B2C) | testapi |
| Test Initiator Password | Safaricom999!*! |
Simulating Success, Failure, and Timeout
The sandbox automatically simulates a successful STK Push when you use the test phone number. To test failure scenarios in your code:
- Success: Use test phone number
254708374149with any amount. - User cancellation: Write a mock callback sender that sends a ResultCode 1032 payload to your callback endpoint.
- Timeout: Set a very short timeout (e.g., 5 seconds) in your polling logic and do not send a callback to test your fallback flow.
- Insufficient balance: Mock a callback with ResultCode 1 to test your error handling.
// Send a mock callback to test your handler locally
async function sendMockCallback(checkoutRequestId, success = true) {
const payload = success
? {
Body: {
stkCallback: {
MerchantRequestID: 'test-merchant-001',
CheckoutRequestID: checkoutRequestId,
ResultCode: 0,
ResultDesc: 'The service request is processed successfully.',
CallbackMetadata: {
Item: [
{ Name: 'Amount', Value: 1500 },
{ Name: 'MpesaReceiptNumber', Value: 'TEST123456' },
{ Name: 'TransactionDate', Value: 20260225143025 },
{ Name: 'PhoneNumber', Value: 254708374149 }
]
}
}
}
}
: {
Body: {
stkCallback: {
MerchantRequestID: 'test-merchant-001',
CheckoutRequestID: checkoutRequestId,
ResultCode: 1032,
ResultDesc: 'Request cancelled by user'
}
}
}
await axios.post('http://localhost:3000/api/mpesa/stk-callback', payload)
}Security Considerations
Storing API Credentials Securely
- Never hardcode Consumer Key, Consumer Secret, or Passkey in your source code.
- Use environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault, Google Secret Manager).
- Rotate credentials periodically and immediately if a breach is suspected.
- Use separate credentials for sandbox and production. Never use production credentials in development.
- Restrict access to credentials on a need-to-know basis within your development team.
Callback URL Validation
- Whitelist Safaricom callback IP addresses at the firewall and application level.
- Validate the callback payload structure before processing. Reject malformed payloads.
- Cross-reference every callback with your database. Only process callbacks for CheckoutRequestIDs you have actually initiated.
- Use HTTPS with TLS 1.2 or higher for all callback endpoints.
- Consider adding a shared secret header or HMAC signature for additional verification.
Data Encryption
- Encrypt phone numbers at rest in your database. They are personal data under Kenya's Data Protection Act.
- Mask phone numbers in logs (show only last 4 digits: ****5678).
- Use TLS for all API calls (Daraja requires this, but verify in your HTTP client configuration).
- Encrypt the stored raw callback payloads since they contain customer financial data.
- Comply with PCI DSS principles even though M-Pesa is not card-based. The same security discipline applies to mobile money data.
Troubleshooting Reference
Complete Daraja Error Code Reference
| Code | Description | Fix |
|---|---|---|
0 | Success | No action needed. Process payment normally. |
1 | Insufficient funds | Inform customer to top up M-Pesa balance and retry. |
2 | Less than minimum transaction value | Ensure amount is at least KES 1. Check your amount formatting. |
3 | More than maximum transaction value | M-Pesa has a KES 150,000 per-transaction limit. Split into multiple payments. |
11 | Invalid account number | Check your BusinessShortCode and PartyB values. |
17 | System internal error | Safaricom-side issue. Retry after 30-60 seconds. |
26 | System busy | Safaricom under load. Implement exponential backoff retry. |
1001 | Unable to lock subscriber | Another transaction is in progress on this phone number. Wait 30 seconds and retry. |
1019 | Transaction expired | The STK Push prompt expired before the customer acted. Re-initiate the request. |
1025 | Transaction limit exceeded | Customer exceeded daily M-Pesa limit. Suggest alternative payment or try next day. |
1032 | Request cancelled by user | Customer dismissed the prompt. Ask if they want to retry. |
1037 | DS timeout | Customer did not respond in time. Re-initiate the request. |
2001 | Wrong PIN | Customer entered wrong M-Pesa PIN. They can retry on their phone (if prompt still active). |
404.001.03 | Invalid access token | Token expired or invalid. Refresh your OAuth token and retry. |
404.001.04 | Bad request - invalid input | Check your request payload format, especially phone number format (2547XXXXXXXX). |
500.001.1001 | Internal server error | Safaricom backend error. Log and retry after delay. |
Top 10 Common Integration Mistakes
- Sending decimal amounts. M-Pesa only accepts whole numbers. Always use
Math.round()before sending the amount. Sending 1500.50 will cause a 400 error. - Wrong phone number format. Always format to
2547XXXXXXXX(12 digits, no + prefix). Common mistake: sending07XXXXXXXXor+2547XXXXXXXX. - Not caching OAuth tokens. Generating a new token per request wastes API calls and can trigger rate limiting. Cache tokens for their full validity period (about 1 hour).
- Slow callback response. Your callback endpoint must respond within 5 seconds. If you do heavy processing (database writes, external API calls), do it asynchronously after returning HTTP 200.
- Not handling duplicate callbacks. Safaricom may send the same callback multiple times. Use CheckoutRequestID as an idempotency key to prevent double-processing.
- Using self-signed SSL certificates. Safaricom rejects callbacks to endpoints with self-signed certs. Use a trusted CA (Let's Encrypt is free).
- Hardcoding credentials in source code. Consumer Key, Consumer Secret, and Passkey should never be in your codebase. Use environment variables or a secrets manager.
- Not implementing callback fallback. Callbacks can fail due to network issues. Always implement a polling mechanism using the STK Push Query API as a fallback.
- Ignoring timestamp format. The Password timestamp must be in
YYYYMMDDHHmmssformat. Getting the timezone wrong or using a different format causes authentication failures. - Not testing failure scenarios. Only testing happy-path (successful) payments. You must test cancellation, timeout, insufficient funds, and wrong PIN scenarios in your POS flow.
Frequently Asked Questions
Q: How long does it take to get Daraja API production credentials?
A: After submitting your go-live request on the Safaricom Developer Portal, production credentials typically take 3-5 business days. Ensure your application is fully tested in sandbox, your callback URLs are live and accessible over HTTPS, and all required business documents are uploaded.
Q: What is the difference between STK Push and C2B for POS systems?
A: STK Push (Lipa Na M-Pesa Online) is merchant-initiated: the POS sends a payment prompt to the customer phone. C2B is customer-initiated: the customer manually enters the Till or Paybill number. STK Push is faster for in-store POS checkout (10-15 seconds vs 30-60 seconds) and reduces human error since the amount is pre-filled.
Q: Can I use the Daraja sandbox for automated testing?
A: Yes. The Daraja sandbox supports automated testing with test credentials and simulated phone numbers. You can simulate successful payments, failed payments, and timeouts. Use the sandbox base URL https://sandbox.safaricom.co.ke and the test credentials from your Safaricom Developer Portal dashboard.
Q: How do I handle M-Pesa callback timeouts in my POS?
A: Implement a polling fallback using the Transaction Status API. After initiating STK Push, wait 30 seconds for the callback. If no callback arrives, query the transaction status. Retry up to 3 times with exponential backoff (30s, 60s, 120s). Always store the CheckoutRequestID so you can query later.
Q: What SSL certificate do I need for M-Pesa callbacks?
A: Safaricom requires a valid SSL/TLS certificate from a trusted Certificate Authority (CA) for all callback URLs. Self-signed certificates are not accepted. Free certificates from Let's Encrypt work perfectly. The certificate must cover the exact domain used in your callback URL.
Q: How do I prevent double charges with M-Pesa STK Push?
A: Use idempotency keys. Generate a unique identifier for each POS transaction and include it as the AccountReference in your STK Push request. Before initiating a new STK Push, check if a pending or completed transaction exists for that identifier. Also implement a cooldown period (e.g., 60 seconds) per phone number to prevent accidental duplicate triggers.
Q: What are the M-Pesa API rate limits?
A: Daraja API rate limits are per-app and depend on your production tier. Default production limits are approximately 40 transactions per second for STK Push. If you need higher throughput, contact Safaricom to request a rate limit increase. Always implement retry logic with exponential backoff for 429 (rate limit) responses.
Q: Can I process M-Pesa refunds programmatically?
A: Yes, use the B2C API (Business to Customer) to send refunds back to the customer M-Pesa account. You need the Initiator Name, Security Credential, and the original transaction details. Note that B2C requires separate approval from Safaricom and has different credentials from STK Push.
Q: How do I handle the "Request cancelled by user" error?
A: ResultCode 1032 means the customer dismissed the STK Push prompt without entering their PIN. In your POS, show a friendly message like "Payment was cancelled. Would you like to try again?" and allow the cashier to re-initiate the STK Push. Do not automatically retry without customer consent.
Q: What IP addresses should I whitelist for M-Pesa callbacks?
A: Safaricom publishes their callback IP ranges in the Daraja API documentation. As of 2025, the primary ranges include 196.201.214.0/24 and 196.201.214.200/28. Always verify the current list from Safaricom official documentation as these may change. Implement IP whitelisting at both application and firewall levels.
Q: How do I test M-Pesa integration without a real phone number?
A: Use the Daraja sandbox environment with the test phone number 254708374149. This number simulates a successful STK Push flow. For testing failures, you can use specific test scenarios documented in the Safaricom Developer Portal. No real money is involved in sandbox transactions.
Q: Does EliteTeQ POS handle all M-Pesa integration complexity?
A: Yes. EliteTeQ POS provides a pre-built M-Pesa integration that handles STK Push, C2B confirmation, callback processing, transaction status queries, error handling, and automatic reconciliation. You do not need to write any code or manage API credentials. The system is production-ready out of the box.
How EliteTeQ POS Handles M-Pesa
Building a production-grade M-Pesa integration from scratch takes weeks of development and ongoing maintenance. EliteTeQ POS includes a complete, battle-tested M-Pesa integration out of the box:
- STK Push with automatic retry: Initiates payment, handles timeouts, and automatically queries transaction status as a fallback.
- C2B matching: Automatically matches incoming Till Number payments to open POS orders by amount and phone number.
- Real-time POS updates: Payment status appears on the POS terminal within seconds of customer confirmation.
- Automatic reconciliation: Daily reconciliation reports comparing M-Pesa statements to POS transactions.
- Full KRA eTIMS integration: M-Pesa payments automatically generate compliant tax invoices.
- Error handling: Friendly cashier-facing error messages for every Daraja error code.
- Security: Credentials encrypted at rest, callback IP whitelisting, and full audit trail.
Explore all EliteTeQ POS features or book a free demo to see M-Pesa integration working immediately. Need help with a custom integration? Contact our team for developer support.
Skip the integration work. Get M-Pesa payments working today.
EliteTeQ POS includes a production-ready M-Pesa integration with STK Push, C2B, reconciliation, and KRA eTIMS compliance. Book a free demo: Book a Free Demo