Skip to content

Android (Kotlin)

Take Mobile Money, card and wallet payments in your Android app with a ready-made Compose payment sheet.

The Android SDK lets you take Mobile Money, card and wallet payments in your app, with a ready-made payment sheet built with Jetpack Compose.

com.wajub:wajub-mobile-compose

Stable · GA

Maven Central

Version
1.2.1
Runtime
Android 7.0 (API 24)+, JVM target 17
Frameworks
Jetpack Compose with Material 3

Covers

  • Payments
  • Mobile Money
  • Cards

Install

build.gradle.kts
dependencies {
    implementation("com.wajub:wajub-mobile-compose:1.3.0")
}
ArtifactWhat it is for
com.wajub:wajub-mobile-composeThe PaymentSheet composable. It brings wajub-mobile-core with it
com.wajub:wajub-mobile-coreThe same payment flow without any UI, for an app that draws its own screens, see Your own UI
RequirementValue
minSdk24, so Android 7.0
compileSdk35. The library sets no targetSdk: your app keeps its own
Java and Kotlin target17
ComposeMaterial 3 1.3 (BOM 2024.12.01) or 1.4 (BOM 2025.09.01)
Comes withstripe-android 21.2.0, OkHttp 4.12.0, Kotlin coroutines 1.9.0

There is nothing to add to your manifest or your R8 rules: the artifacts bring their own.

Quick start

Three steps, and only the second one runs in the app.

  1. Your server creates the payment with POST /payments and its secret key, and hands the app the authorization_token. The secret key never goes into the app.
  2. The app shows PaymentSheet with that token. onComplete receives the result once, when the sheet closes.
  3. Your server confirms the payment, with the payment.succeeded webhook or GET /payments/{id}, before it delivers. The result in the app is what the payer saw, not proof of payment.
The whole integration
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import com.wajub.mobile.checkout.CheckoutResult
import com.wajub.mobile.checkout.PresentOptions
import com.wajub.mobile.ui.PaymentSheet

@Composable
fun Checkout(authorizationToken: String) {
    var paying by rememberSaveable { mutableStateOf(true) }

    if (paying) {
        PaymentSheet(PresentOptions(sessionToken = authorizationToken)) { result ->
            paying = false
            when (result) {
                is CheckoutResult.Complete -> showReceipt(result.payment) // confirm on your server first
                is CheckoutResult.Processing -> showPending()              // money may still move
                is CheckoutResult.Failed -> showError(result.error.message)
                is CheckoutResult.Cancelled -> Unit                        // result.reason, result.redirectUrl
            }
        }
    }
}

CheckoutResult is a sealed class, so the when is exhaustive without an else. The sheet survives configuration changes such as a rotation or a change of language. Showing a second PaymentSheet while one is open resolves the second at once with Failed and the code sheet_already_presented, and leaves the first alone.

Options

PresentOptions is a data class in com.wajub.mobile.checkout. Only sessionToken must be set.

OptionType, defaultWhat it does
sessionTokenStringThe authorization_token from your server
localeString?, null"fr" or "en". Without it, the payment's language, else the device's
appearanceAppearance?, nullThe sheet's look, see Appearance
loaderLoaderStyle, SkeletonSkeleton, pulsing placeholder blocks, or Spinner
readyTimeoutLong milliseconds, 15_000A sheet still loading after it shows an error with Retry and reports ready_timeout to onLoadError. 0 disables it
returnUrlString?, nullAn https App Link back into your app, where the payer lands after a payment provider's page. Without it, the payer comes back to the app by hand
mobileMoneyMobileMoneyOptionsThe Mobile Money form, see Mobile Money
cancelOnDismissBoolean, trueClosing the sheet before any payment attempt cancels the payment. false keeps it open so you can show the sheet again, see Cancelling and closing
autoCloseSuccessLong milliseconds, 3_000The success screen closes itself after it. 0 keeps it until the payer taps Done
callbacksCheckoutCallbacksSee Callbacks and states

Callbacks and states

