The Complete Developer Guide to M-Pesa POS Integration

Published: February 25, 2026By: EliteTeQ POS TeamRead time: 25 minutes

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:

1. POS Terminal (Cashier initiates payment)
↓
2. Your Backend Server (Generates auth token, sends STK Push)
↓
3. Safaricom Daraja API (Processes request)
↓
4. Customer Phone (Receives payment prompt)
↓
5. Customer Enters PIN
↓
6. Safaricom Processes Payment
↓
7. Callback to Your Server (Success/Failure)
↓
8. POS Updates Order Status (Receipt printed)

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.

APIDirectionPOS Use CaseSpeed
STK Push (Lipa Na M-Pesa Online)Customer → BusinessPrimary checkout payment. Merchant-initiated, fastest experience.10-15 seconds
C2B (Customer to Business)Customer → BusinessFallback 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 → CustomerRefunds, 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

  1. Go to developer.safaricom.co.ke and create an account.
  2. Create a new app in the dashboard. Select the APIs you need: Lipa Na M-Pesa Online (STK Push), C2B, B2C, and Transaction Status.
  3. 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

EnvironmentBase URLPurpose
Sandboxhttps://sandbox.safaricom.co.keDevelopment and testing. Uses test credentials, no real money.
Productionhttps://api.safaricom.co.keLive 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:

auth.js - OAuth Token Generation with CachingNode.js
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:

  1. Your POS backend sends a POST request to the STK Push endpoint with the customer phone number, amount, and account reference.
  2. Safaricom validates the request and sends a payment prompt (USSD push) to the customer phone.
  3. The customer sees the merchant name and amount, then enters their M-Pesa PIN.
  4. Safaricom processes the payment (debit customer, credit merchant).
  5. Safaricom sends an HTTP POST callback to your CallBackURL with the result (success or failure).
  6. Your backend processes the callback, updates the order status, and notifies the POS terminal.

Request Payload Reference

FieldTypeDescriptionExample
BusinessShortCodeStringYour Lipa Na M-Pesa Online shortcode (Paybill or Till)174379
PasswordStringBase64 encoded string of BusinessShortCode + Passkey + Timestamp(generated)
TimestampStringFormat: YYYYMMDDHHmmss (e.g., 20260225143000)20260225143000
TransactionTypeStringAlways "CustomerPayBillOnline" for Paybill or "CustomerBuyGoodsOnline" for TillCustomerPayBillOnline
AmountNumberTransaction amount in whole KES (no decimals)1500
PartyAStringCustomer phone number (format: 2547XXXXXXXX)254712345678
PartyBStringSame as BusinessShortCode174379
PhoneNumberStringCustomer phone number to receive STK Push254712345678
CallBackURLStringYour HTTPS endpoint to receive the payment resulthttps://api.yoursite.com/mpesa/callback
AccountReferenceStringIdentifier shown on customer phone. Use your POS order ID.ORD-20260225-001
TransactionDescStringDescription of the transactionPayment 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:

password-generation.jsNode.js
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

stk-push.js - Initiate Lipa Na M-Pesa OnlineNode.js
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:

