Accepting mobile money in Flutter
A mobile app is the one place where the temptation to ship a secret key is strongest, and the payment is asynchronous, so the UI has to wait for something it cannot see.
Live in 14 markets: Benin, Cameroon, DR Congo, Gabon, Ivory Coast, Kenya, Mozambique, Republic of Congo, Rwanda, Senegal, Sierra Leone, Tanzania, Uganda and Zambia.
The Dart client is for talking to your own backend and for reading public data. Charges and payouts are initiated server-side, so this page shows both halves.
Install
Flutter is served by the Dart and Flutter client. There is no Flutter-specific package, and this page does not pretend otherwise: what is below is that client, used the way Flutter is written.
flutter pub add ultranerThe snippets below are checked in CI against the API's own schemas. This client is not yet driven end to end against the sandbox the way the Node one is.
The Flutter trap
Never put an API key in the app bundle. A compiled binary is readable, and a key in it is public. Charge from your own server and have the app poll or listen for the result.
Taking a payment
Two things this shows that are easy to get wrong anywhere. The amount is in whole units for most African currencies, so 5000 TZS is five thousand shillings and not fifty. And provideris the network's provider code, not its brand name: Mixx by Yas is still Tigo on the wire.
// YOUR SERVER initiates the charge. The app never holds the key.
// The app asks your backend, which calls Ultraner:
Future<String> startPayment(String phone, int amount) async {
final res = await http.post(
Uri.parse('https://your-api.example.com/pay'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'phone': phone, 'amount': amount}),
);
final body = jsonDecode(res.body);
// A reference, not a result. The payer is about to see a prompt.
return body['reference'] as String;
}The response means the request was accepted, not that money moved. A mobile-money charge sends a prompt to the payer's handset and they may take a minute, or never answer. Which is why the next section is not optional.
Receiving the result
The webhook is how you learn a payment succeeded. Verify the signature over the raw bytes before trusting anything in the payload, acknowledge quickly, and do the work afterwards: a slow handler gets retried, and retries mean you will see the same event twice.
// The app cannot receive webhooks, so it waits on your backend, which does.
// Poll your own status endpoint, backing off, and stop on a terminal state.
Future<String> awaitResult(String reference) async {
var delay = const Duration(seconds: 2);
for (var attempt = 0; attempt < 10; attempt++) {
await Future.delayed(delay);
final res = await http.get(
Uri.parse('https://your-api.example.com/payments/$reference'),
);
final status = jsonDecode(res.body)['status'] as String;
// Terminal states. Anything else means keep waiting.
if (status == 'success' || status == 'failed') return status;
delay *= 2;
}
return 'pending'; // tell the user honestly, do not claim failure
}Before you go live
- Test keys need test paths. A uk_test_ key is rejected on a live URL by design. The client handles this; hand-rolled requests must use the sandbox path.
- Paying out is a different rail. Settling your balance to your own bank works from anywhere in the world. Paying into someone else's wallet is live in Tanzania only. The difference.
- Payouts need two more headers. X-Signature-Key names the person authorising it, and Idempotency-Key stops a retry paying twice.
- Never send Authorization: Bearer. The API takes X-API-Key. Bearer is for dashboard sessions and will fail.
Live in 14 markets. Every snippet on this page is checked in CI against the schemas the API validates with, so a field name here cannot drift from the one the API expects.