CheckoutCallbacks takes nine optional lambdas. They run on the main thread, and a callback that throws is logged without breaking the sheet.

Every callback
PresentOptions(
    sessionToken = token,
    callbacks = CheckoutCallbacks(
        onReady = { event -> },          // ReadyEvent: sessionId, environment, amount, currency, methods
        onStateChange = { event -> },    // StateChangeEvent: state, method
        onLoadError = { error -> },      // WajubError: the sheet cannot offer a payment
        onPaymentStarted = { event -> }, // PaymentStartedEvent: method, channel
        onRedirect = { event -> },       // RedirectEvent: url, right before Custom Tabs opens
        onSuccess = { payment -> },      // PaymentSuccess
        onError = { error -> },          // WajubError: every failure the payer sees
        onCancel = { event -> },         // CancelEvent: reason, redirectUrl
        onComplete = { result -> },      // the CheckoutResult PaymentSheet's onComplete receives
    ),
)
CallbackFires
onReadyOnce, when the first screen is drawn, also for a payment already paid or ended. environment is "sandbox" or "live", methods the payment methods shown
onStateChangeAfter onReady, then on every change of state. method is mobile_money, card, wallet, or unknown
onLoadErrorThe payment cannot be offered: session_not_found, session_terminal_expired, session_terminal_cancelled, session_terminal_failed, load_error, ready_timeout, no_payment_method
onPaymentStartedThe payment request is accepted. From then on, closing the sheet resolves Processing
onRedirectRight before a payment provider's page opens
onSuccessThe payment succeeded. method is unknown when it was paid before the sheet opened
onErrorEvery failure the payer sees, retryable ones included, field errors excluded
onCancelThe sheet closes as Cancelled
onCompleteOnce, as the sheet closes

StateChangeEvent.state is a CheckoutState. Its value is the state's name as a string, which matters for one of them: the constant is THREE_DS_REQUIRED and its value is 3DS_REQUIRED.

CheckoutStateScreen
INITIATED, COLLECTING_DETAILS, PROCESSINGMethod list, a method's form, the form with Pay busy
APPROVAL_REQUIRED, USSD_REQUIRED"Approve the payment on your phone", or a USSD code to dial
PAYER_OTP_REQUIRED"Generate your payment code": how to get the code the operator asks for, then a field for it
OTP_REQUIRED, PIN_REQUIRED, PHONE_REQUIRED, BIRTHDAY_REQUIRED, ADDRESS_REQUIRED, THREE_DS_REQUIRED, VERIFYINGA confirmation step, then its check
PENDING"Payment in progress", with Continue payment when a provider's page can be reopened
SUCCESS, FAILED, EXPIRED, CANCELLEDOutcomes, with Try again on a retryable failure

What the payer sees in each state is on Mobile SDKs.

Results

CheckoutResultWhenCarries
CompleteThe sheet closed after a success, also when the payment was already paid as it openedpayment: PaymentSuccess, transaction
ProcessingThe sheet closed while a payment was under way. Money may still move: confirm on your servertransaction, may be null
FailedThe sheet closed on a failure, an expired payment, a load error, or a payment cancelled elsewhere (payment_cancelled)error: WajubError, transaction
CancelledCancel payment, a close before any payment attempt, or your screen going away firstreason: CancelReason, redirectUrl

Every result also has status, its name as a string: complete, processing, failed, cancelled. CancelReason is PayerCancelled, Dismissed or HostGone, with its string in value.

