Quickstart
Goal: a successful sandbox payment in under 15 minutes, using only curl.
1. Get your sandbox key
Sandbox keys look like sk_test_…. In local development, start the platform and run DataSeed — it prints the seeded key:
cd modules/backend && dotnet run --project src/MoniWave.AppHostThe local seeded merchant is mer_local with key sk_test_LocalDevOnly000000000000000000000, and the API listens on http://localhost:6003.
For the hosted sandbox, sign up in the merchant dashboard: creating your workspace issues a sk_test_ key immediately and shows it once — copy it then, because it cannot be retrieved later. You can issue further keys, and revoke any of them, from the dashboard's API keys page.
2. Create a collection
A collection charges a customer's mobile wallet. Every mutating request needs an Idempotency-Key — a UUID you generate.
curl -X POST http://localhost:6003/v1/collections \
-H "Authorization: Bearer sk_test_LocalDevOnly000000000000000000000" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount": 5000,
"currency": "XAF",
"msisdn": "237690000001",
"description": "Order #1042",
"externalId": "order-1042"
}'{
"id": "col_019fff54dbf37614b3420ade5ab24ae1",
"status": "accepted",
"amount": 5000,
"currency": "XAF",
"msisdn": "237690000001",
"description": "Order #1042",
"externalId": "order-1042",
"failureReasonCode": null,
"createdAt": "2026-08-14T08:12:59.2432503+00:00"
}amount is an integer in minor units. XAF has no minor unit, so 5000 means 5 000 francs. Never send decimals.
237690000001 is a magic MSISDN that always succeeds immediately. In the sandbox, magic numbers let you force any outcome deterministically.
3. Poll for the result
The customer approves on their phone, so collections complete asynchronously. Poll the transaction:
curl http://localhost:6003/v1/collections/col_019fff54dbf37614b3420ade5ab24ae1 \
-H "Authorization: Bearer sk_test_LocalDevOnly000000000000000000000"The status walks accepted → submitted → completed. With 237690000001 this takes a second or two; with 237690000002 it stays pending-side for about 10 seconds first, which is a more realistic rehearsal of production timing.
{ "id": "col_019fff...", "status": "completed", "amount": 5000, "...": "..." }That is your first successful sandbox payment.
4. Receive the result instead of polling
Polling is authoritative, but you do not have to poll in a loop forever. Configure a webhook endpoint and MoniWave POSTs each terminal outcome to you, HMAC-signed:
{
"id": "0199ab…",
"type": "collection.completed",
"createdAt": "2026-08-14T08:13:04Z",
"data": { "id": "col_019fff…", "status": "completed", "amount": 5000, "currency": "XAF", "...": "..." }
}Always verify the signature — see webhooks.md for the header format and ready-to-use verification code. And treat webhooks as a convenience: every event is also obtainable by polling, so a missed webhook never means a lost transaction.
5. Try the other outcomes
Re-run step 2 with a different MSISDN to rehearse failure handling before you ever touch real money:
| MSISDN | Outcome |
|---|---|
237690000001 | succeeds immediately |
237690000002 | succeeds after ~10 s (exercises your polling) |
237690000003 | fails — customer declined |
237690000004 | fails — insufficient funds |
237690000005 | never completes, then expires (exercises your timeout path) |
237690000006 | rejected at submission — not a mobile-money subscriber |
Full table with reason codes: testing.md.
Or skip the curl: run it in Postman or Bruno
Everything above, as a collection you can import — generated from the same OpenAPI document that produced this reference, so the requests cannot drift from the routes.
Postman
- Download the collection and one environment from the API reference (the Postman block, under the machine-readable spec).
- Import both into Postman, select the environment, and set
apiKeyto thesk_test_key from step 1. It ships empty and marked secret — a key committed to a file is a leaked key.
Bruno
Open docs/api/bruno/ from the repository as a collection — Bruno collections are directories,
so there is nothing to import. Pick an environment (local, api-dev, api-sandbox) and set
apiKey.
Do not import the Postman file into Bruno. Bruno's importer translates the assertions but leaves Postman's variable calls behind, so a request passes its tests and then fails with
'pm' is not defined— and the request that then has nocollectionIdmakes the refund fail two steps later. The Bruno collection exists precisely so nobody has to debug that.
Either way, run the folders in order. Folder 1 creates a collection and stores its id; folder 2 opens a hosted checkout session and extracts its token from the returned URL; folder 3 refunds what folder 1 created. Nothing needs copying between requests.
Two details the collections handle for you, and which are worth knowing when you write your own
client: the Idempotency-Key is a freshly generated UUID on every send, because the API
rejects anything else with idempotency_key_invalid; and the three /v1/checkout/{token}
requests send no Authorization header at all, because the session token in the path is the
whole authorisation there. To rehearse the outcomes in the table above, change msisdn — a
collection variable in Postman, an environment variable in Bruno.
Next steps
- Idempotency — how to retry safely (important before production)
- Errors — the codes to branch on
- Payouts and Refunds
- Going live — swap the base URL and keys