The problem is reliability, not reading SMS
Reading an incoming SMS on Android is a solved problem: register a receiver, get the message, done. That is why people underestimate this. The actual engineering problem is delivering that message to a backend reliably, under conditions that are actively hostile to background work - flaky mobile data, aggressive battery management, reboots, and an OS that would rather kill your process than let it run.
When the payload is a payment, reliability is not a nice-to-have. A dropped message is a payment your system never learns about. So the design has to assume that everything that can fail will, and lose nothing when it does. Upeosoft's SMS Gateway is built around that assumption, and it is open source (github.com/Upeosoft-Limited/upeo-sms-gateway), so the patterns below can be read in full rather than taken on faith.
Durability first: queue before you send
The foundational rule is that a message is persisted before any network attempt. The receiver's only job is to write the incoming SMS into a durable local store; sending happens separately against that store.
- Write the message to an encrypted database (SQLCipher) the instant it arrives.
- A separate sync path reads pending rows and attempts delivery.
- A row is only marked delivered after the server acknowledges it.
- Failed sends stay pending and are retried, so a crash or network drop never loses data.
Trust: sign every message
Your webhook is on the public internet, so the server must be able to prove that a message genuinely came from your device and was not tampered with. That is what HMAC-SHA256 signing provides. The device computes a signature over a canonical byte representation of the message using a shared secret, and the server recomputes the same signature to verify it.
Two details make this actually work. First, canonicalisation: device and server must serialise the message identically, byte for byte, or genuine messages will fail verification. Second, secret handling: the shared secret lives in the Android Keystore and is used only to compute the signature - it never travels over the wire. The server should also reject messages with a stale timestamp, so an intercepted request cannot be replayed hours later.
Exactly-once: hashes, nonces and duplicate reporting
SMS and networks both create duplicates. M-Pesa-style confirmations can be re-read, retries can double-send, and the OS can redeliver. If a duplicate becomes a second ledger entry, you have corrupted the books. Exactly-once semantics prevent that.
The mechanism is a stable message hash - derived from the canonical content - plus a per-send nonce. The server keeps a record of hashes it has already processed and returns a duplicate status when it sees one again, so the second arrival is acknowledged but not re-recorded. On the device side, delivery is idempotent: because a message is only marked done on acknowledgement, retrying a message the server already has is safe. The contract between device and server is what makes 'at least once' transport behave as 'exactly once' in effect.
Liveness: foreground service plus a scheduled backstop
None of the above matters if the app is not running when messages arrive or when the network returns. Android will throttle or kill background work, and OEM battery managers are worse. The answer is layered liveness.
A persistent foreground service owns SMS reception and the primary, real-time sync path - it is visible to the user and far harder for the OS to kill. Behind it, a scheduled worker (WorkManager) runs on an interval as a backstop, sweeping the pending queue in case the service was ever suspended. A boot receiver restarts the whole thing after a reboot or an app update. And because OEM behaviour varies wildly, first-run setup should walk the operator through the battery-optimisation exemption for their specific device, since that step is what makes long-term reliability hold.
The retry policy that ties it together
Retries need to be aggressive enough to recover quickly but polite enough not to hammer a struggling server or drain the battery. Exponential backoff with full jitter is the standard answer: each failed attempt waits longer than the last, jitter spreads retries so many devices do not sync in lockstep, and a cap keeps the interval bounded. A maximum attempt count with a sensible ceiling prevents a permanently failing message from retrying forever.
Put together - durable queue, signed messages, exactly-once contract, layered liveness, and disciplined retries - these turn 'read an SMS and POST it' into a gateway you can trust with real money. That is the difference between a demo and production, and it is the same difference we build for whenever a business depends on a message actually arriving.
