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 · GAMaven 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
dependencies {
implementation("com.wajub:wajub-mobile-compose:1.3.0")
}| Artifact | What it is for |
|---|---|
com.wajub:wajub-mobile-compose | The PaymentSheet composable. It brings wajub-mobile-core with it |
com.wajub:wajub-mobile-core | The same payment flow without any UI, for an app that draws its own screens, see Your own UI |
| Requirement | Value |
|---|---|
minSdk | 24, so Android 7.0 |
compileSdk | 35. The library sets no targetSdk: your app keeps its own |
| Java and Kotlin target | 17 |
| Compose | Material 3 1.3 (BOM 2024.12.01) or 1.4 (BOM 2025.09.01) |
| Comes with | stripe-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.
- Your server creates the payment with
POST /paymentsand its secret key, and hands the app theauthorization_token. The secret key never goes into the app. - The app shows
PaymentSheetwith that token.onCompletereceives the result once, when the sheet closes. - Your server confirms the payment, with the
payment.succeededwebhook orGET /payments/{id}, before it delivers. The result in the app is what the payer saw, not proof of payment.
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.
| Option | Type, default | What it does |
|---|---|---|
sessionToken | String | The authorization_token from your server |
locale | String?, null | "fr" or "en". Without it, the payment's language, else the device's |
appearance | Appearance?, null | The sheet's look, see Appearance |
loader | LoaderStyle, Skeleton | Skeleton, pulsing placeholder blocks, or Spinner |
readyTimeout | Long milliseconds, 15_000 | A sheet still loading after it shows an error with Retry and reports ready_timeout to onLoadError. 0 disables it |
returnUrl | String?, null | An 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 |
mobileMoney | MobileMoneyOptions | The Mobile Money form, see Mobile Money |
cancelOnDismiss | Boolean, true | Closing the sheet before any payment attempt cancels the payment. false keeps it open so you can show the sheet again, see Cancelling and closing |
autoCloseSuccess | Long milliseconds, 3_000 | The success screen closes itself after it. 0 keeps it until the payer taps Done |
callbacks | CheckoutCallbacks | See 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.
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
),
)| Callback | Fires |
|---|---|
onReady | Once, when the first screen is drawn, also for a payment already paid or ended. environment is "sandbox" or "live", methods the payment methods shown |
onStateChange | After onReady, then on every change of state. method is mobile_money, card, wallet, or unknown |
onLoadError | The payment cannot be offered: session_not_found, session_terminal_expired, session_terminal_cancelled, session_terminal_failed, load_error, ready_timeout, no_payment_method |
onPaymentStarted | The payment request is accepted. From then on, closing the sheet resolves Processing |
onRedirect | Right before a payment provider's page opens |
onSuccess | The payment succeeded. method is unknown when it was paid before the sheet opened |
onError | Every failure the payer sees, retryable ones included, field errors excluded |
onCancel | The sheet closes as Cancelled |
onComplete | Once, 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.
CheckoutState | Screen |
|---|---|
INITIATED, COLLECTING_DETAILS, PROCESSING | Method 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, VERIFYING | A confirmation step, then its check |
PENDING | "Payment in progress", with Continue payment when a provider's page can be reopened |
SUCCESS, FAILED, EXPIRED, CANCELLED | Outcomes, with Try again on a retryable failure |
What the payer sees in each state is on Mobile SDKs.
Results
CheckoutResult | When | Carries |
|---|---|---|
Complete | The sheet closed after a success, also when the payment was already paid as it opened | payment: PaymentSuccess, transaction |
Processing | The sheet closed while a payment was under way. Money may still move: confirm on your server | transaction, may be null |
Failed | The sheet closed on a failure, an expired payment, a load error, or a payment cancelled elsewhere (payment_cancelled) | error: WajubError, transaction |
Cancelled | Cancel payment, a close before any payment attempt, or your screen going away first | reason: 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 it | Result | The payment |
|---|---|---|
| While the payment loads | Cancelled, Dismissed | Unchanged |
| Before any payment attempt, or back on the form after a refusal or Try again | Cancelled, Dismissed | Cancelled, unless cancelOnDismiss = false |
| While a payment is under way: approval, USSD code, confirmation step, "Payment in progress" | Processing | Keeps running |
| On the failure screen | Failed, with the error shown. onCancel does not fire | Cancelled 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.
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()orrgba(); lengths"8px","8"or"0.5rem". A value that does not parse is ignored. inherit = truetakes what you leave unset from your app'sMaterialTheme.- A
fontFamilyreaches the sheet only once your app registers it withWajubFonts.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 = trueturns 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.
MobileMoneyOptions | Default | What it does |
|---|---|---|
country | null | The country to start on, ISO-2 |
countryLocked | false | Hides the country selector when country is set |
operatorMode | OperatorMode.Choose | Choose lists the operators. Detect shows the operator detected from the number in the field, and the list only when the number does not tell |
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.
| Step | State |
|---|---|
| A code sent by SMS or email | OTP_REQUIRED |
| The card PIN | PIN_REQUIRED |
| A phone number | PHONE_REQUIRED |
| A date of birth | BIRTHDAY_REQUIRED |
| A billing address | ADDRESS_REQUIRED |
| In sandbox, the 3-D Secure code sent by email | THREE_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.
| Property | What it is for |
|---|---|
type | WajubErrorType: ApiError, AuthenticationError, InvalidRequestError, PaymentError, RateLimitError |
code | What went wrong, insufficient_funds for instance |
message | For the payer, in the sheet's language |
declineCode | On a decline, the reason given for it |
retryable | Whether Try again can work |
param | The field at fault |
correlationId | The request id to quote to Wajub support |
retryAfterSeconds | The wait on rate_limited |
blockReference | On 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.
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,closeanddismiss. - Call
onReturn()when your app is resumed, andhostGone()when your screen goes away for good. Every call belongs on the main thread, where the callbacks run. - When a form's
card?.flavoris aCardFlavor.Stripe, show Stripe'sCardInputWidgetand pay withcontroller.payStripe { StripeTokenizer.createPaymentMethod(context, stripe.publishableKey, card) }, wherestripeis thatCardFlavor.Stripeandcardis the widget'spaymentMethodCard. Addimplementation("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.
Related pages