Testing
The sandbox runs a deterministic simulator instead of real mobile-money operators. Outcomes are chosen by the MSISDN you send, so you can rehearse every path — including the ones that are hard to trigger against a real operator — before touching real money.
This is deliberately better than a real operator sandbox: no flakiness, no waiting for a test phone, and the same result every run, which makes it safe to assert on in automated tests.
Magic MSISDNs
| MSISDN | Outcome | Reason code | Use it to test |
|---|---|---|---|
237690000001 | Succeeds on the first status check | — | The happy path |
237690000002 | Pending ~10 s, then succeeds | — | Your polling loop — the realistic case |
237690000003 | Fails | payer_declined | Customer rejects the prompt |
237690000004 | Fails | insufficient_funds | Customer's wallet is empty |
237690000005 | Never completes, eventually expires | — | Timeout handling and expiry |
237690000006 | Rejected at submission (never reaches pending) | invalid_subscriber | Immediate rejection |
| anything else | Succeeds on the first status check | — | Convenient default |
These work identically for collections, payouts, and refunds.
Numbers are stable API behaviour — they will not be renumbered. Anything not in this table succeeds immediately, so arbitrary test numbers "just work".
Recommended test matrix
Before going live, prove your integration survives each of these:
| Scenario | How | What to verify |
|---|---|---|
| Successful payment | 237690000001 | Order fulfils exactly once |
| Slow payment | 237690000002 | You wait rather than assuming failure |
| Declined | 237690000003 | Order not fulfilled, customer sees a useful message |
| No response | 237690000005 | You handle expired and release the reservation |
| Invalid number | 237690000006 | Fails fast, does not hang |
| Retry after timeout | Same idempotency key twice | Exactly one transaction created |
| Duplicate webhook | Replay the same event id | Order not fulfilled twice |
| Forged webhook | POST unsigned JSON to your endpoint | Rejected with 401 |
The last three catch the bugs that actually cost money in production, and none of them require a real operator to test.
Local development
Running the whole platform locally needs Docker:
cd modules/backend
dotnet run --project src/MoniWave.AppHostThis starts Postgres, the Service Bus emulator, blob storage, the simulator, the APIs, and orchestration. Then run DataSeed (from the Aspire dashboard, explicit start) to create a merchant and key.
| Service | URL |
|---|---|
| Public API | http://localhost:6003 (health: /v1/ping) |
| Panel API | http://localhost:6001 |
| Simulator | http://localhost:6002 |
Seeded merchant mer_local, key sk_test_LocalDevOnly000000000000000000000.
Local behaviour is identical to the hosted sandbox — same magic numbers, same statuses, same webhook format.
Testing webhooks locally
Your endpoint must be reachable from the orchestration service and publicly routable
— a webhook URL on localhost or a private address is refused, both when you save it and
at delivery time (endpoint requirements).
So a local receiver alone will not work. Use a tunnelling tool (cloudflared tunnel,
ngrok http, or similar) and register the public URL it gives you. There is deliberately
no development bypass for this: a flag that relaxed the rule locally would be one
misconfiguration away from relaxing it in production, and the rule exists because the
delivery worker runs inside a private network.
Verifying the signature does not need a tunnel — that logic is pure, so unit-test it against a payload and secret directly rather than against a live delivery.
Verify signatures in tests too — webhooks.md has copy-paste verification code in Node, PHP, and Python. Testing that a valid signature passes proves little; also assert that a tampered body and a wrong secret are rejected.
Sandbox limits
- Sandbox data is entirely separate from live: transactions, merchants, and keys do not carry over.
- Sandbox money is not real; balances and float are simulated.
- Rate limits are looser than live but not unlimited — batch load tests should be discussed with us first.