Encrypted networking · SnapPay
A transport layer that can lose its key mid-transfer and not tell the user
Every request and response encrypted above TLS with a server-issued AES key. Keys expire without warning. The client’s job is to notice, recover, and replay, invisibly and exactly once.
- Role
- Flutter Engineer
- Organisation
- SnapPay
- Period
- 2026
- Surface
- Flutter
The problem
SnapPay puts wallets, transfers, bill payments, savings, invoices and QR pay in front of live Nigerian users. Payloads are encrypted end-to-end on top of TLS using an AES key the server issues.
Those keys rotate. Nothing tells the client in advance. It finds out by being rejected with a 403, at which point the user’s transfer is already half-made and sitting in memory.
Constraints
- Rotation is not on a schedule the client can predict. The only signal is a rejection.
- A 403 during a transfer must never reach the user as an error. It has to be invisible.
- The recovery replay must not create a second transfer.
- 401 and 403 look nearly identical at the wire and mean entirely different things: one is “your session is over”, the other is “your key is stale”.
Decisions
Put encryption in an interceptor, not at the call sites
Encrypt on the way out, decrypt on the way in, in one place in the pipeline. Every feature added afterwards is encrypted by construction rather than by discipline.
What I rejected
Encrypting explicitly in each repository. More visible, and more honest-looking in review, until the twelfth feature forgets once and you ship a plaintext endpoint you cannot detect from the outside.
Single-flight the key refresh
The first 403 starts a refresh. Concurrent 403s await that same in-flight future instead of each starting their own, and all of them replay against the new key once it lands.
What I rejected
Refreshing per failed request. Under a burst, say a dashboard fanning out six calls on load, you get six refreshes, five of which invalidate each other, and the app settles into a login loop that only reproduces on slow networks.
Separate 401 from 403 at the transport boundary
403 means refresh the key and replay silently. 401 means tear the session down, clear secrets, and route to login. The distinction is made once, at the edge, and never re-litigated upstream.
What I rejected
A single generic auth-failure handler. Fewer lines, and it will eventually log someone out in the middle of sending money because a key rotated.
Make failures values, not exceptions
A layered MVVM core with Provider and get_it, and dartz Either at the domain boundary, so a decryption failure or a rotation timeout is a value the view model has to handle rather than an exception thrown through three layers into a global catch.
What I rejected
Exception-based control flow. It compiles, it’s idiomatic in a lot of Dart, and it makes the set of things a screen can fail at unknowable from the screen’s own signature.
Where it landed
- Transfers, Prembly KYC (BVN / NIN / liveness), biometric auth, QR scan-to-pay, PDF receipts and deep links all ride the same transport, all encrypted, all rotation-safe.
- Changes shipped backwards-compatible and feature-flag-gated into an app with live users and live money.
- Flutter
- AES request/response encryption
- MVVM
- Provider
- get_it
- dartz
- Prembly KYC
Working on something like this?
I’m open to senior mobile and full-stack roles, and I’m relocation-ready.
donaldamadi15@gmail.com