Russend moves money from Russia to recipients in Central Africa. Not into bank accounts - into mobile money wallets on Orange Money, MTN and the other networks people in those markets actually use. It ships on the App Store and Google Play.
I built the backend: the transfer lifecycle, the payment gateway integration, the real-time layer, and the deployment underneath all of it.
What follows is the architecture and, more usefully, the decisions I would defend and the ones I would make differently.
The problem, stated honestly
A remittance product looks like a form with two fields. The engineering is entirely in what sits behind them.
A sender in Moscow initiates a transfer in roubles. A recipient in Douala receives CFA francs in a mobile wallet. Between those two events: a currency conversion, a payout instruction to a network with its own settlement timing, and a variable delay during which neither party can see what is happening.
Three constraints shaped everything.
The recipient has no bank account. This is not an edge case, it is the market. Payouts land in mobile money wallets, and each network - Orange, MTN, Moov - has different rails, different failure semantics and a different idea of what "completed" means.
The sender is anxious. People send remittances for rent, school fees and medical bills. The question is never "did the API return 200". It is "did my mother receive it". A product that cannot answer that continuously has failed regardless of whether the money arrived.
Nothing here is atomic. There is no transaction spanning a Russian funding source and a Cameroonian wallet. The system fails partway through by design, and the architecture's job is to make every partial state recoverable.
Architecture
Deliberately small. A payments system with more moving parts than it needs has more places to lose money.
| Layer | Choice | Why | | --- | --- | --- | | API | Express.js on Node | I/O-bound work; the team knew it; no reason to be clever | | Data | MongoDB | Transfer records with provider-specific payloads of varying shape | | Payments | Paydunya | One integration fronting several regional mobile money networks | | Realtime | Socket.IO | Status transitions pushed to the sender's device | | Edge | Nginx on DigitalOcean | TLS termination, reverse proxy, static |
The gateway choice deserves a note. Integrating Orange Money, MTN and the others directly would have meant separate onboarding, separate compliance and separate failure semantics per network - before a single transfer moved. Paydunya aggregates them behind one API. The cost is a dependency in the settlement path and a layer of indirection when debugging a network-specific failure. For a product that needed to reach several markets at launch, that was the right trade. It would not be the right trade at ten times the volume, where the aggregator's margin and its outages both become yours.
Transfers as a state machine
The central decision, and the one everything else follows from: a transfer is not a request, it is a record that moves through states.
initiated ──▶ funded ──▶ dispatched ──▶ settled
│ │ │
└───────────┴────────────┴──────▶ failed ──▶ refunded
Every transition is written to the database before the external call that triggers it. This ordering is the whole design.
// Write the intent, then act on it.
await transfers.updateOne(
{ _id: id, status: "funded" },
{ $set: { status: "dispatched", dispatchedAt: new Date() } },
);
await gateway.payout({ transferId: id, ...details });
Written this way, a gateway timeout leaves a transfer sitting in dispatched
with no confirmation - which is a known, queryable, resumable state. Written
the other way round, the same timeout leaves a transfer in funded while money
may or may not already be moving, and the only way to find out is to ask a human
to check.
That distinction - recoverable versus ambiguous - is most of what separates a payments backend that can be operated from one that cannot.
The conditional in the updateOne filter matters just as much. Guarding on the
expected current state makes every transition idempotent for free: a retry that
arrives after the transition already happened matches nothing and changes
nothing.
The callback problem
Gateway webhooks retry, arrive out of order, and occasionally arrive twice. All three are correct behaviour on the provider's side and all three will corrupt a naive handler.
The handler does four things, in this order:
- Verify the signature against the raw body, before parsing.
- Insert the provider's event ID into a collection with a unique index. A duplicate key error means this event was already processed - acknowledge and stop. The unique index does the concurrency control; a read-then-write check is a race two simultaneous retries will both win.
- Apply a guarded transition, never a raw status write. A late
pendingarriving aftersettledmatches no valid source state and is correctly ignored, with no timestamp comparison anywhere. - Return 200 immediately. Anything expensive - notifications, receipts -
goes on a queue. The
200means "durably recorded", not "fully reacted to".
Reconciliation, which is the actual safety net
Webhooks make the system feel fast. They do not make it correct, because some callbacks never arrive at all.
A scheduled job walks every transfer that has been non-terminal for longer than its expected window and queries the gateway directly for the authoritative status. Anything the webhooks missed is caught here, usually within minutes.
This job is the reason the numbers add up at the end of the day. It is also the component I would build first if I started again - the webhook handler is an optimisation on top of reconciliation, not the other way around.
The real-time layer
Senders track transfers live. Socket.IO carries state transitions to the device as they happen, with two rules that make it survive mobile networks.
Persist, then emit. The database write is the event. The socket emit is a notification that the event happened. Reversed, a failed write leaves clients displaying a transfer state that does not exist.
Reconcile on reconnect. The client does not assume it received every event.
On connect it re-fetches the transfer record and applies whatever it finds.
A tunnel, a lift or a carrier handover becomes a latency problem rather than a
correctness one.
Emits are addressed to a room keyed on the user ID, not a socket ID. Users start a transfer on their phone and check it from a browser; room-per-user made both surfaces work without any connection bookkeeping.
Data model
MongoDB, for a specific reason rather than a default. A transfer accumulates provider-specific payloads - gateway responses, network references, failure reasons - whose shape varies by network and changes when a provider updates their API. A document model absorbs that without a migration per integration.
Two things I would insist on again:
- Append, don't overwrite. Each transition appends to a
historyarray alongside updating the current status. When a customer disputes a transfer three months later, the record tells you what happened and when, not just where it ended. - Store the provider's reference on the record itself, indexed. Every support conversation and every reconciliation query starts from it.
And the honest limitation: a document store gives you no cross-document transaction guarantees by default, which is fine here only because the state machine never needs one. If ledger entries and balances had been in scope, that calculus changes and I would want a relational store underneath.
Deployment and operations
Nginx on DigitalOcean, terminating TLS and reverse-proxying to Node processes. Domain via Namecheap, DNS split across the API and app surfaces. Deployments run from a pipeline rather than an SSH session - the difference matters most on the day something is broken and the person deploying is not the person who wrote it.
The operational lesson was about what to watch. Request rate and error rate tell you almost nothing here; volume is low and errors are usually the system working correctly. The signals that mattered:
- Age of the oldest non-terminal transfer. One number that catches stalled gateway calls, a dead worker, and a silently failing integration.
- Reconciliation job corrections per run. Non-zero is normal. A step change means webhooks are being lost.
- Transitions into
failed, by reason. Grouped, because one network failing looks completely different from all of them failing.
What I would do differently
A real queue, earlier. Retries and follow-up work sat in scheduled jobs and in-process handling. It held, but a proper broker would have made retry policy and backoff explicit rather than implied.
A ledger from day one. Status on a transfer document answers "where is it". It does not answer "does the money add up". Double-entry records would have made reconciliation a query rather than an investigation.
Structured logs keyed on transfer ID from the first commit. Retrofitting a correlation ID across an existing service is tedious, and every hour spent tracing a transfer through unstructured logs is an hour that the retrofit would have paid for.
What held up
The state machine. Two years of gateway quirks, retries and network-specific edge cases, and the model never needed restructuring - new failure modes became new transitions rather than new special cases.
Writing the transition before the network call. It is one line of ordering. It is the difference between a system you can operate and one you have to investigate.
