Android (Kotlin)
Two artifacts, a Compose payment sheet, and the coroutine API underneath.
The Android SDK collects payments inside your native application. Mobile Money fields are Material
3 composables, card fields come from stripe-android, and a 3DS challenge opens in Custom Tabs
rather than a WebView.
com.wajub:wajub-mobile-compose
Stable · GAMaven Central
- Version
- 1.1.0
- Runtime
- Android 7.0 (API 24)+, JVM target 17
- Frameworks
- Jetpack Compose with Material 3
Covers
- Payments
- Mobile Money
- Cards
Two artifacts, and you usually want both
dependencies {
implementation("com.wajub:wajub-mobile-compose:1.1.0")
}That single line is enough, because the Compose module declares the core as an api dependency
and pulls it in. Take the core alone only when you are building the interface yourself.
| Artifact | What it is | What it holds |
|---|---|---|
com.wajub:wajub-mobile-core | A plain Kotlin/JVM jar | WajubMobile, WajubSession, the models, the realtime client |
com.wajub:wajub-mobile-compose | An Android AAR | The PaymentSheet composable, Stripe, Custom Tabs |
The core has no Android in it
wajub-mobile-core is published as kotlin("jvm"), not as an Android library. It has no
manifest, no resources and no Context. That is why it is usable from a plain JVM test, and why
the sheet, the browser launcher and the Stripe tokenizer all live in the Compose artifact.
| Requirement | Value |
|---|---|
minSdk | 24, so Android 7.0 |
compileSdk | 35 |
| Java and Kotlin target | 17 |
| Compose | BOM 2024.12.01, Material 3 |
| Also pulled in | stripe-android 21.2.0, androidx.browser 1.8.0, OkHttp, Moshi, Pusher |
The group is com.wajub
co.wajub resolves to nothing. Both artifacts sit under com.wajub, the same group as the
server side wajub-java.
Get a token from your server
Nothing in these artifacts talks to /payments, and they hold no secret key. Your backend creates
the payment and returns the authorization_token.
Show the sheet
PaymentSheet is a composable, not a class with a present method.
import androidx.compose.runtime.*
import com.wajub.mobile.WajubMobile
import com.wajub.mobile.model.PaymentResult
import com.wajub.mobile.ui.PaymentSheet
@Composable
fun Checkout(token: String, onPaid: (String) -> Unit) {
var showSheet by remember { mutableStateOf(false) }
val session = remember(token) { WajubMobile.createSession(token) }
Button(onClick = { showSheet = true }) {
Text("Pay")
}
if (showSheet) {
PaymentSheet(
wajubSession = session,
onDismiss = { showSheet = false },
onResult = { result ->
showSheet = false
when (result) {
is PaymentResult.Complete -> onPaid(result.transaction.reference)
is PaymentResult.Processing -> showInstruction(result.instruction)
is PaymentResult.RequiresAction -> Unit // Custom Tabs already opened
is PaymentResult.Failed -> showError(result.error.message)
}
},
)
}
}PaymentResult is a sealed class, so a when over it is exhaustive without an else and the
compiler flags a missing branch.
| Branch | What happened |
|---|---|
Complete | Settled. transaction carries the reference |
Processing | Sent to the operator. instruction is the text to show the payer |
RequiresAction | 3DS or a bank redirect, already opened in Custom Tabs |
Failed | error carries code, declineCode and retryable |
Processing is the normal Mobile Money outcome
A Mobile Money payment almost never returns Complete from the sheet. The payer still has to
approve on their handset, so you get Processing with an instruction such as dialling a USSD
code. Show that text, then collect watchStatus() or wait on your webhook.
Your own screens instead of the sheet
WajubSession is the whole API underneath. Every network method is a suspend fun, so calls sit
in a coroutine and cancel with it.
val session = WajubMobile.createSession(token)
viewModelScope.launch {
val data = session.loadSession()
// data.transaction.amount, data.transaction.currency, data.branding, data.locale
val operators = data.channels.filter {
it.type.lowercase() in setOf("mobile_money", "mobile")
}
val result = 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(slug, paymentMethodId, name) | Processes a Stripe PaymentMethod you already created |
process(channel, data) | The raw escape hatch for any channel |
watchStatus(intervalMs) | A Flow<PaymentResult>, realtime when available |
watchStatusPolling(intervalMs) | A Flow<PaymentResult>, always polled |
cancel() | Abandons the session, returns the redirect URL |
cardChannelSlug() | The card channel of the loaded session, if there is one |
WajubSession has an internal constructor
WajubSession(token) does not compile from your code. WajubMobile.createSession(token) is the
only way in, which also means the class cannot be subclassed for a test. Stub at the ViewModel
boundary instead.
Follow the outcome
viewModelScope.launch {
session.watchStatus().collect { result ->
when (result) {
is PaymentResult.Complete -> _state.value = OrderState.Paid
is PaymentResult.Failed -> _state.value = OrderState.Failed
else -> Unit
}
}
}watchStatus subscribes over Pusher when the session carries realtime settings and falls back to
polling every five seconds otherwise. watchStatusPolling forces the polled path, which is what
you want on a device behind a firewall that blocks WebSockets.
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. createPaymentMethod calls ensureInitialized itself, so
there is no separate setup step.
import com.wajub.mobile.ui.PaymentActionHandler
import com.wajub.mobile.ui.StripeTokenizer
viewModelScope.launch {
session.loadSession()
val config = session.getSdkConfig()
val slug = session.cardChannelSlug() ?: return@launch
val publishableKey = config.channels[slug]?.publishableKey ?: return@launch
// card comes straight from Stripe's widget, never from your own state:
// cardInputWidget.paymentMethodCreateParams?.card
val paymentMethodId = StripeTokenizer.createPaymentMethod(
context = context,
publishableKey = publishableKey,
card = card,
cardholderName = "Amina Diallo",
)
val result = session.payCard(slug, paymentMethodId, cardholderName = "Amina Diallo")
PaymentActionHandler.handle(context, result)
}PaymentActionHandler opens Custom Tabs, not a WebView
It routes Redirect, Confirm and Confirm3ds to androidx.browser Custom Tabs, and ignores
PushApproval, which needs no browser. A 3DS challenge inside a WebView is refused by a growing
number of banks, which is why this path is not configurable. Make sure the callback on
POST /payments points at a deep link your app handles.
Errors
WajubError extends Exception, so it is thrown from the suspend functions and it is also the
payload inside PaymentResult.Failed.
try {
when (val result = session.payMobileMoney(input)) {
is PaymentResult.Failed -> showRetry(result.error.message, result.error.retryable)
else -> Unit
}
} catch (error: WajubError) {
if (error.type == WajubErrorType.RateLimitError) {
showRetryIn(error.retryAfterSeconds ?: 60)
return
}
if (error.retryable) {
showRetryButton(error.message)
return
}
showFatal(error.message, error.correlationId)
}| Property | 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 |
httpStatus | The status that produced it, when there was one |
httpStatus exists here and on no other mobile SDK, which makes an Android crash report slightly
easier to triage.
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.
Because the core is a plain JVM jar it runs in a unit test with no emulator, which is the right place to exercise mapping and error handling.