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.
The thing this is really about
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.
What a real callback looks like
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 failure that was mine, not theirs
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.
Small things that cost hours
Collected while getting there, none of them in the documentation:
-
A bodyless
POSTneeds an explicitContent-Length: 0. Without it you get HTTP411 Length Required— as an HTML error page, not JSON. Clients that sendTransfer-Encoding: chunkedinstead are refused the same way. This hits the token call, the most frequent call the adapter makes. -
Error bodies are
{"message": …, "code": …}. Map oncode.messageis prose for a human and must never be parsed. -
Response fields are conditional. A
PENDINGstatus carries nofinancialTransactionIdand noreason; those appear only once the payment settles. An adapter that requires them fails on every pending payment — which is most payments. -
A
202really is empty.Content-Length: 0, no body at all. The outcome exists only through the query. - The sandbox settles in EUR whatever country you think you are testing.
-
Test MSISDNs behave very differently from one another. One settles fast enough to poll for in a manual test; another stays
PENDINGfor 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.
Observed, assumed, and unknown
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.
What this is part of
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.
Top comments (0)