Handle failed M-Pesa payments by storing every transaction attempt with its CheckoutRequestID before initiating the STK Push, then using that ID as an idempotency key when processing callbacks. For missing callbacks, poll the Transaction Status API after 2 to 3 minutes. For duplicates, check whether the CheckoutRequestID has already been processed before updating your records. Never assume a missing callback means failure.
The happy path of M-Pesa integration is straightforward: customer pays, callback arrives, order is fulfilled. Most tutorials stop there. But in production, the happy path accounts for maybe 85 to 90 percent of transactions. The other 10 to 15 percent are edge cases: timeouts, cancellations, network failures, duplicate callbacks, and transactions that succeed on M-Pesa's side but never notify your server.
How you handle these edge cases determines whether your users trust your product. If a customer pays KES 5,000 and your app shows "payment failed" because the callback was lost, that customer is calling your support line, posting on Twitter, and probably never using your product again. If a bug causes a customer to be charged twice, you have a much bigger problem.
The financial stakes are real. A single missed callback on a high-value transaction can mean a dispute that takes days to resolve. A duplicate processing bug that affects a hundred transactions can cost you serious money and potentially get your M-Pesa business account suspended while Safaricom investigates.
The good news is that all of these edge cases are predictable and can be handled with solid engineering. You do not need to be a payments expert. You need good database design, idempotent processing, and a fallback mechanism for missing callbacks. This guide covers all three.
Before you can handle failures, you need to understand all the ways a payment can go wrong. Here is the complete list, organized by where in the flow the failure occurs.
Before the STK Push reaches the customer:
After the STK Push but before the customer responds:
During customer interaction:
After the transaction resolves:
Each of these requires a different response in your application. A blanket "payment failed" message is not good enough when the customer actually paid but the callback was lost.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Idempotency means that processing the same operation multiple times produces the same result as processing it once. For payments, this means: if the same callback arrives twice, the customer should only be charged once and the order should only be fulfilled once.
Without idempotency, duplicate callbacks cause duplicate processing. The customer pays KES 1,000 once but your system credits KES 2,000 (or ships two orders, or creates two subscriptions). This is not a hypothetical scenario. It happens regularly in production M-Pesa integrations that do not implement idempotency.
The CheckoutRequestID is your idempotency key. Every STK Push response includes a unique CheckoutRequestID. The callback for that transaction also contains the same CheckoutRequestID. Use this as the unique identifier for the transaction throughout your system.
// Step 1: When initiating STK Push, store the request
async function initiatePayment(phoneNumber, amount, orderId) {
const stkResponse = await sendSTKPush(phoneNumber, amount, orderId);
// Store the transaction BEFORE the callback arrives
await db.transactions.create({
checkoutRequestId: stkResponse.CheckoutRequestID,
merchantRequestId: stkResponse.MerchantRequestID,
orderId: orderId,
phoneNumber: phoneNumber,
amount: amount,
status: 'initiated', // not 'pending', not 'processing'
initiatedAt: new Date(),
callbackPayload: null,
processedAt: null,
});
return stkResponse;
}
// Step 2: In your callback handler, check before processing
async function handleCallback(payload) {
const { CheckoutRequestID, ResultCode } = payload.Body.stkCallback;
// Find the transaction
const transaction = await db.transactions.findOne({
checkoutRequestId: CheckoutRequestID,
});
if (!transaction) {
console.error('Callback for unknown transaction:', CheckoutRequestID);
return; // Ignore callbacks for transactions we did not initiate
}
// IDEMPOTENCY CHECK: Has this already been processed?
if (transaction.status === 'completed' || transaction.status === 'failed') {
console.log('Duplicate callback ignored:', CheckoutRequestID);
return; // Already processed, do nothing
}
// Safe to process
if (ResultCode === 0) {
await db.transactions.update(
{ checkoutRequestId: CheckoutRequestID },
{
status: 'completed',
callbackPayload: JSON.stringify(payload),
processedAt: new Date(),
mpesaReceiptNumber: extractReceiptNumber(payload),
}
);
await fulfillOrder(transaction.orderId);
} else {
await db.transactions.update(
{ checkoutRequestId: CheckoutRequestID },
{
status: 'failed',
callbackPayload: JSON.stringify(payload),
processedAt: new Date(),
failureReason: ResultCode.toString(),
}
);
}
}
The key line is the idempotency check: if (transaction.status === 'completed' || transaction.status === 'failed'). If the transaction has already been finalized, ignore the callback entirely. This is safe because the first callback already handled everything.
Do not use "upsert" or "insert or update" operations for payment records. Explicit check-then-update is clearer and easier to audit. When money is involved, clarity beats cleverness every time.
You sent the STK Push. The API said "accepted for processing." And then... nothing. No callback. No error. Just silence. This is one of the most stressful situations in M-Pesa development, because you do not know whether the customer paid or not.
Rule number one: do not assume failure. A missing callback does not mean the payment failed. The customer may have paid successfully, but the callback was lost due to a network issue, your server being temporarily unreachable, or an SSL certificate problem. If you mark the transaction as failed and ask the customer to pay again, you might end up with a double payment.
The correct approach: poll the Transaction Status API.
// Background job that runs every 2 minutes
async function checkPendingTransactions() {
// Find transactions that have been pending for more than 3 minutes
const pendingTransactions = await db.transactions.find({
status: 'initiated',
initiatedAt: { $lt: new Date(Date.now() - 3 * 60 * 1000) },
});
for (const txn of pendingTransactions) {
try {
const status = await queryTransactionStatus(txn.checkoutRequestId);
if (status.ResultCode === '0') {
// Payment was successful, callback was lost
await db.transactions.update(
{ checkoutRequestId: txn.checkoutRequestId },
{
status: 'completed',
processedAt: new Date(),
resolvedVia: 'status_query', // Track how we discovered this
}
);
await fulfillOrder(txn.orderId);
console.log('Recovered lost payment:', txn.checkoutRequestId);
} else if (status.ResultCode === '1032' || status.ResultCode === '1037') {
// Cancelled or timed out
await db.transactions.update(
{ checkoutRequestId: txn.checkoutRequestId },
{
status: 'failed',
processedAt: new Date(),
failureReason: status.ResultCode,
resolvedVia: 'status_query',
}
);
}
// If status query itself fails, leave as initiated and retry next cycle
} catch (err) {
console.error('Status query failed for:', txn.checkoutRequestId, err);
}
}
}
Run this job every 2 to 3 minutes. It catches transactions where the callback was lost and resolves them based on the actual status from Safaricom. The resolvedVia field is useful for debugging: it tells you whether a transaction was completed via callback (normal path) or via status query (fallback path). If you see a high percentage resolved via status query, your callback infrastructure has a problem.
What to show the user while waiting: After initiating the STK Push, show a "waiting for payment confirmation" screen with a loading indicator. If you have not received a callback after 30 seconds, show a message like "Still waiting. Please check your phone and enter your M-Pesa PIN if prompted." After 90 seconds, show "We have not received confirmation yet. If you completed the payment, it will be reflected shortly. If not, you can try again."
Do not show a "payment failed" message until you have checked the Transaction Status API and confirmed it actually failed. Premature failure messages cause customers to pay again, which creates the duplicate payment problem described in the next section.
Duplicate payments happen when a customer pays twice for the same order. This is different from duplicate callbacks (same payment notified twice). Duplicate payments mean two separate M-Pesa transactions for the same order, each with its own receipt number.
How duplicates happen:
Prevention strategy 1: Lock the order during payment.
async function initiatePaymentForOrder(orderId, phoneNumber, amount) {
// Check if there is already an active payment attempt for this order
const existingAttempt = await db.transactions.findOne({
orderId: orderId,
status: { $in: ['initiated'] },
initiatedAt: { $gt: new Date(Date.now() - 5 * 60 * 1000) }, // within last 5 mins
});
if (existingAttempt) {
// Already have a pending payment for this order
return {
success: false,
message: 'A payment is already in progress for this order. Please check your phone for the M-Pesa prompt.',
checkoutRequestId: existingAttempt.checkoutRequestId,
};
}
// Check if order is already paid
const completedPayment = await db.transactions.findOne({
orderId: orderId,
status: 'completed',
});
if (completedPayment) {
return {
success: false,
message: 'This order has already been paid.',
};
}
// Safe to initiate
return await initiatePayment(phoneNumber, amount, orderId);
}
Prevention strategy 2: Disable the pay button after click. On the frontend, disable the "Pay" button immediately after the first click and show a loading state. Do not re-enable it until you have confirmation of success or failure. This prevents impatient double-clicks.
Prevention strategy 3: Rate-limit per phone number. Do not allow more than one STK Push to the same phone number within 60 seconds. M-Pesa itself enforces this (a phone can only have one active STK Push session), but checking on your side gives a better user experience than letting Safaricom reject the second request.
When duplicates happen anyway (and they will): You need a refund process. Keep a record of all payments per order. If an order has two completed payments, flag it for review. The refund can be processed via B2C (sending money back to the customer's M-Pesa) or handled manually through your M-Pesa business account. Automated refunds are better, but manual refunds are fine when you are starting out and volume is low.
Your database schema for M-Pesa transactions should be designed for safety, auditability, and easy debugging. Here is a schema that covers the edge cases discussed in this guide.
-- PostgreSQL schema for M-Pesa transactions
CREATE TABLE mpesa_transactions (
id SERIAL PRIMARY KEY,
checkout_request_id VARCHAR(100) UNIQUE NOT NULL,
merchant_request_id VARCHAR(100),
order_id VARCHAR(100) NOT NULL,
phone_number VARCHAR(15) NOT NULL,
amount INTEGER NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'initiated',
-- initiated: STK Push sent, waiting for customer
-- completed: payment confirmed (via callback or status query)
-- failed: payment failed (cancelled, timeout, wrong PIN, etc)
result_code VARCHAR(10),
result_desc TEXT,
mpesa_receipt VARCHAR(50),
resolved_via VARCHAR(20),
-- 'callback': normal callback path
-- 'status_query': fallback polling path
-- 'manual': manually resolved by support
callback_payload JSONB, -- raw callback JSON for debugging
initiated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ, -- when callback/status was processed
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index for idempotency checks (fast lookup by checkout_request_id)
CREATE INDEX idx_mpesa_checkout ON mpesa_transactions(checkout_request_id);
-- Index for finding pending transactions (for status polling job)
CREATE INDEX idx_mpesa_pending ON mpesa_transactions(status, initiated_at)
WHERE status = 'initiated';
-- Index for order lookups (finding all payments for an order)
CREATE INDEX idx_mpesa_order ON mpesa_transactions(order_id);
-- Audit log: every state change is recorded
CREATE TABLE mpesa_transaction_log (
id SERIAL PRIMARY KEY,
transaction_id INTEGER REFERENCES mpesa_transactions(id),
previous_status VARCHAR(20),
new_status VARCHAR(20) NOT NULL,
changed_by VARCHAR(50) NOT NULL, -- 'system', 'callback', 'status_query', 'admin'
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Key design decisions explained:
UNIQUE constraint on checkout_request_id. This is your idempotency key at the database level. Even if your application code has a bug, the database will reject duplicate inserts for the same transaction. Defense in depth.
JSONB for callback_payload. Store the entire raw callback JSON. You will need it when debugging disputes ("the customer says they paid KES 5,000 but we only see KES 500"). Having the raw payload means you can always go back to the source of truth.
Separate audit log table. Every status change is recorded with a timestamp, who or what made the change, and optional notes. This is invaluable for investigating issues. When a customer disputes a transaction, you can show the complete timeline: initiated at 14:32, callback received at 14:33, status changed to completed at 14:33, order fulfilled at 14:34.
Never delete transaction records. Use soft deletes if you must, but ideally, never delete payment records at all. Financial records should be immutable. If a transaction needs to be reversed, create a new refund record rather than deleting the original transaction.
The resolved_via column. This tells you how the transaction was finalized. If you see a lot of transactions resolved via "status_query" instead of "callback," you have a callback infrastructure problem to investigate. If you see "manual" entries, those are support interventions worth reviewing to prevent recurrence.
Reconciliation is the process of comparing your transaction records against M-Pesa's records to make sure they match. It catches every edge case that slips through your callback handling and status polling: the transaction that completed after your polling window, the callback that arrived but had a processing error, the refund you forgot to record.
At minimum, reconcile daily. Download your M-Pesa business statement (available through the M-Pesa business portal or via the Account Balance API) and compare it against your database.
What to check:
// Simplified daily reconciliation pseudocode
async function dailyReconciliation(date) {
const mpesaStatement = await downloadMpesaStatement(date);
const ourRecords = await db.transactions.find({
processedAt: { $gte: startOfDay(date), $lt: endOfDay(date) },
status: 'completed',
});
const discrepancies = [];
// Check: every M-Pesa transaction has a matching record
for (const mpesaTxn of mpesaStatement) {
const match = ourRecords.find(r => r.mpesaReceiptNumber === mpesaTxn.receiptNumber);
if (!match) {
discrepancies.push({
type: 'MISSING_IN_OUR_RECORDS',
mpesaReceipt: mpesaTxn.receiptNumber,
amount: mpesaTxn.amount,
phone: mpesaTxn.phoneNumber,
});
} else if (match.amount !== mpesaTxn.amount) {
discrepancies.push({
type: 'AMOUNT_MISMATCH',
mpesaReceipt: mpesaTxn.receiptNumber,
ourAmount: match.amount,
mpesaAmount: mpesaTxn.amount,
});
}
}
if (discrepancies.length > 0) {
await alertTeam('Reconciliation discrepancies found', discrepancies);
}
return { totalChecked: mpesaStatement.length, discrepancies: discrepancies.length };
}
Reconciliation is not glamorous work. It does not make for an exciting demo. But it is the difference between a payment system you can trust and one that silently loses money. At McTaba Labs, reconciliation is a core part of our payments module. Every student builds a reconciliation system before we consider their M-Pesa integration complete, because that is what production-grade payment processing demands.
KES 9,999
Master Daraja API, STK Push, C2B, B2C, and callback handling. Build a real payment flow from scratch. 10 modules, 57 lessons.
Enrol in M-Pesa Integration