MoniWave

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.AppHost

The 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 acceptedsubmittedcompleted. 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:

MSISDNOutcome
237690000001succeeds immediately
237690000002succeeds after ~10 s (exercises your polling)
237690000003fails — customer declined
237690000004fails — insufficient funds
237690000005never completes, then expires (exercises your timeout path)
237690000006rejected 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

  1. Download the collection and one environment from the API reference (the Postman block, under the machine-readable spec).
  2. Import both into Postman, select the environment, and set apiKey to the sk_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 no collectionId makes 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