Xedla Pay
Wallets, bank rails and escrow, across four backend services.
- 4
- Services I lead
- 10
- Transaction types
- 2023
- Live since
- 2
- App stores
The problem
Buying from a stranger online in Nigeria is a trust problem before it is a payments problem. The seller will not ship until the money moves; the buyer will not send money until the goods arrive. The usual resolution is a WhatsApp argument.
The product answers that with escrow: funds are held against a transaction, released when delivery is confirmed, and appealed to a human when it is not. That turns a trust problem into a state machine, and the state machine holds other people’s money.
What I built
I am the primary author of four of the backend services: identity and accounts, escrow, transactions, and virtual cards. Node and Express on MongoDB with Redis, each service owning its own data and deployed on its own pipeline, with a test suite and staging and production workflows.
The product moves money on banking-as-a-service rails through a licensed partner rather than a card gateway, with identity verification tiered against a KYC provider, so an account’s limits are a function of how much of itself it has proved.
I also wrote the request-encryption layer that sits between the clients and the API, and contributed the virtual-card and escrow screens to the Flutter app, which is live on both app stores.
Decisions I'd defend
Escrow is its own service with its own boundary, and the clients honour that boundary rather than pretending it does not exist. Escrow is the only part of the system holding funds that belong to neither party yet, and it is the part most likely to need a human in the loop. Keeping it separately deployable means a bad release elsewhere cannot take custody logic with it.
No money moves from the admin console on a single click. Releasing or reversing an escrow is modelled as a typed intent behind a confirmation step; rejecting a verification requires a written reason; issuing a manual credit enforces a ceiling and a justification before the request is made. Those all pair with a server-side action log, so every movement has an operator, an amount and a reason attached to it.
Authenticated requests from the console go through one wrapper rather than being scattered across components, so the bearer token, a request timeout and the refresh-and-retry behaviour are defined once. The argument for that pattern is visible in the codebase: the one screen that bypasses it is also the one screen that does not work.
What I got wrong
I shipped client-side encryption of request bodies from the admin console, deriving the key from a build-time public environment variable with a fallback to a hardcoded string. It could not have worked as a security control, because a key compiled into a browser bundle is not a secret, and the fallback meant a misconfigured build would encrypt with a value anyone could guess rather than failing loudly.
It also never functioned: the console derived the key one way and the server derived it another, so the two could not agree. It was removed a few weeks later, and the honest part of the story is why. It was not removed because anyone caught the cryptography. It was removed because it broke.
The lesson stuck. My own implementation of the same layer on the mobile client keeps the key off the public surface, and the server refuses to start without one rather than defaulting. A control that cannot fail loudly is not a control.
Separately, I hand-rolled a container build and deploy pipeline for one of the front ends, pushing an image to a registry and replacing the container over SSH. It has exactly one run in its history and that run failed. The site it was written for is served by managed hosting instead, and the pipelines I would actually point at are the ones on the backend services, which run on every pull request.
Where it is now
Live, with the mobile app on the App Store and Google Play and the services in production. The work continues under contract alongside my other roles.