Handling Failed and Duplicate M-Pesa Payments Safely
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.
Why Payment Edge Cases Will Define Your Reputation
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.
Every Way an M-Pesa Payment Can Fail
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:
- Authentication failure. Your OAuth token expired or your credentials are wrong. The API returns an error immediately. No STK Push is sent.
- Invalid parameters. Wrong phone number format, amount below minimum, or missing required fields. The API rejects the request.
- Rate limiting. You have sent too many requests in a short period. The API returns a throttling error.
- Network timeout. Your server cannot reach Safaricom's API. The request never makes it.
After the STK Push but before the customer responds:
- Customer's phone is off or unreachable. The STK Push prompt never arrives. Eventually times out.
- Customer is already in another M-Pesa session. Only one STK Push can be active at a time per phone number. The second one fails.
During customer interaction:
- Customer cancels (ResultCode 1032). The customer sees the prompt but declines.
- Wrong PIN (ResultCode 2001). The customer enters the wrong M-Pesa PIN.
- Insufficient funds (ResultCode 1). The customer does not have enough money in their M-Pesa account.
- Timeout (ResultCode 1037). The customer does not respond within approximately 60 seconds.
After the transaction resolves:
- Callback lost. The transaction completed on M-Pesa's side, but the callback never reaches your server (network issue, server down, SSL problem).
- Callback delayed. The callback arrives minutes or even hours later, by which time your system may have already marked the transaction as failed.
- Duplicate callback. Safaricom sends the same callback twice (perhaps because your server was slow to respond the first time).
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.
Idempotency: Why It Matters and How to Implement It
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.
Timeout Handling: What to Do When the Callback Never Arrives
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.
Preventing and Handling Duplicate Payments
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:
- The customer clicks "Pay" twice because the first click did not seem to do anything (slow UI response).
- Your app shows "payment failed" prematurely (before checking Transaction Status), so the customer tries again, but the first payment actually went through.
- The customer initiates payment on two devices or two browser tabs simultaneously.
- A retry mechanism in your code initiates a second STK Push for the same order.
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.
Database Design for Safe Payment Recording
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: The Safety Net You Cannot Skip
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:
- Every payment on the M-Pesa statement should have a matching record in your database. If you find a payment on the statement that is not in your database, a callback was lost and your status polling did not catch it. Investigate and credit the customer.
- Every completed transaction in your database should appear on the M-Pesa statement. If you find a "completed" transaction in your database that is not on the statement, your system incorrectly marked it as paid. This is serious and needs immediate investigation.
- Amounts should match. The amount in your database should match the amount on the M-Pesa statement for every transaction.
- Check for orphaned initiated transactions. Transactions stuck in "initiated" status for more than 24 hours should be investigated. Either the status polling job is not running, or there is a persistent issue with the Transaction Status API.
// 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.
Key Takeaways
- ✓Every STK Push request should be recorded in your database before you send it to Safaricom. This gives you a reference point regardless of what happens next.
- ✓Use the CheckoutRequestID as an idempotency key. Before processing any callback, check if that ID has already been processed. This prevents double-crediting customers or double-fulfilling orders.
- ✓A missing callback does not mean the payment failed. The customer may have paid, but the callback was lost. Always check the Transaction Status API before marking a transaction as failed.
- ✓Design your payment table with explicit states (initiated, pending, completed, failed, cancelled) and never delete payment records. Append-only logging protects you during disputes.
Frequently Asked Questions
- What is the most common cause of duplicate M-Pesa payments?
- The most common cause is the customer clicking "Pay" multiple times because the UI does not immediately show that a payment is in progress. The second most common cause is the app prematurely showing a "payment failed" message (before checking the Transaction Status API), prompting the customer to try again when the first payment actually succeeded. Both are preventable with good UI design and proper timeout handling.
- How do I refund a duplicate M-Pesa payment?
- You can refund programmatically using the Daraja B2C API (Business to Customer) to send money back to the customer's phone. For low volume, you can also refund manually through your M-Pesa business account portal. Either way, record the refund in your database linked to the original transaction for audit purposes.
- Should I use the M-Pesa Reversal API or B2C for refunds?
- The Reversal API is designed to reverse a specific transaction and is the technically correct approach. However, it has stricter requirements and can be harder to get approved for in production. Many developers use B2C for refunds instead, as it is simpler to implement. The trade-off is that B2C creates a new transaction rather than reversing the original one, which makes reconciliation slightly more complex.
- How long should I keep M-Pesa transaction records?
- Keep them indefinitely if possible. At minimum, keep them for 7 years to comply with Kenyan tax record-keeping requirements. Storage is cheap, and old transaction records are invaluable for resolving customer disputes, tax audits, and business analytics. Never delete payment records.
- What happens if my server crashes while processing a callback?
- If your server crashes after receiving the callback but before updating the database, the transaction stays in "initiated" status. Your Transaction Status polling job will pick it up within a few minutes and resolve it correctly. This is why the polling fallback is essential. If your server crashes before responding with HTTP 200, Safaricom may retry the callback, which is handled safely by your idempotency check.
- Can I use Redis or in-memory storage for payment records?
- No. Payment records must be stored in a durable database (PostgreSQL, MySQL, or similar). In-memory storage like Redis can lose data on restart, which is unacceptable for financial transactions. You can use Redis for caching (like OAuth tokens) or for rate limiting, but the source of truth for payment records must be a persistent database with proper backups.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon