Flutter
The wajub_mobile package, a native payment sheet, and no WebView.
wajub_mobile collects payments inside your Flutter application. Mobile Money fields are native
Flutter widgets, card fields come from flutter_stripe, and the payer never sees a WebView.
wajub_mobile
Stable · GApub.dev
- Version
- 1.1.1
- Runtime
- Dart 3.5+, Flutter with Material
- Frameworks
- Android and iOS
Covers
- Payments
- Mobile Money
- Cards
Install
flutter pub add wajub_mobileIt brings flutter_stripe, pusher_channels_flutter, url_launcher, http and meta with it.
The Stripe dependency is not optional, so its platform requirements become yours.
| Platform | What flutter_stripe 11.4 requires |
|---|---|
| Android | minSdkVersion 21, Kotlin 1.8 or later, Android Gradle Plugin 8 or later |
| Android | MainActivity extending FlutterFragmentActivity, on a Theme.AppCompat descendant |
| iOS | Deployment target 13.0 or later |
Android needs FlutterFragmentActivity
Stripe's card fields present as a fragment. Leave MainActivity extending FlutterActivity and
the card tab crashes the moment it opens, while the Mobile Money tab keeps working, which makes
it look like a card specific bug rather than a configuration one.
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity()Get a token from your server
Nothing in this package talks to /payments, and it holds no secret key. Your backend creates the
payment and returns the authorization_token.
final response = await http.post(
Uri.parse('https://api.yourshop.com/checkout'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'cart_id': cart.id}),
);
final token = jsonDecode(response.body)['authorization_token'] as String;Show the sheet
Two lines, and the sheet handles loading the session, listing the operators, collecting the phone number or the card, and processing the payment.
import 'package:wajub_mobile/wajub_mobile.dart';
Future<void> pay(BuildContext context, String token) async {
final session = Wajub.createSession(token);
await showWajubPaymentSheet(
context: context,
session: session,
onResult: (result) {
switch (result) {
case PaymentComplete(:final transaction):
context.go('/orders/${transaction.reference}');
case PaymentProcessing(:final instruction):
showDialog(context: context, builder: (_) => AwaitingApproval(instruction));
case PaymentRequiresAction():
// The sheet already opened the browser for you.
break;
case PaymentFailed(:final error):
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.message)),
);
}
},
);
}PaymentResult is a sealed class, so the switch above is exhaustive and the compiler tells you
when a case is missing. That is the reason to switch on it rather than read a status string.
| Case | What happened |
|---|---|
PaymentComplete | Settled. transaction carries the reference |
PaymentProcessing | Sent to the operator. instruction is the text to show the payer |
PaymentRequiresAction | 3DS or a bank redirect. The sheet already opened the browser |
PaymentFailed | error carries code, declineCode and retryable |
Processing is the normal Mobile Money outcome
A Mobile Money payment almost never returns PaymentComplete from the sheet. The payer still
has to approve on their handset, so you get PaymentProcessing with an instruction such as
dialling a USSD code. Show that text, then wait on watchStatus or on your webhook.
Follow the outcome
final subscription = session.watchStatus().listen((result) {
if (result is PaymentComplete) {
setState(() => _state = OrderState.paid);
}
if (result is PaymentFailed) {
setState(() => _state = OrderState.failed);
}
});
@override
void dispose() {
subscription.cancel();
super.dispose();
}watchStatus subscribes over Pusher when the session carries realtime settings and falls back to
polling every five seconds otherwise. You do not choose, and you must cancel the subscription in
dispose.
Your own screens instead of the sheet
WajubSession is the whole API underneath the sheet. Reach for it when the payment has to sit
inside a design you already have.
final session = Wajub.createSession(token);
final data = await session.loadSession();
// data.transaction.amount, data.transaction.currency, data.branding, data.locale
final operators = data.channels
.where((c) => const {'mobile_money', 'mobile'}.contains(c.type.toLowerCase()))
.toList();
final result = await session.payMobileMoney(
MobileMoneyInput(
channelSlug: operators.first.slug,
phone: '+237670000000',
country: 'CM',
),
);| Method | What it does |
|---|---|
loadSession({forceRefresh}) | Amount, currency, channels, branding, locale. Cached after the first call |
getSdkConfig() | Per channel provider settings, including the Stripe publishable key |
payMobileMoney(input) | Sends the push to the operator |
payCard(...) | Processes a Stripe PaymentMethod you already created |
payCardWithStripeElements(...) | Initialises Stripe and creates the method in one call |
process(channel, data) | The raw escape hatch for any channel |
handleRedirectAction(result) | Opens a 3DS or bank URL in the system browser |
watchStatus({interval}) | A stream of results, realtime or polled |
cancel() | Abandons the session, returns the redirect URL |
cardChannelSlug() | The card channel of the loaded session, if there is one |
Cards without the sheet
The publishable key comes from the session, not from your code, because it differs between sandbox and live and between merchants.
final session = Wajub.createSession(token);
await session.loadSession();
final config = await session.getSdkConfig();
final slug = session.cardChannelSlug();
final publishableKey = config.channels[slug]?.publishableKey;
if (slug != null && publishableKey != null) {
final result = await session.payCardWithStripeElements(
channelSlug: slug,
publishableKey: publishableKey,
cardholderName: 'Amina Diallo',
);
await session.handleRedirectAction(result); // opens 3DS if needed
}handleRedirectAction opens the system browser, not a WebView
It uses url_launcher, so the payer leaves your app for Chrome or Safari and comes back through
your callback URL. That is deliberate: a 3DS challenge inside a WebView is refused by a growing
number of banks. Make sure your callback on POST /payments points at a deep link your app
handles.
Errors
try {
await session.payMobileMoney(input);
} on WajubError catch (error) {
if (error.type == WajubErrorType.rateLimitError) {
showRetryIn(error.retryAfterSeconds ?? 60);
return;
}
if (error.retryable) {
showRetryButton(error.message);
return;
}
showFatal(error.message, error.correlationId);
}| Field | What it is for |
|---|---|
type | One of apiError, authenticationError, invalidRequestError, paymentError, rateLimitError |
code | The machine readable reason |
declineCode | Present on a 402, why the provider refused |
retryable | Whether offering another attempt makes sense |
param | The field at fault on a validation error |
correlationId | Quote this to support |
retryAfterSeconds | The wait on a rate limit |
Two names to know about
Wajub.initialize(publicKey:) stores a pk. key for Stripe helpers. It is optional and the sheet
does not need it, since the publishable key comes from getSdkConfig().
WajubMobile is a deprecated alias
Older examples call WajubMobile.createSession(...). WajubMobile is now a typedef for
Wajub and still compiles, but new code should use Wajub.
Testing
The API origin is a constant, so there is no base URL to redirect. Point your backend at a sandbox key instead and the token it mints puts the whole flow in sandbox, sheet included. Numbers that produce each outcome are on Test scenarios.