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

CaseWhat happened
PaymentCompleteSettled. transaction carries the reference
PaymentProcessingSent to the operator. instruction is the text to show the payer
PaymentRequiresAction3DS or a bank redirect. The sheet already opened the browser
PaymentFailederror 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

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

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(...)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.

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
}

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?