A payment source, not a payment parser: what keeping the pipe dumb actually buys
There is a moment in every integration where you can see the shortcut. The phone already has the M-Pesa message in memory. A ten-line regular expression pulls out the transaction code and the amount. You could send the server a clean little JSON object with four fields instead of a raw text blob, and the server would not have to do anything except write a row.
The gateway I built does not do that, and the decision not to is the one I would defend hardest of everything in the project. This article is the argument, including the parts of it that cost something.
the phone forwards the raw SMS body and nothing else. Every piece of meaning is extracted on the server.
- The parser is the part that changes; the phone is the part that is hard to change. Keep them apart.
- One dumb pipe serves M-Pesa confirmations, OTP relay and SMS alerts. A smart client would need a build per use case.
- The parsed result goes through the same function the Daraja webhook uses, so both sources share one reconciliation flow and one idempotency key.
- The cost is real: raw bodies cross the wire, and the server has to own deduplication because a dumb client will resend.
Where the line is drawn
The system has three layers and each one is allowed to know a specific amount.
| Layer | Knows about | Does not know about |
|---|---|---|
| Kotlin | Broadcasts, PDU reassembly, SIM slots, the content provider, keeping the process alive | What an allowlist is. What a payment is. Where the server is. |
| Dart | Which senders to keep, encryption, the queue, signing, retry policy, HTTP | What the message means. That M-Pesa exists at all. |
| Backend | Transaction codes, amounts, payers, reconciliation, everything semantic | Nothing about phones, PDUs or battery managers. |
Read the middle row again, because it is the surprising one. The Dart layer signs a payload containing an SMS body and has no idea what is in it. The word "MPESA" appears in that layer exactly once, as the default value of a configurable allowlist:
static const List<String> defaultAllowlist = ['MPESA'];
Change that string in settings and the same binary becomes an OTP relay, or a forwarder for a bank's alert shortcode, or a bridge for an SMS-based sensor. Nothing else in the app has to change, because nothing else in the app ever knew.
The Kotlin layer is even stricter about it, and the comment in the parser says so out loud:
/**
* A single, logical (already multipart-reassembled) incoming SMS.
*
* The gateway is a *dumb pipe*: [body] is the raw SMS text, never parsed or
* modified. Business semantics (M-Pesa codes, OTPs, ...) belong on the backend.
*/
data class IncomingSms(
val sender: String,
val body: String,
/** PDU / SMSC timestamp in epoch millis (when the network stamped it). */
val timestampMillis: Long,
val subscriptionId: Int,
val simSlot: Int,
)
Even the allowlist is not applied in Kotlin, although it easily could be. The receiver hands the raw message to the service, and the Dart side drops non-allowlisted senders before anything is written or sent. That looks like the wrong place to filter, because the message crosses one in-process channel before being dropped. It is the right place anyway: the allowlist is configuration, configuration lives in the encrypted store the Dart layer owns, and duplicating a privacy rule across two languages means two implementations that must agree forever. One is enough.
What it buys, concretely
One app, several jobs
The phone at the till forwards M-Pesa confirmations. The same APK, with a different allowlist entry, forwards one-time passcodes to a service that needs them. A third deployment forwards alerts from an SMS-capable sensor. Nobody built three apps, nobody maintains three release branches, and nobody has to remember which build is on which phone.
A smart client cannot do this without becoming a plugin host, which is a much larger thing than an SMS forwarder and comes with all the questions a plugin host comes with.
Parser changes never touch a phone
This is the one that pays for itself repeatedly. The M-Pesa parser is a set of regular expressions:
_AMOUNT_RE = re.compile(r"Ksh[\s]*([\d,]+(?:\.\d{1,2})?)", re.IGNORECASE)
_CODE_RE = re.compile(r"^\s*([A-Z0-9]{8,12})\b")
_PHONE_RE = re.compile(r"(?:\+?254|0)(7\d{8})")
_NAME_RE = re.compile(r"from\s+(.+?)\s+(?:\+?254|0)?7\d{8}", re.IGNORECASE)
Every one of those will be wrong about some message eventually. The name pattern in particular is doing something delicate: a non-greedy capture bounded by a phone number, with a second fallback pattern bounded by the word "on" for the messages where the number is formatted differently. Getting that wrong on a subset of messages is not hypothetical, it is Tuesday.
On the server, being wrong is a fix, a deploy, and a re-run of process_pending_messages over everything that failed. On a fleet of sideloaded phones, being wrong is a rollout, and until every phone updates you have devices reporting structured data derived from an outdated understanding of a format. Worse, the raw text those devices saw is gone, so you cannot re-derive anything: the phone threw away the evidence and kept its conclusion.
That is the deepest reason for the design. A dumb pipe preserves the raw material. A smart client destroys it and keeps an interpretation. Interpretations get revised. Raw material does not.
Two sources, one reconciliation flow
And here is the payoff that makes the whole thing worth it.
The parsed result goes into the same function the Daraja webhook calls, and that function deduplicates on the unique transaction code. A shop that starts on the SMS gateway and gets approved for Daraja later can run both at once through the entire transition and produce not one duplicate payment, because both doors lead to the same idempotency key.
Now imagine the smart-client version. The phone extracts a transaction code and posts a payment object. Where does it go? Either into the same payment table, in which case the phone is now responsible for producing exactly the identifier that Safaricom's webhook produces, forever, in agreement with a server it cannot see. Or into a separate table, in which case somebody writes a reconciliation job to merge two tables of payments, and that job becomes a permanent tax on every feature that touches money.
Neither is better. Both are strictly worse than "the phone sends text and the server decides what it means".
The device stays testable
A dumb pipe has a small surface. Does it capture every allowlisted SMS? Does it persist before sending? Does it retry correctly? Does it sign correctly? Those are four questions with clear answers, and none of them requires a sample M-Pesa message.
The moment the device parses, the device has a semantic test suite. Every new message format is a new device test, a new build, and a new rollout. The app's test directory contains parity tests for the canonical signing string and nothing about payments at all, and that is a deliberate consequence of the boundary rather than an oversight.
What it costs
Three things, and pretending otherwise would be dishonest.
Raw bodies cross the wire
A structured payload could carry only a transaction code and an amount. The raw body carries the payer's name, their phone number, the amount and the balance, in full, in text.
That is a real obligation and it is handled with real controls rather than a shrug. Non-allowlisted senders are never stored or transmitted, so the personal messages on that phone never leave it. Stored messages sit in a SQLCipher database whose key is a 256-bit value in the Android Keystore. Transport is HTTPS, enforced in code with a hard refusal to send over plain HTTP. Synced messages are purged after a configurable retention window, 14 days by default. And the app's own log screen never renders a full body in a list:
/// Privacy-preserving preview for the UI log (never show the full body in lists).
String get maskedPreview {
final body = message.replaceAll('\n', ' ').trim();
if (body.length <= 18) return body;
return '${body.substring(0, 18)}...';
}
Eighteen characters is enough to recognise a message and not enough to read one over somebody's shoulder at a counter. Under Kenya's Data Protection Act the deployer is the data controller for the messages they forward, which means deploying on a dedicated device they own, disclosing the processing to the people whose payments are being handled, and registering with the ODPC where that applies. A smart client would reduce what crosses the wire; it would not remove the obligation, because the phone would still be reading and storing the same messages.
The server must own deduplication
A dumb client resends. It has to, because the alternative is losing messages when a response goes missing. So exactly-once cannot live on the device, and the server is the only place it can be enforced.
Which the server does, twice: a per-request nonce catches a repeated delivery of the same signed request, and a content hash catches two distinct deliveries of the same underlying SMS. Both come back as success so the client's queue drains. That machinery is not free, it is an article's worth of design, and it is a direct cost of the dumb-pipe choice. It is a cost I would pay again, because deduplication logic on a server is inspectable and fixable and deduplication logic spread across a hundred phones is neither.
You cannot act locally
The phone cannot show a cashier the amount that just came in, because it does not know. It cannot beep differently for a large payment. It cannot work offline in any sense richer than "queue and forward".
For this product that is fine, because the point of sale is a separate screen and it is the thing the cashier is already looking at. For a different product it might not be, and that is the honest boundary of the argument: if the client has to act on the data, the client has to understand the data, and then you are building something else.
The question underneath
Every integration eventually asks: where does meaning belong?
The test I have settled on is not about layers or coupling. It is about rates of change and cost of change, and it is two questions.
Which part of this system changes more often, the transport or the interpretation? For a payment SMS the answer is emphatic. The transport has not changed since the first version. The interpretation has been revised more than once, and it will be revised again the next time Safaricom adjusts a word.
Which part is more expensive to change? A server deploy takes minutes. A sideloaded APK reaching every phone in every shop takes as long as the slowest shopkeeper takes to notice the update prompt, and there is no Play Store to force it.
Put meaning on the side that is cheap to change, and put as little as possible on the side that is expensive. That is the whole heuristic, and it happens to produce the same answer as "keep the client dumb" almost every time, which is probably why the slogan survives despite being a bad explanation of itself.
The corollary is worth stating too, because slogans cut both ways: when the expensive-to-change side is the server, and it sometimes is, put the meaning on the client. A design rule that only ever gives one answer is not a rule, it is a habit.
What I would do differently
I would give the pipe one small piece of self-knowledge: a content-type hint, chosen by the operator alongside the allowlist, and carried in the signed payload. Not a parse, just a label saying "the operator says messages from this sender are M-Pesa confirmations". Today the backend infers that from the sender string, which works and is slightly fragile, and a hint would let one backend serve several kinds of gateway without guessing. It stays inside the rule, because a label chosen by a human in settings is configuration, not interpretation.
I would also make the retention window aware of whether the backend has actually parsed the message, rather than only whether it accepted it. Right now the phone purges 14 days after a successful sync, and a message that was accepted but sat in Error on the server because a regex threw is gone from the device by the time anyone looks. The raw material still exists on the server, so nothing is lost, but the phone is deleting evidence based on a weaker signal than it could ask for.
And I would write the boundary down in the repository rather than in the comments. The rule survived because two comments in two languages say the gateway is a dumb pipe and the next person read them. That is fortunate rather than robust. A one-page design note saying what each layer may and may not know, checked in next to the code, is the cheapest architectural control there is, and I only wrote it once it became this article.
Frequently asked questions
- Why not parse the M-Pesa message on the phone and send structured data?
- Because the parser is the part that changes and the phone is the part that is hard to change. Safaricom adjusts message wording, a new till type produces a format nobody anticipated, and a regex turns out to be too greedy. On the server those are a deploy. On a fleet of sideloaded phones in shops they are a rollout, and until the rollout completes you have devices producing structured data from an old understanding of the format.
- Does forwarding raw SMS bodies create a privacy problem?
- It creates a real obligation rather than a problem. Only allowlisted senders are ever stored or transmitted, so unrelated personal messages never leave the phone. What does cross the wire is the full body of a payment confirmation, including payer name, phone number and amount. That travels over HTTPS with a signature, is stored encrypted on the device, and is purged after a configurable retention window. The deployer is the data controller for it and should say so.
- What does the backend have to take responsibility for in this design?
- Deduplication, above everything. A dumb client will resend, and it should, because the alternative is losing messages. That means the server is the only place where exactly-once can be enforced, and it enforces it twice: a per-request nonce catches a repeated delivery of the same signed request, and a content hash catches two distinct deliveries of the same underlying message.
- When is a smart client the right answer instead?
- When the client has information the server cannot get, when bandwidth or cost makes sending raw data prohibitive, or when the client must act on the data locally without a round trip. None of those applied here: the phone has no context the server lacks, an SMS is under a kilobyte, and the phone is not making any decision. When they do apply, the calculus changes completely.