Skip to content

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 · GA

pub.dev

Version
1.1.1
Runtime
Dart 3.5+, Flutter with Material
Frameworks
Android and iOS

Covers

  • Payments
  • Mobile Money
  • Cards

Install

Add the dependency
flutter pub add wajub_mobile

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

PlatformWhat flutter_stripe 11.4 requires
AndroidminSdkVersion 21, Kotlin 1.8 or later, Android Gradle Plugin 8 or later
AndroidMainActivity extending FlutterFragmentActivity, on a Theme.AppCompat descendant
iOSDeployment target 13.0 or later
android/app/src/main/kotlin/.../MainActivity.kt
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.

Your own endpoint, your own client
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.

The whole integration
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.

CaseWhat happened
PaymentCompleteSettled. transaction carries the reference
PaymentRequiresActionSomething 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
PaymentProcessingAccepted and not confirmed yet, for instance a provider checkout the provider has not settled. instruction may carry a text for the payer
PaymentFailederror 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

A live status while the payer approves
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.

Read the session, then pay
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',
  ),
);
MethodWhat 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.

Stripe, driven by the session
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.

A card the provider collects itself
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.

Errors

Every failure is a WajubError
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);
}
FieldWhat it is for
typeOne of apiError, authenticationError, invalidRequestError, paymentError, rateLimitError
codeThe machine readable reason
declineCodePresent on a 402, why the provider refused
retryableWhether offering another attempt makes sense
paramThe field at fault on a validation error
correlationIdQuote this to support
retryAfterSecondsThe 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.

What did you think of this content?