The Sale the Till Sent Twice
On patchy mobile data the request usually arrives; it is the reply that gets lost. The phone cannot tell the difference, so it sends again, and a till without protection records the sale twice. How the app and the server now agree that one tap is one sale, however many times it is sent.
Twenty to seven on a Friday at a mini-mart outside Kitengela. The queue is out of the door, the phone running the till is on one bar of Safaricom data, and the cashier taps Complete sale on a KES 3,450 basket paid by M-Pesa. The spinner turns. The connection drops for four seconds and comes back. Did the sale go through? The phone has no way of knowing, so it sends it again.
On a weak connection the common failure is not the request that never arrived. It is the reply that never came back. The server received the sale, took the stock off the shelf count and recorded the payment, and its answer was lost somewhere between the mast and the phone. From the phone’s side, a lost request and a lost reply look exactly the same. Sending again is the right instinct, and without protection it is also how one basket becomes two sales: stock taken off twice, two receipts, and a till that shows KES 3,450 more than the cashier ever collected.
One key per action
The fix is old and simple, and the hard part is applying it everywhere. When the app sends an action that moves money or stock, it attaches a key unique to that action, and it sends the same key on every retry of it. The server remembers each key it has seen and the answer it gave.
| What arrives | What happens |
|---|---|
| A new key | The action runs once, and its answer is kept |
| The same key with the same details | Nothing runs again; the first answer comes back |
| The same key with different details | Refused, because that is not a retry |
| Two requests with one key at the same moment | Taken one at a time, so the action still runs once |
The third row matters more than it looks. A retry is the same request sent again. A request that carries a used key but a different amount, a different customer or a different quantity is something else: a bug, or a request that has been tampered with. Guessing which of the two versions was meant is how a ledger goes quietly wrong, so it is refused instead.
Where it applies
- At the till: POS sales, payments against a sale and returns.
- In the store: stock check-in and check-out, and transfers between warehouses.
- In finance: payments against customer invoices and supplier bills, payment runs and payouts.
In all, 172 actions on the mobile app and the API carry the check. The list lives in one place on the server, and a test fails the build when a new route that looks like it moves money or stock carries neither the check nor a written reason for going without it, so coverage does not rest on someone remembering.
A second lock at the ledger
Underneath the request check, journal postings carry their own unique keys. The request check stops a duplicate from running at all; the ledger key stops the same posting from landing in the books twice under one key, whichever route it came by.
The phone cannot tell a lost request from a lost reply. The server can, if the request carries a key.
The double tap
Impatience produces the same problem without any help from the network. A cashier who sees the spinner taps again, and two identical requests leave the phone a fraction of a second apart. With one key, the server takes them one at a time, and the second gets the first one’s result instead of creating a second sale.
Work captured with no connection at all has been replayed with an identifier for some time, as offline sync: what the word should mean explains. This applies the same rule to the far more common case: the app is online, just badly, and sends live. For a till on patchy data that is most of the working day. If your shop runs mixed tenders, the drawer side of the story is in half cash, half M-Pesa.
This covers the mobile app and the API. The same server-side check can extend to forms in a web browser, so a form submitted twice records once; that is in the list below.
Retry-safe in the browser too
The mobile app and the API send a key with every action that moves money or stock. Teams that do most of their entry in a browser ask for the same protection there.
Double-submit protection on web forms
A key on each browser form that moves money or stock, so a form sent twice by a double click or a slow page records once.
Checking last month’s till for retries
Are there pairs of POS sales with the same total, the same till and the same cashier, under a minute apart?
Look for
A handful, clustered on busy evenings.
Then
Those are likely retries rather than customers. Check them against the M-Pesa statement.
Which tills run on mobile data rather than a fixed line?
Look for
Branches, market stalls, delivery vans.
Then
Make sure they sell through the app, which sends the key.
Who enters payments in a browser?
Look for
Accounts staff at head office.
Then
Ask them to wait for the page rather than click again, until web forms carry a key.
Does your stock count drift down on fast-moving items with no matching sales?
Look for
Small, unexplained shortfalls.
Then
Compare duplicated check-outs against the dates the drift started.
Our position
On mobile data, assume every action that moves money or stock will be sent more than once, and design for it. A key on each action, the first answer returned to every retry, a changed payload refused and simultaneous taps taken one at a time: that turns a flaky connection from a source of duplicate sales into a slow spinner, which is the right failure.
What AWRA OpsHub does today
- The same key on every retry of a POS sale, payment, stock check-in or check-out, transfer, invoice or bill payment, payment run or payout sent from the mobile app.
- A duplicate answered with the first result, without the action running again.
- A changed payload under a used key refused.
- Two requests with one key at the same moment taken one at a time.
- 172 actions covered on the app and the API, from one list on the server held by a test.
- Unique keys on journal postings, a second lock at the ledger.
More we can add to your workspace
- Double-submit protection on web forms, so a browser form sent twice records once.
Where we point you to a specialist
- Two sales with two keys are two sales. We will not merge genuine repeat purchases by guessing from the amount and the time; whether a request is a retry is decided by its key alone.
Web forms would use the same server-side check that already guards the 172 app and API actions.
One tap, one sale
Run your tills on the AWRA mobile app and every sale, payment and stock movement it sends is safe to retry on a bad connection.
Talk to us about your tillsFrequently asked questions
What happens if the AWRA app sends the same sale twice on a bad connection?
It is recorded once. The app sends the same key with every retry of an action, and the server returns the first result to any repeat instead of running the action again.
Which actions are protected against duplicates?
Actions that move money or stock: POS sales, payments and returns, stock check-in and check-out, transfers, invoice and bill payments, payment runs and payouts. 172 actions on the mobile app and the API carry the check.
What if a retry carries different details from the original?
It is refused. The same key with a different payload is not a retry, so the server does not guess which version was meant.
What if the cashier taps twice?
Two requests with the same key arriving at the same moment are taken one at a time, so the action runs once.
Are web browser forms protected the same way?
The protection covers the mobile app and the API. Double-submit protection on web browser forms is something we can add on request.