◀ Knowledge hub

05, Payments & monetization

Reconciling a dual rail ledger, M-Pesa and Stripe

codeAmani Labs Engineering
Cinematic still for Reconciling a dual rail ledger, M-Pesa and Stripe

Two payment rails, one ledger

A product that takes both M-Pesa and card payments has two sources of money that behave nothing alike. Mobile money confirms by calling you back, sometimes more than once, sometimes late. Cards confirm through a different webhook with a different shape and different failure modes. If each rail writes to the system in its own way, you end up with two half truths and no single answer to "how much did we actually take".

Normalize both rails into one internal shape

The fix is an internal ledger that does not care which rail a payment came from. Each rail has a thin adapter that turns its confirmation into the same internal record: an amount in the smallest integer unit, a currency, a stable external reference, and a status. Downstream, the application reads the ledger, never the rail.

type LedgerEntry = {
  orderId: string;
  rail: "mpesa" | "stripe";
  externalRef: string;   // Daraja transaction id or Stripe event id
  amountMinor: number;   // integer, no floats
  currency: string;
  status: "pending" | "settled" | "failed";
};

The external reference is what makes it idempotent

Both rails retry their confirmations. The external reference, the Daraja transaction id or the Stripe event id, is the key that lets you record each real payment exactly once. A unique constraint on (rail, externalRef) turns a duplicate confirmation into a harmless no op instead of a double credit.

Reconcile on a schedule, not on hope

A nightly job is the safety net. Pull what each rail says it settled, compare it to what the ledger recorded, and surface the differences. A payment the rail confirmed but the ledger missed, perhaps a callback that arrived during a deploy, shows up as a discrepancy you can fix, rather than as money that quietly vanished.

-- payments the rail reports as settled but the ledger never recorded
select r.external_ref
from rail_settlements r
left join ledger l on l.external_ref = r.external_ref and l.rail = r.rail
where l.id is null;

Why this is the hard part

Taking a payment is the demo. Knowing, at the end of every day, that the money in both systems matches the money in your ledger is the business. The reconciliation job is unglamorous and it is the thing that lets you trust your own revenue numbers, across two rails that will never agree on their own.

Qualified conversation

Have a build to de-risk? Let's talk.

Tell us what you are building. We respond within two business days.