Nkap — an open mobile money gateway with a real double-entry ledger
Five submissions to MTN Mobile Money's sandbox, and what they cost me to learn.
On 16 September I put a catch-all recorder behind a Cloudflare tunnel, created a second MTN
API user pinned to that tunnel's hostname, and sent five collection requests straight to MTN
in curl — deliberately bypassing my own gateway, so that whatever came back
would be MTN's behaviour and not mine.
Everything about the five submissions was identical except one header.
| MSISDN | X-Callback-Url sent | Callback received |
|---|---|---|
| 46733123453 | no | none |
| 46733123453 | yes | yes |
| 46733123451 | yes | yes, twice |
| 46733123451 | no | none |
| 46733123450 | yes | yes |
Three carried the header. All three produced a callback. Two omitted it. Neither produced anything — not a rejection, not a delayed delivery, nothing at all.
That table says something the documentation does not. providerCallbackHost, the
field you register when you create an API user and can never change afterwards, is
an allow-list, not a destination. Registering it does not cause MTN to call
you. It only constrains where a supplied X-Callback-Url is permitted to point.
Omit the header and you are simply not called, silently, forever.
If you have ever wired up an integration, registered your callback host, and then sat watching a log that never fills, that is why.
Here is the question that decides whether a payment integration is correct or merely convincing: your request to the operator times out. What do you write down?
The tempting answer is FAILED. The request did not succeed, after all. It is
also the answer that eventually costs somebody real money, because a timeout says nothing
whatsoever about what happened on the other side. The operator may have never seen your
request. It may have seen it, debited the customer, and had its response lost on the way back
to you. Those two situations are indistinguishable from where you are standing, and they have
opposite consequences.
Writing FAILED is not recording an outcome. It is guessing one, and then
treating the guess as a fact forever after, because a terminal state is by definition the end
of the story.
So in Nkap an unanswered call becomes UNKNOWN, and UNKNOWN is
not terminal. It means exactly what it says: we do not know yet. A reconciler
picks the payment up afterwards and keeps asking the operator until the operator answers
something conclusive — or until a configured window runs out, at which point the payment is
escalated to a human rather than resolved by a machine that does not know either.
Escalation is not a verdict. It never produces FAILED. It produces a person.
That single rule — a timeout is never a failure — is the reason the rest of the system looks the way it does, and the rest of this post is the evidence that made me build it that way.
Here is the first one I ever received, verbatim, with the tunnel's own Cf-* and
X-Forwarded-* headers removed as Cloudflare artefacts rather than MTN's:
POST /callbacks/mtn-cm
Host: <the host registered as providerCallbackHost>
User-Agent: LWAC Http Client 1.0
Content-Type: application/json; charset=utf-8
Content-Length: 212
Accept-Encoding: gzip
Connection: keep-alive
{"externalId":"…","amount":"100","currency":"EUR",
"payer":{"partyIdType":"MSISDN","partyId":"46733123451"},
"payeeNote":"…","status":"FAILED","reason":"APPROVAL_REJECTED"}
Three things in there matter more than they look.
There is no signature and no credential of any kind. No
Authorization, no HMAC, nothing MTN-specific. Anyone who learns your callback URL
can post to it. I had argued in my own design notes that the callback endpoint should stay
unauthenticated on purpose and never be trusted as a source of truth; I can now say that as an
observation rather than an intention. A callback is a hint that something changed. It is never
the thing you write to the ledger without checking.
The body carries no referenceId — only externalId.
Whatever identifier you send on the way out is the only one you get back. If your design
assumed the operator would echo the reference you generated, it does not.
MTN retries a delivery you already answered. The third submission's callback
arrived twice — 22:00:51 and 22:03:50 UTC, the same body, three
minutes apart — to a recorder that had answered 200 the first time. There was no
acknowledgement problem to explain it.
Which means idempotency on your callback endpoint is not a nicety you add later. It is the first thing you write, or you will settle the same payment twice.
The most useful thing I observed that week was my own bug, and it is the clearest illustration of why the timeout rule matters.
A payment was accepted — 202, CREATED → SUBMITTED. MTN then
answered every status query with status: FAILED, reason:
INTERNAL_PROCESSING_ERROR. That is a terminal verdict, delivered promptly and
repeatedly.
My status mapper read reason before status. Once
reason matched a known-inconclusive value, the rule never looked at
status at all, and returned UNKNOWN. So the gateway concluded it did
not know — and then did exactly what it is supposed to do when it does not know:
09:26:24 RECONCILER … not conclusive (INTERNAL_PROCESSING_ERROR), changing nothing
09:27:24 RECONCILER … not conclusive (INTERNAL_PROCESSING_ERROR), changing nothing
09:29:24 RECONCILER … not conclusive (INTERNAL_PROCESSING_ERROR), changing nothing
09:33:25 RECONCILER … not conclusive (INTERNAL_PROCESSING_ERROR), changing nothing
09:41:25 RECONCILER … not conclusive (INTERNAL_PROCESSING_ERROR), changing nothing
One, two, four, eight minutes — the backoff doing its job, on a payment whose outcome had been sitting in the response the whole time.
Every individual component behaved correctly. The reconciler chased because it was told
UNKNOWN. It changed nothing on each pass because an inconclusive answer must never
overwrite what you already knew. The escalation window did what it was configured to do. The
defect was one line of precedence in a mapping function, and the system's correct behaviour
around it is what made the bug survivable instead of silently destroying a terminal
state.
That is the whole argument for the design, demonstrated against me.
Collected while getting there, none of them in the documentation:
POST needs an explicit Content-Length: 0.
Without it you get HTTP 411 Length Required — as an HTML error page, not JSON.
Clients that send Transfer-Encoding: chunked instead are refused the same way.
This hits the token call, the most frequent call the adapter makes.{"message": …, "code": …}. Map on
code. message is prose for a human and must never be parsed.PENDING status carries no
financialTransactionId and no reason; those appear only once the
payment settles. An adapter that requires them fails on every pending payment — which is
most payments.202 really is empty. Content-Length: 0, no
body at all. The outcome exists only through the query.PENDING for minutes and
reaches a terminal state only by callback. A test that submits and asserts immediately fails
for reasons that have nothing to do with the code under test.Every provider page in this project separates what was observed against a real operator from what is assumed from documentation, and ends with a section called Still unknown.
That discipline is not modesty. It is the only way a page like this stays worth reading a
year later, and it is what lets me tell you, precisely, that the callback run above went
straight to MTN in curl and never through the gateway — so what it establishes is
MTN's behaviour, and anything it implies about my own code is inference from source you can
read, not observation of software in motion.
I would rather publish a page that says what it does not know than one that quietly rounds up.
Nkap is an open-source, self-hosted mobile money gateway with a real double-entry ledger — MTN MoMo first, other operators through the same contract. It is Apache 2.0, it runs from published images with no credentials to pull them, and the whole thing stands up in about five minutes.
It has never handled real money. When it does, I would like the person running it to be someone I have never met.