PaymentSuccess carries id (Wajub's payment id, what GET /payments/{id} takes), reference (yours), amount (what the payer paid, fee included, in major units), currency, method, status (always success), timestamp (ISO 8601) and receiptUrl.

The sheet never shows a failure when the outcome is not known yet: it shows "Payment in progress" and keeps waiting, and closing it then resolves Processing. If your app's process dies, no result arrives: at the next launch, ask your server.

Cancelling and closing

The payer closes the sheet with its close button, by dragging it down, by pressing Back, or by tapping outside it.

When the payer closes itResultThe payment
While the payment loadsCancelled, DismissedUnchanged
Before any payment attempt, or back on the form after a refusal or Try againCancelled, DismissedCancelled, unless cancelOnDismiss = false
While a payment is under way: approval, USSD code, confirmation step, "Payment in progress"ProcessingKeeps running
On the failure screenFailed, with the error shown. onCancel does not fireCancelled when it still can be, unless cancelOnDismiss = false

Cancel payment, offered on the method list and on a form, cancels the payment for good and resolves Cancelled with PayerCancelled and the merchant's redirectUrl.

Your app removing the composable, or finishing its activity, is not the payer closing it: the sheet resolves Cancelled with HostGone before any payment attempt, Processing once one started, Complete after a success, and never cancels the payment. See Mobile SDKs.

Appearance

The appearance option sets the sheet's look. Its keys, presets and variables are on Appearance.

Appearance
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import com.wajub.mobile.checkout.Appearance
import com.wajub.mobile.checkout.AppearanceVariables
import com.wajub.mobile.ui.WajubFonts

// Once, before showing the sheet (in Application.onCreate, say): a font your app ships.
WajubFonts.register(
    "Poppins",
    FontFamily(Font(R.font.poppins_regular), Font(R.font.poppins_semibold, FontWeight.SemiBold)),
)

PresentOptions(
    sessionToken = token,
    appearance = Appearance(
        theme = "stripe",                 // a preset: stripe, night, flat, none, or a scheme: light, dark, auto
        colorScheme = "auto",             // follows the phone
        variables = AppearanceVariables(
            colorPrimary = "#0F766E",
            borderRadius = "12px",
            fontFamily = "Poppins",          // the name registered above
        ),
        buttonTextColor = "#FFFFFF",      // top-level keys work too
        inherit = true,                   // your MaterialTheme fills what you leave unset
    ),
)
  • Every value is a String. Colours are #rgb, #rrggbb, #rrggbbaa, rgb() or rgba(); lengths "8px", "8" or "0.5rem". A value that does not parse is ignored.
  • inherit = true takes what you leave unset from your app's MaterialTheme.
  • A fontFamily reaches the sheet only once your app registers it with WajubFonts.register(name, family); unregister(name) takes it back.
  • The merchant's logo, business name and colours from the Dashboard fill what you leave unset, and a white-label merchant hides "Secured by Wajub".
  • disableAnimations = true turns the sheet's animations off.

Mobile Money

The payer picks the country and the operator, and types the number with the country's dial code. After Pay, the sheet shows "Approve the payment on your phone" with the operator's instruction until the payment settles.

MobileMoneyOptionsDefaultWhat it does
countrynullThe country to start on, ISO-2
countryLockedfalseHides the country selector when country is set
operatorModeOperatorMode.ChooseChoose lists the operators. Detect shows the operator detected from the number in the field, and the list only when the number does not tell
Start on Côte d'Ivoire
PresentOptions(
    sessionToken = token,
    mobileMoney = MobileMoneyOptions(country = "CI", countryLocked = true, operatorMode = OperatorMode.Detect),
)

For your own form, MobileMoney checks, formats and reads a number: isValid, formatAsYouType, detectOperator, and data() for the countries and their dial codes.

Cards

Cards need no setup in your app. Depending on the payment, the payer enters the card in the sheet or on a secure page of the payment provider, opened in Custom Tabs; Continue payment reopens that page if the payer comes back before paying. In sandbox, the sheet shows a test card form.

Confirmation steps

Some payments ask the payer for one more thing after Pay. Each step has its own screen and shows the attempts left after a wrong answer.

StepState
A code sent by SMS or emailOTP_REQUIRED
The card PINPIN_REQUIRED
A phone numberPHONE_REQUIRED
A date of birthBIRTHDAY_REQUIRED
A billing addressADDRESS_REQUIRED
In sandbox, the 3-D Secure code sent by emailTHREE_DS_REQUIRED

A wrong answer with attempts left stays on the step. Too many, or an expired step, ends the attempt and the payer can start again. See Mobile SDKs.

Errors

WajubError extends Exception. The sheet hands it to onLoadError, onError and CheckoutResult.Failed.

PropertyWhat it is for
typeWajubErrorType: ApiError, AuthenticationError, InvalidRequestError, PaymentError, RateLimitError
codeWhat went wrong, insufficient_funds for instance
messageFor the payer, in the sheet's language
declineCodeOn a decline, the reason given for it
retryableWhether Try again can work
paramThe field at fault
correlationIdThe request id to quote to Wajub support
retryAfterSecondsThe wait on rate_limited
blockReferenceOn shield_blocked, the reference to quote to support

Codes you will meet: insufficient_funds, payment_declined, payment_attempt_ended (refused, nothing was charged), payment_failed, payment_cancelled, session_expired, shield_blocked, merchant_restricted, rate_limited, session_terminal_expired, unsupported_action, redirect_unavailable, sheet_already_presented. ErrorCatalog.message(code, WajubLanguage.Fr, null) gives a code's message in English or French. Every code is on Mobile SDKs.

Your own UI

CheckoutController, in wajub-mobile-core, is the whole payment sheet without its UI: it runs the payment, fires the same callbacks and ends with the same CheckoutResult, while you draw the screens.

Your screens, the SDK's payment flow
import com.wajub.mobile.WajubMobile
import com.wajub.mobile.checkout.CheckoutController
import com.wajub.mobile.checkout.PresentOptions
import com.wajub.mobile.checkout.SheetScreen
import com.wajub.mobile.ui.HostedRedirectLauncher

// In your ViewModel. token comes from your server, appContext is your Application.
val controller = CheckoutController(
    WajubMobile.createSession(token),
    PresentOptions(sessionToken = token),
    viewModelScope,
).apply {
    urlOpener = { url -> HostedRedirectLauncher.open(appContext, url) } // opens Custom Tabs
    onFinished = { result -> /* hide your screens, then handle the result */ }
    start()
}

// In your composable:
val screen by controller.screen.collectAsState()
when (val s = screen) {
    SheetScreen.Loading -> MyLoader()
    is SheetScreen.Methods -> MyMethodList(s.methods, onPick = controller::selectMethod)
    is SheetScreen.Form -> MyForm(s, onPhone = controller::setPhone, onPay = controller::payMobileMoney)
    is SheetScreen.Success -> MyReceipt(s.payment)
    else -> MyStatus(s) // approval, steps, "Payment in progress", failures…
}
  • Forward the payer's actions: selectMethod, setCountry, setPhone, pickOperator, payMobileMoney, payWallet, setCardField, payCard, payStripe, setStepField, submitStep, resendStep, continuePayment, tryAgain, cancelPayment, close and dismiss.
  • Call onReturn() when your app is resumed, and hostGone() when your screen goes away for good. Every call belongs on the main thread, where the callbacks run.
  • When a form's card?.flavor is a CardFlavor.Stripe, show Stripe's CardInputWidget and pay with controller.payStripe { StripeTokenizer.createPaymentMethod(context, stripe.publishableKey, card) }, where stripe is that CardFlavor.Stripe and card is the widget's paymentMethodCard. Add implementation("com.stripe:stripe-android:21.2.0") to your own build file: the Compose module does not expose Stripe to your code.

WajubSession, the lower-level client of one payment, is documented in the package's README.

Privacy

To make the payment, the SDK sends Wajub what the payer types. For fraud prevention, it also sends basic device information and a random install identifier, which is not the advertising identifier. What to declare in Google Play's data safety form, category by category, is on Mobile SDKs.

Testing

Create the payment with a sandbox key, and the whole flow runs in sandbox, sheet included, which shows a "TEST MODE" badge. There is nothing to switch in the app. The test numbers and cards are on Test scenarios.

wajub-mobile-core has no Android dependency, so it runs in a plain JVM unit test, without an emulator.

What did you think of this content?