Noria Pay: one payments service instead of twenty-one integrations
2026-09-22 / 4 min / payments / m-pesa / daraja / idempotency / infrastructure
Every product that took money had rebuilt the same layer: persist the transaction, host the callback, normalise the payload, reconcile the settlement. Roughly 4,800 lines of it in one codebase and 12,000 in another. This is that layer, written once.
The library was never the problem
We already had payment libraries. A PHP package, a Node package, a Python one. Each wraps the providers competently and each stops at exactly the same place.
What a payments library deliberately leaves to you is the part that takes the time: persisting the transaction, hosting the callback, normalising the payload, and reconciling the settlement. That is not integration work. That is a small stateful service, and every product had written its own version of it.
We measured it before deciding to build. Roughly 4,800 lines of that layer in Zana. Roughly 12,000 in the billing product. A variant of it in nineteen others. Twenty-one codebases carrying the same responsibility, each with its own bugs, each needing the same fix twice a year when a provider changed something.
Noria Pay is that layer, once.
What it is and is not
It moves money and reports what moved. It does not keep your ledger.
That boundary is the whole design. You post a charge with your own reference and your own metadata, you get the payer’s next action back, you get a signed webhook when it settles, and you write your own journal entry. Pay never learns what the money meant.
The temptation to absorb the ledger is constant and has to be refused every time. A payments service that also knows your invoices becomes a second place where a balance lives, and second balances are how reconciliation breaks. The product owns the meaning; Pay owns the movement.
Four providers, one contract
Collections run through M-PESA Daraja, SasaPay, Paystack and KCB Buni. Payouts run through Daraja B2C, SasaPay b2c and b2b, and KCB Buni funds transfer. Refunds run through Paystack, a Daraja reversal, and SasaPay sending the money back.
Those are three different shapes of operation across four providers, and none of them agree on anything: not the callback format, not the verification method, not what a failure looks like, not whether a timeout means the money moved.
The normalisation is the product. A caller posts a charge and gets back a next action. Whether that is an STK prompt on a phone or a hosted card checkout is a provider detail, and the calling product should never have branched on it in the first place.
The parts that are not glamorous and are the entire job
Idempotency on every write. A charge carries your reference. Replaying it returns the original charge rather than creating a second one. Without this, a retry after a network blip charges a customer twice, and you find out from the customer.
A callback sink with per-provider verification. Every provider calls back differently and every provider’s callback is an untrusted input from the public internet until it is verified with that provider’s own scheme. This is the single most attacked surface in the service and it is the one place where sharing code across providers would have been a mistake.
A status poller, because callbacks get lost. Callbacks are best effort from every provider here. A charge that is waiting is polled until it resolves. A system that only learns about settlement from callbacks has a permanent population of transactions stuck in limbo.
Credentials sealed at rest, per project. Each project brings its own provider credentials. They are encrypted per project, and payment_methods.mode chooses sandbox or live, so a local run pointed at the wrong environment fails rather than spending real money.
Statement reconciliation. Post a statement and it settles what it matches, which is the same problem Zana solves for invoices, solved here for raw movements.
Status
Pay is new. It was scaffolded in September 2026 and is being adopted product by product rather than all at once, starting with the flows that were already the most duplicated.
I am writing it up now because the decision it embodies is the interesting part, and that decision was made before a line was written: measure the duplication first, and only build the shared service if the number is embarrassing. 4,800 and 12,000 lines across twenty-one codebases is embarrassing.
If you have the same layer copied across several products and you are deciding whether to extract it, send me a brief. The measurement is usually worth doing before the extraction.
Read next
- Noria Send: email and SMS behind one API
A Resend-shaped service over Amazon SES and OnFon Media. One outbox, one event stream, one suppression list, and a careful answer to the hardest question in delivery: what to do when a provider never replies at all.
- SiteBooks: construction management for a client who cannot visit the site
Four years of production construction software for contractors and the diaspora clients funding the build. Time-stamped site photos, budgets tracked against the BOQ, milestone payments, and M-PESA payroll for casuals, all of it usable on 3G.
