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 when Stripe carries the card, 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 PaymentRequiresAction(action: ActionKind.pushApproval, :final transaction):
final instruction = transaction.processingContext?.payerInstruction;
showDialog(context: context, builder: (_) => AwaitingApproval(instruction));
case PaymentRequiresAction():
// A redirect, a 3DS check or a provider page: the sheet already opened the browser.
break;
case PaymentProcessing():
// Accepted, not confirmed yet. Wait on watchStatus or on your webhook.
break;
case PaymentFailed(:final error):
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.message)),
);
}
},
// The payer closed the sheet without a result.
onCancel: () => context.go('/cart'),
);
}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 |
PaymentRequiresAction | Something is left for the payer to do, and action says what. pushApproval: approve on the handset, nothing to open. redirect, confirm, confirm3ds or clientSession: a page the sheet already opened in the browser |
PaymentProcessing | Accepted and not confirmed yet, for instance a provider checkout the provider has not settled. instruction may carry a text for the payer |
PaymentFailed | error carries code, declineCode and retryable |
Closing the sheet (the close button, a swipe down, the back button) calls onCancel, from version
1.5.0. When the payer had not submitted anything, the payment is also cancelled on Wajub, as the
hosted checkout does. When an attempt was under way it is left alone: a Mobile Money approval can
still land after the sheet is gone, so follow it with watchStatus() or your webhook.
The sheet speaks English and French. Pass locale: 'fr' to showWajubPaymentSheet to choose;
without it the sheet follows the payment's own language.
Mobile Money comes back as an action to approve
A Mobile Money payment almost never returns PaymentComplete from the sheet. The payer still
has to approve on their handset, so you get PaymentRequiresAction with
ActionKind.pushApproval, and the instruction to show them, dialling a USSD code for instance,
in transaction.processingContext?.payerInstruction. 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 to realtime updates when they are available and falls back to polling
every five seconds otherwise. From 1.5.0 the polling runs in both cases, next to the socket,
so a missed event costs at most one interval, and a result arrives when the status changes. 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(channelSlug:, paymentMethodId:, cardholderName:) | Processes a Stripe PaymentMethod you already created |
payCardWithStripeElements(channelSlug:, publishableKey:, cardholderName:) | Initialises Stripe and creates the method in one call, from the CardField on screen |
startClientSession(channelSlug:, email:, name:, returnUrl:, restart:) | Opens a provider's own checkout for a card. Returns a PaymentRequiresAction whose clientSession carries the page to open |
completeClientSession(id) | Verifies that checkout once the payer is back |
payCardHostedRedirect(channelSlug:, billing:) | Starts a card on a provider's own page, with the contact fields the channel requires |
payWallet(channelSlug:) | A one-tap wallet such as Djamo: returns the redirect that opens its app |
process(channel, data) | The raw escape hatch for any channel |
handleRedirectAction(result) | Opens a 3DS, bank or provider 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
}payCardWithStripeElements takes no card details: it reads them from a CardField of
flutter_stripe mounted on the screen, so render one before you call it.
That is the Stripe case. When the card channel's entry in getSdkConfig() has clientSession set,
or sdk equal to SdkFlavor.hostedRedirect, the provider collects the card on its own page and
your code only opens it.
final config = await session.getSdkConfig();
final slug = session.cardChannelSlug()!;
final channel = config.channels[slug];
if (channel?.clientSession == true) {
final started = await session.startClientSession(
channelSlug: slug,
email: '[email protected]',
);
// Opening returns at once: the payer is still on the provider's page.
final opened = await session.handleRedirectAction(started);
if (opened && started case PaymentRequiresAction(:final clientSession?)) {
await backInApp(); // the payer closed the browser, or your returnUrl brought them back
final result = await session.completeClientSession(clientSession.id);
// PaymentComplete, or PaymentProcessing until the provider confirms.
}
} else if (channel?.sdk == SdkFlavor.hostedRedirect) {
final result = await session.payCardHostedRedirect(
channelSlug: slug,
billing: {'email': '[email protected]'}, // every field in channel.requiredFields
);
await session.handleRedirectAction(result);
}
/// Resolves the next time the app comes back to the foreground after leaving it.
Future<void> backInApp() {
final done = Completer<void>();
var left = false;
late final AppLifecycleListener listener;
listener = AppLifecycleListener(onStateChange: (state) {
if (state != AppLifecycleState.resumed) {
left = true;
} else if (left && !done.isCompleted) {
listener.dispose();
done.complete();
}
});
return done.future;
}Opening the provider's page does not wait for the payer. Verify only once your app is back in the
foreground: called straight after opening, completeClientSession finds nothing paid yet and answers
PaymentProcessing. The package's own PaymentSheet waits the same way, on
didChangeAppLifecycleState. AppLifecycleListener needs Flutter 3.13 or later.
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, written as an https App Link (Android) or Universal Link (iOS): the API refuses any
other scheme, so a custom one such as myapp://paid answers 422.
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.