Your spare Android phone is a payment gateway: an offline-first SMS relay for M-Pesa
Walk into a small shop in Nairobi and watch a payment happen. The customer pays to the till on their phone. A few seconds later a phone behind the counter buzzes. The cashier picks it up, reads an SMS from MPESA, and types the transaction code into the point of sale. Then they hand over the goods.
That loop is the reason this article exists. It is slow, it is unauditable, and it is trivially defrauded from both sides of the counter. A cashier can key in a code that never arrived. A customer can present a doctored screenshot and be out of the door before anyone checks. At close of business nobody can reconcile anything without reading a phone message by message.
The official fix is Safaricom's Daraja API, which delivers a webhook to your server the moment a payment lands. I have written a full production guide to Daraja and I recommend it wherever you can get it. The problem is the phrase "wherever you can get it". Daraja is not open to every merchant: there are tier requirements, onboarding friction and paperwork, and a very large number of genuine shops never make it through. Those shops are not going to stop trading while they wait. They already receive the payment confirmation. It is just sitting in the wrong place, in the wrong format, on a phone.
put a dedicated Android phone on the till, running a gateway app that captures incoming SMS, encrypts them on the device, signs them, and posts them to your backend over HTTPS.
- The device does zero business parsing. It forwards the raw SMS. All M-Pesa semantics live on the server, so one app serves payments, OTP relay and alerts.
- Every message is written to a SQLCipher-encrypted queue before any network attempt, so a crash, a reboot or a dead network never loses one.
- Each payload carries an HMAC-SHA256 signature over a byte-exact canonical string, plus a content hash and a per-request nonce, so the backend can reject forgeries and collapse duplicates.
- The interesting engineering is not reading SMS. It is keeping a process alive on a cheap phone whose battery manager considers your app a defect.
What was actually built
The UPEO SMS Gateway is a Flutter and Kotlin Android app, package com.upeo.upeo_sms_gateway, minSdk 24 (Android 7.0) and targetSdk 36, currently at version 1.0.5+5. It ships as part of the UpeoRetail retail platform but it is backend-agnostic: it posts to three fixed paths and the operator configures only the base URL. It is distributed as a sideloaded APK and will never appear on Google Play, for reasons that are a policy decision rather than a technical one and that get their own article.
The one-sentence description is: it turns a spare Android phone into a self-hosted SMS-to-HTTP gateway. The one-sentence design principle is: it is a dumb pipe and it stays dumb.
The hard part was never reading an SMS
Android will hand you an incoming message in about ten lines. Register a receiver for SMS_RECEIVED, pull the PDUs out of the intent, read the body. Done by lunchtime.
The hard part is making that never lose a message on a KSh 8,000 phone whose manufacturer ships a battery manager that treats a long-running background process as a defect to be corrected. Roughly all of the engineering in this project is reliability and integrity. The SMS is the easy bit, and I have given the reliability work an article of its own because it is the part that generalises to anything you have to keep running on a device you do not control.
What that pressure produces architecturally is the shape in the diagram above. The receiver is a doorbell, not a worker. The foreground service is the thing that is actually alive. And every decision that could possibly be wrong is made in one place.
Three isolates, one encrypted database
The app runs Dart code in three separate isolates, and each one has a different reason to exist.
| Isolate | Entrypoint | Role |
|---|---|---|
| Foreground service | backgroundMain | The primary path. Alive as long as the service is. Captures, persists, signs, sends, and runs the timers. |
| User interface | main | Dashboard, logs, reliability walkthrough, settings. Opens its own connection to the same encrypted file. |
| WorkManager | callbackDispatcher | A roughly 15-minute backstop sweep for anything the immediate path missed. |
They all read and write the same SQLCipher database file, and they can all decide to send at the same moment. That is safe by construction rather than by luck: the backend deduplicates on the content hash and the per-request nonce, so a double send comes back as a duplicate, and a duplicate is treated by the client as success. There is no lock to get wrong, because there is nothing that two writers can corrupt.
There is a deliberate absence in the diagram: no business logic lives in Kotlin. Kotlin does exactly three things. It keeps the process alive, it reassembles multipart message parts, and it reads the inbox content provider. Persistence, allowlisting, signing, retry policy and HTTP are all in one Dart code path shared by every isolate. One code path means one place for a bug to live, and it means the WorkManager backstop cannot drift out of agreement with the live path, because they are literally the same code.
The journey of one message
Here is the whole path a payment confirmation takes, from the moment Safaricom's SMSC stamps it to the moment it becomes a row in the ERP.
The allowlist step deserves a note, because it is a privacy control and not a convenience feature. A sender that does not match is dropped before anything is written to disk and before anything crosses the network. It is never stored, so it can never leak. The match is deliberately fuzzy: uppercase the sender, strip hyphens and spaces, then substring match, so a single MPESA entry catches MPESA, M-PESA and the shortcodes that embed it.
bool senderAllowed(String sender) {
if (allowlist.isEmpty) return false;
final s = sender.toUpperCase().replaceAll('-', '').replaceAll(' ', '');
for (final raw in allowlist) {
final token = raw.trim().toUpperCase().replaceAll('-', '').replaceAll(' ', '');
if (token.isEmpty) continue;
if (s.contains(token)) return true;
}
return false;
}
An empty allowlist matches nothing rather than everything. That default is the right way round: a misconfigured gateway that forwards no messages is an operational problem, while one that forwards every SMS on the phone is a data-protection incident.
The integrity model
A payment record that anybody can forge is worse than no payment record, because people will trust it. So every request is signed with a per-device shared secret using HMAC-SHA256, lowercase hex. The operator generates the secret, types it into the phone once, and registers it on the backend. It is stored in flutter_secure_storage, which on Android is Keystore-backed, and it never travels over the wire.
Two distinct values do the work, and conflating them is the classic mistake.
The first is the message hash, which is about the identity of the SMS itself:
/// message_hash = SHA256( sender | message | received_at )
static String messageHash({
required String sender,
required String message,
required String receivedAt,
}) {
final canonical = '$sender|$message|$receivedAt';
return sha256.convert(utf8.encode(canonical)).toString();
}
The load-bearing decision is that received_at is the PDU timestamp, the moment the network stamped the message, and not the moment the app happened to see it. Because of that, a duplicate delivery or a double receiver fire collapses onto the same hash, while two genuinely different messages never collide. If you hashed arrival time instead, the same SMS captured twice would produce two hashes and your "duplicate detection" would silently do nothing.
The second is the string to sign, which is about the authenticity of this particular request. It is newline-joined, in a frozen field order, with no trailing newline, and with the signature field itself excluded:
device_id \n sender \n message \n received_at \n sim_slot \n message_hash \n nonce \n sent_at
A heartbeat signs a shorter string, device_id then nonce then sent_at. Every timestamp on the wire is East Africa Time as ISO-8601, for example 2026-06-19T12:30:00+03:00, formatted by a single helper so that the bytes are identical on both sides of the connection.
None of this survives casual maintenance unless it is defended, so it is defended by parity tests that assert the literal joined string rather than testing the function against itself:
expect(
s,
'PHONE_001\nMPESA\nhi\n2026-06-19T12:30:00+03:00\n1\nabc\nn-1\n2026-06-19T12:30:01+03:00',
);
Both the Dart implementation and the Python one carry a comment saying the two must match byte for byte and that neither side may be changed alone. The whole integrity design gets its own article, because the nonce-versus-hash distinction is subtle enough to be worth a slow walk.
The backend contract
The app posts to three fixed paths. The operator configures only the base URL.
| Method | Path | Purpose |
|---|---|---|
POST | /api/sms/incoming | One signed SMS. Verify, store, then parse downstream. |
POST | /api/sms/heartbeat | Signed health report. Doubles as the Test Connection handshake. |
GET | /api/app/version | The in-app update feed, since there is no Play Store. |
The incoming body is deliberately boring:
{
"device_id": "PHONE_001",
"sender": "MPESA",
"message": "raw SMS body",
"received_at": "2026-06-19T12:30:00+03:00",
"sim_slot": 1,
"message_hash": "...sha256 hex...",
"nonce": "uuid-v4",
"sent_at": "2026-06-19T12:30:01+03:00",
"signature": "...hmac sha256 hex..."
}
What is not boring is the order in which a conforming backend must check it. Every one of these rules exists because skipping it opens a hole:
| # | Check | Response |
|---|---|---|
| 1 | Unknown or disabled device | 401 |
| 2 | Recomputed message_hash does not match the supplied one | 400 |
| 3 | Invalid HMAC signature | 401 |
| 4 | sent_at outside the 5-minute skew window | 400 |
| 5 | Nonce already used, so this exact request is a replay | 409 |
| 6 | message_hash already stored | 200 {"status":"duplicate"} |
| 7 | Otherwise, store it | 200 {"status":"accepted"} |
Rules 5 and 6 look like the same rule and are not. A reused nonce means the identical signed request arrived twice, which usually means a response was lost rather than an attack. A duplicate hash means two separate deliveries of the same underlying SMS. Both end at "already handled", through different doors, and the client treats both as success. The alternative, treating a 409 as an error, produces a message that retries forever against a server that has already stored it.
Two runnable reference receivers ship with the project: a FastAPI one with SQLAlchemy, and a Frappe one that is wired into ERPNext. The signing helper in the FastAPI sample is thirty lines and the constant-time comparison is the only clever part:
def verify_signature(string_to_sign: str, secret: str, signature: str) -> bool:
expected = sign(string_to_sign, secret)
# Constant-time comparison.
return hmac.compare_digest(expected, signature or "")
What happens when the send fails
Most of the time it will. Not because the backend is bad, but because a phone on a mobile network in a shop with a metal roof is not a datacentre. So failure is a first-class state with three distinct meanings:
| Outcome | Trigger | Behaviour |
|---|---|---|
success | 2xx, a body containing "duplicate", or HTTP 409 | Mark synced. Done. |
transient | Network error, timeout, 5xx | Stay queued, back off, retry. |
permanent | 4xx other than duplicate: bad signature, unknown device | Stays queued, but retrying will not help. The operator must fix configuration. |
That third row is the one people leave out, and leaving it out is how you get a queue that quietly grinds against a wrong secret for a week. A permanent failure is a message to a human, not to the retry loop.
The retry policy itself is short enough to state in full: a base delay of 15 seconds doubling per attempt, capped at one hour, over a maximum of eight attempts, in batches of 25 per sweep. The delay uses full jitter, meaning the actual wait is a random value between zero and the capped exponential rather than the exponential plus a wobble. With a fleet of till phones that matters: without it they all come back at the same instant after an outage and knock the backend over a second time.
Meanwhile the sweep itself runs every 15 seconds, which sounds contradictory until you separate the two clocks. The sweep is aggressive so that a message that failed during a thirty-second backend restart goes out almost immediately. The per-message backoff is what stops the same message hammering a server that is genuinely down. Fast sweep, slow per-message retry.
Knowing the gateway is alive
The failure that actually hurts is not a message that fails loudly. It is a gateway that died at 11am and was discovered at 6pm during reconciliation. So the phone reports in.
Every five minutes it sends a signed heartbeat carrying app version, pending, failed and synced counts, the timestamp of the last SMS, battery level, charging state and connectivity. The FastAPI sample turns that into fleet-level monitoring with a single endpoint, GET /api/devices/offline?threshold_minutes=10, which answers the only question an operations person actually has: which of my tills have gone quiet?
On the phone itself, the foreground service writes a "last alive" timestamp to shared preferences every 60 seconds, in both wall clock and elapsed-realtime form, and the dashboard's watchdog flags the service stale after three minutes of silence. The operator learns from the phone in their hand that the gateway stopped, rather than learning it from a missing day of payments.
Where the M-Pesa logic actually lives
Not on the phone. The device forwards the raw body and nothing else. On the ERPNext side, a downstream parser decides whether a given SMS is a payment at all, and it is conservative about it: a message counts only if the body contains both "received" and "confirmed", so balance messages and debit notifications are marked ignored rather than mangled into a payment. From a qualifying message it extracts the transaction code, the amount, the payer's phone, the payer's name and the transaction time.
Then comes the part that makes the whole design worth it. The parsed result goes through the same upsert_mpesa_payment function that the Daraja webhook uses, deduplicating on the unique transaction code. The SMS gateway is not a parallel payment system. It is a second source feeding one reconciliation flow, with source recorded as either "SMS Gateway" or "Daraja". A shop can start on SMS, get approved for Daraja later, and run both at once during the transition without a single duplicate payment, because the transaction code is the transaction code either way. That argument is the subject of a separate article on keeping the pipe dumb, and it is the decision I would defend hardest.
What I would do differently
Three things.
The one in-memory window is still there. When the foreground service starts from cold, the Flutter background engine takes about a second to boot, and an SMS arriving inside that window is held in an in-memory queue until Dart signals that it is ready. It is documented, it is bounded, and the inbox backfill will recover anything lost to a crash inside that second. But it is the single place in the system where a message exists only in RAM, and I would rather it did not exist at all. A tiny Kotlin-side write-ahead file would close it. I have not done it because the backfill genuinely covers the case, and adding a second persistence path in a second language cuts against the one-code-path rule that has kept this thing correct.
The secret is entered by hand. Somebody types a shared secret into a phone. It works, it is auditable, and it has no dependency on anything, but it is the least pleasant part of provisioning, and it is where a support call becomes a security incident if the wrong person reads it out. A QR-based enrolment with a short-lived, single-use token would be better, and it is what I would build next.
Everything assumes East Africa Time. The timestamp helper is hard-wired to +03:00 with no daylight saving, which is correct for where this runs and wrong the day it runs anywhere else. That is a deliberate simplification and not an oversight, because a fixed offset removes an entire class of signature mismatch. But it is a constraint the code should state louder than it currently does.
What I would not change is the shape. A doorbell, a service that is genuinely alive, an encrypted queue written before the network is touched, one signed contract, and no cleverness whatsoever on the device. Everything difficult about this project came from the phone fighting to stay running, and that is where the next article goes.
Frequently asked questions
- Why use SMS at all when M-Pesa has an official API?
- Because Daraja is not available to every merchant. Onboarding has tier requirements and paperwork, and a large number of real shops in Kenya never get through it. Those shops still receive a payment confirmation SMS on a phone at the counter. The gateway turns that SMS into a structured, signed, reconcilable record without a Daraja account. Where Daraja is available, use Daraja: it is a webhook from Safaricom rather than a phone you have to keep alive.
- Does the app parse M-Pesa messages on the phone?
- No, and that is the central design decision. The phone captures, encrypts, signs and forwards the raw SMS body. Every piece of business meaning, including the M-Pesa transaction code and amount, is extracted on the backend. One dumb pipe therefore serves M-Pesa confirmations, OTP relay and SMS-based alerts, and a parser change ships to the server without touching a single phone in the field.
- What happens to a message if the phone is offline?
- Nothing is lost. Every allowlisted SMS is written to the encrypted on-device queue before any network attempt is made. A sweep runs every fifteen seconds, a connectivity-regained listener fires an extra sweep the moment the phone is back online, and each individual message carries its own exponential backoff with full jitter, capped at one hour over a maximum of eight attempts.
- Is it safe to have a phone reading payment SMS?
- It is safe to the degree you engineer it. Messages from senders outside the allowlist are dropped before they are stored or transmitted, so they never leave the phone. Stored messages sit in a SQLCipher-encrypted database whose key is a 256-bit random value in the Android Keystore. Every request travels over HTTPS, enforced in code, and carries an HMAC-SHA256 signature computed with a per-device secret that never crosses the wire.
- Can I get this app from the Play Store?
- No, deliberately. Google Play forbids the READ_SMS and RECEIVE_SMS permissions for any app that is not the device's default SMS handler, and grants no exception where an official API exists. For M-Pesa, Daraja is that official API. The app is built for private distribution as a signed APK on devices the deployer owns.