Date: Sep 24, 2026
Subject: Handling Mobile Money at Scale: Idempotency, Retries, and Race Conditions
If you run or build software for a business in Kenya that touches mobile money — a school collecting fees, a SACCO taking contributions, a clinic billing patients, an agency processing client payments, or a fintech built entirely around M-Pesa — you will eventually hit a strange bug that only shows up under load or on a bad network day: a customer is charged twice for one transaction, or worse, credited twice for a payment they made once. Support tickets pile up, someone has to manually reconcile the ledger against the till number statement, and the business owner asks a reasonable but uncomfortable question: "how did we let this happen?" The honest answer is almost always the same — the system was not built to handle duplicate messages, delayed responses, or two things happening at exactly the same time. This is not a niche engineering concern. It is the difference between a payment system your business can trust at scale and one that quietly leaks money or credibility every month.
This article is about three related ideas that matter enormously once your mobile money integration grows beyond a handful of transactions a day: idempotency, retries, and race conditions. None of these are exotic. They are well-understood engineering problems, but they are frequently skipped in the rush to launch, especially by small teams working on tight budgets and tighter deadlines. The cost of skipping them does not show up on day one — it shows up months later, when transaction volume grows and the edge cases that were rare become routine.
It helps to be precise about what actually happens when a customer pays you via M-Pesa. Whether you're using an STK push (the "Lipa na M-Pesa" prompt that appears on the customer's phone), a paybill, a till number, or a B2C disbursement, the payment is not one atomic action from your system's point of view. It is a sequence of network calls: your system requests a payment, Safaricom's platform processes it on their side, and then — separately, and not always promptly — a callback (or webhook, or notification) arrives at your server confirming what happened. Between the request and the callback, there is a gap. During that gap, anything can happen: your server can be restarted, the customer can close the app, the network can drop, or the callback can simply take longer than your code expects. Because this is a conversation over an unreliable network rather than a single instruction, the safe assumption is that any message might arrive late, out of order, more than once, or not at all. Systems that behave correctly under all four of those conditions are robust. Systems that only work when messages arrive once, on time, and in order will work fine in testing and then fail unpredictably in production — usually during your busiest period, which is exactly when you can least afford it.
Idempotency is a formal-sounding word for a simple and extremely practical idea: an operation is idempotent if doing it once has the same effect as doing it five times. Pressing a light switch is not idempotent — press it five times and the light ends up in a different state depending on how many times you pressed it. Setting the light to "on" is idempotent — no matter how many times you send that instruction, the light ends up on. The goal in payment systems is to convert every operation that touches money into the second kind.
Safaricom's platform, like any distributed system, can and occasionally will deliver the same callback more than once. This is not a bug on their end — it is a deliberate design choice made by almost every payment provider globally, because from their perspective, it is safer to risk sending a duplicate than to risk never sending a confirmation at all. If your server does not respond fast enough, or the response is lost on a shaky connection somewhere between Nairobi and the provider's servers, the platform may retry the callback. Your system therefore cannot assume "one payment equals one callback." It must assume "one payment equals one or more callbacks that all describe the same event," and it must process that event exactly once regardless of how many times the callback arrives.
The standard solution is to attach a unique identifier to every transaction — the M-Pesa transaction reference (often called the receipt number) is usually the right choice, since it is unique per completed transaction. Before your system credits an account, updates an invoice, or triggers a receipt, it should check whether it has already processed that exact reference. If it has, it does nothing further and simply returns a success response — the caller doesn't need to know or care that this was a repeat. This check-then-act logic typically lives at the database level, using a unique constraint on the transaction reference column, rather than relying purely on application code to "remember" what it has seen. A unique constraint will reject a duplicate insert outright, which is a far more reliable guardrail than an in-memory check that disappears the moment your server restarts — which, given power interruptions and shared hosting realities in many parts of the region, is not a rare event.
Retries are the natural companion to idempotency. If you know that repeating an operation is safe, you can retry aggressively when something goes wrong, instead of leaving a transaction in limbo. This matters a great deal in East Africa's mobile-first environment, where connectivity is genuinely variable — a customer paying from a rural area on a congested tower, a business's own server hiccupping during a brief outage, or a third-party API simply timing out under load. A system that gives up after one failed attempt will generate a steady trickle of "the customer paid but nothing happened on our end" complaints, which are expensive to investigate and corrosive to trust.
The temptation is to wrap every payment call in a loop that retries immediately and repeatedly until it succeeds. This is usually the wrong approach, for two reasons. First, hammering an API with immediate repeated requests can make a temporary problem worse, and some providers will rate-limit or temporarily block a client that behaves this way. The more common and more resilient pattern is exponential backoff — wait briefly, then retry; if that fails, wait longer, then retry again, up to a sensible limit — combined with jitter (a small random variation in the wait time) so that if many of your requests fail at once, they don't all retry at exactly the same moment and create a second wave of congestion. Second, and more importantly for money-handling systems, retries are only safe to the extent that the operation being retried is idempotent. Retrying a non-idempotent "credit this account" instruction is precisely how double-crediting bugs are born. Retries and idempotency are not two separate best practices — they are a pair, and neither should be implemented without the other.
For businesses processing meaningful volume — a SACCO with thousands of members, a school with hundreds of fee-paying parents, a fintech with continuous transaction flow — it is worth treating incoming payment callbacks as messages to be queued and processed reliably, rather than events to be handled inline within the same request that received them. A message queue (even a modest one) lets you accept the callback quickly, acknowledge it, and process the actual business logic — updating balances, sending receipts, notifying downstream systems — separately and with proper retry logic if that processing step fails. This decouples "did we receive confirmation of payment" from "did we finish acting on it," which is a distinction that matters enormously when your database is briefly unavailable or a downstream SMS provider is slow.
A race condition occurs when the outcome of a system depends on the precise timing of two or more operations that were not designed to account for each other. In mobile money systems, the classic example looks like this: a customer's payment callback arrives and your code reads their current balance, calculates the new balance, and writes it back — but at almost the same instant, a second callback for a different transaction from the same customer does the exact same read-calculate-write sequence, based on the balance it read before the first update finished. One of those updates gets silently overwritten, and the customer's real balance no longer matches what your system believes. This is especially common for businesses with a small number of shared paybill or till numbers, where many customers' transactions land on the same account records in quick succession — precisely the situation a busy Nairobi retail outlet, a school during fee-payment week, or a SACCO on contribution day will experience.
Race conditions rarely show up when a developer tests a system by hand, one transaction at a time, because there simply isn't enough concurrent traffic to expose the timing gap. They appear when volume rises — which is exactly when a business can least afford new bugs, and exactly when the pressure to "just launch and fix issues later" is strongest. This is one of the more frustrating realities of building payment systems on a lean budget: the bug that will eventually cost you the most is invisible during the period when you have the least capacity to look for it.
The reliable fix is to make sure that balance updates and similarly sensitive operations happen atomically at the database level, rather than as a read-then-write sequence in application code. Most relational databases support this through row-level locking or atomic increment operations — instructing the database to "add this amount to the balance" as a single indivisible instruction, rather than "read the balance, add to it in application memory, then write it back." For more complex logic that can't be expressed as a single atomic instruction, database transactions with appropriate isolation levels, or application-level locking around the specific account being updated, are the standard tools. None of this requires exotic infrastructure — it requires deliberately choosing patterns that hold up under concurrency, rather than patterns that merely look correct when tested one request at a time.
If you are the owner or manager rather than the engineer, the technical detail matters less than the questions you should be asking whoever builds or maintains your payment integration. Does the system guarantee that a duplicate M-Pesa callback cannot result in a duplicate credit or a duplicate receipt? What happens if your server is briefly down when a callback arrives — is that transaction lost, or retried until it succeeds? Under a burst of simultaneous payments, such as fee-payment week or month-end contributions, has the system actually been tested for correctness, not just for whether it "seems to work"? These are fair questions to put to a developer or vendor, and a competent one should have clear, specific answers rather than vague reassurance.
There is also a compliance and record-keeping dimension worth flagging, even if this article won't guess at specifics: accurate, non-duplicated transaction records matter for your own reconciliation against Safaricom statements, for tax record-keeping relevant to KRA and eTIMS, and for any reporting obligations that may apply if you operate as a regulated or CBK-supervised entity. If your ledger has silently double-counted or dropped transactions, cleaning that up after the fact is far more expensive than building the safeguards in from the start. Where you are unsure whether a specific rule or reporting requirement applies to your business, that is a question for the relevant authority or a qualified advisor, not something to guess your way through in code.
For teams building or reviewing a mobile money integration, a few habits go a long way: treat every incoming payment confirmation as something that might arrive more than once and design accordingly; use the provider's transaction reference as a unique key enforced at the database level, not just checked in application code; retry failed operations with backoff rather than immediately or not at all, and only retry operations you've confirmed are safe to repeat; move balance and ledger updates into atomic database operations instead of read-then-write application logic; and test deliberately under simulated concurrent load before your busiest real-world day does the testing for you. None of this is glamorous work, and none of it will show up in a product demo. But it is exactly the kind of unglamorous engineering that determines whether a payment system quietly earns trust over years or quietly erodes it one duplicate transaction at a time.
We build AI agents and automation for Kenyan businesses — and the infrastructure underneath them. Run the automation scan and find out what's worth building first.