Success Callback PayloadJSON
{ // 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:

Failed Callback PayloadJSON
{ // 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

stk-callback.js - Express.js Callback HandlerNode.js
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:

stk-query.js - Query STK Push StatusNode.js
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

ResultCodeDescriptionHow to Handle in POS
0SuccessMark order as paid, print receipt
1Insufficient balanceShow "Insufficient M-Pesa balance" message
1032Request cancelled by userShow "Payment cancelled" - offer retry
1037DS timeout (user took too long)Show "Payment timed out" - offer retry
1025Transaction limit exceededCustomer has hit daily/transaction limit - suggest smaller amount or alternative
1019Transaction expiredSTK prompt expired before customer acted - re-initiate
2001Wrong PIN enteredShow "Incorrect PIN" - customer can retry on their phone
1001Unable to lock subscriberAnother transaction is pending on this number - wait and retry
1General error / insufficient balanceLog the full response, display generic error, offer retry
17System internal errorSafaricom-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.
c2b-register.js - Register C2B URLsNode.js
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:

  1. 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.
  2. Amount + Phone matching: Match by the combination of phone number and exact amount within a time window.
  3. Manual reconciliation: Show unmatched C2B payments in a POS dashboard for the cashier to manually assign to orders.
c2b-handlers.js - Validation and ConfirmationNode.js
// 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:

b2c-credential.js - Generate Security CredentialNode.js
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

b2c-refund.js - Process M-Pesa RefundNode.js
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:

transaction-status.js - Check Transaction StatusNode.js
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:

reversal.js - Reverse a TransactionNode.js
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.
idempotency.js - Prevent Double ChargesNode.js
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:

retry.js - Exponential Backoff RetryNode.js
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):

  1. Callback endpoint receives the payload and immediately returns HTTP 200.
  2. Push the raw payload onto the queue.
  3. Worker processes consume from the queue and handle database updates, POS notifications, and reconciliation.
  4. 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

  1. Log in to developer.safaricom.co.ke and go to your app.
  2. Copy the sandbox Consumer Key and Consumer Secret.
  3. Set your environment to sandbox: MPESA_ENV=sandbox
  4. Use sandbox base URL: https://sandbox.safaricom.co.ke
  5. Set up a tunnel (e.g., ngrok) to expose your local callback endpoint.
  6. Initiate an STK Push to the test phone number.
  7. Verify you receive the callback on your endpoint.

Test Credentials

CredentialSandbox Value
Test Phone Number254708374149
Test Shortcode (Lipa Na M-Pesa)174379
Test Passkeybfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919
Test Shortcode (C2B)600000
Test Initiator (B2C)testapi
Test Initiator PasswordSafaricom999!*!

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 254708374149 with 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.
test-mock-callback.js - Mock Callback for TestingNode.js
// 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

CodeDescriptionFix
0SuccessNo action needed. Process payment normally.
1Insufficient fundsInform customer to top up M-Pesa balance and retry.
2Less than minimum transaction valueEnsure amount is at least KES 1. Check your amount formatting.
3More than maximum transaction valueM-Pesa has a KES 150,000 per-transaction limit. Split into multiple payments.
11Invalid account numberCheck your BusinessShortCode and PartyB values.
17System internal errorSafaricom-side issue. Retry after 30-60 seconds.
26System busySafaricom under load. Implement exponential backoff retry.
1001Unable to lock subscriberAnother transaction is in progress on this phone number. Wait 30 seconds and retry.
1019Transaction expiredThe STK Push prompt expired before the customer acted. Re-initiate the request.
1025Transaction limit exceededCustomer exceeded daily M-Pesa limit. Suggest alternative payment or try next day.
1032Request cancelled by userCustomer dismissed the prompt. Ask if they want to retry.
1037DS timeoutCustomer did not respond in time. Re-initiate the request.
2001Wrong PINCustomer entered wrong M-Pesa PIN. They can retry on their phone (if prompt still active).
404.001.03Invalid access tokenToken expired or invalid. Refresh your OAuth token and retry.
404.001.04Bad request - invalid inputCheck your request payload format, especially phone number format (2547XXXXXXXX).
500.001.1001Internal server errorSafaricom backend error. Log and retry after delay.

Top 10 Common Integration Mistakes

  1. 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.
  2. Wrong phone number format. Always format to 2547XXXXXXXX (12 digits, no + prefix). Common mistake: sending 07XXXXXXXX or +2547XXXXXXXX.
  3. 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).
  4. 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.
  5. Not handling duplicate callbacks. Safaricom may send the same callback multiple times. Use CheckoutRequestID as an idempotency key to prevent double-processing.
  6. Using self-signed SSL certificates. Safaricom rejects callbacks to endpoints with self-signed certs. Use a trusted CA (Let's Encrypt is free).
  7. Hardcoding credentials in source code. Consumer Key, Consumer Secret, and Passkey should never be in your codebase. Use environment variables or a secrets manager.
  8. 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.
  9. Ignoring timestamp format. The Password timestamp must be in YYYYMMDDHHmmss format. Getting the timezone wrong or using a different format causes authentication failures.
  10. 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

Global Support & Remote Onboarding

We support businesses across the world with fully remote onboarding, real-time assistance, and dedicated technical support — no matter your location.

Remote support worldwide
WhatsApp-first global communication
⏱
Fast response times across time zones
Dedicated POS
WhatsApp — replies in under 10 minutes
+254 791 313 683
No international calling required
Response time: under 10 Minutes
WhatsAppIndustries