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 when Stripe carries the card, and a 3DS
challenge or a provider's own page opens in Custom Tabs rather than a WebView.
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
Two artifacts, and you usually want both
dependencies {
implementation("com.wajub:wajub-mobile-compose:1.2.0")
}That single line is enough for the sheet, 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.
Stripe is the exception. The Compose module uses stripe-android internally and does not expose
it to your code, so a card screen of your own, built on Stripe's CardInputWidget, needs
implementation("com.stripe:stripe-android:21.2.0") in your own build file.
| 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.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import com.wajub.mobile.WajubMobile
import com.wajub.mobile.model.ActionKind
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.RequiresAction -> when (result.action) {
ActionKind.PushApproval ->
showInstruction(result.transaction.processingContext?.payerInstruction)
else -> Unit // a page Custom Tabs already opened
}
is PaymentResult.Processing -> Unit // accepted, not confirmed yet
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.
onDismiss is the payer closing the sheet. From 1.2.1, when they 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.
| Branch | What happened |
|---|---|
Complete | Settled. transaction carries the reference |
RequiresAction | Something 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 already opened in Custom Tabs |
Processing | Accepted and not confirmed yet, for instance a provider checkout the provider has not settled. instruction may carry a text for the payer |
Failed | error carries code, declineCode and retryable |
Mobile Money comes back as an action to approve
A Mobile Money payment almost never returns Complete from the sheet. The payer still has to
approve on their handset, so you get RequiresAction with ActionKind.PushApproval, and the
instruction to show them, dialling a USSD code for instance, in
transaction.processingContext?.payerInstruction. 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.
import androidx.lifecycle.viewModelScope
import com.wajub.mobile.WajubMobile
import com.wajub.mobile.model.MobileMoneyInput
import kotlinx.coroutines.launch
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(channelSlug, paymentMethodId, cardholderName) | Processes a Stripe PaymentMethod you already created |
startClientSession(channel, email, name, returnUrl, restart) | Opens a provider's own checkout for a card. Returns a PaymentResult.RequiresAction whose clientSession carries the page to open |
completeClientSession(clientSessionId) | 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 |
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 to realtime updates when they are available and falls back to polling
every five seconds otherwise. From 1.2.1 the polling runs in both cases, next to the
socket, so a missed event costs at most one interval. 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)
}That is the Stripe case, and it is the one that needs stripe-android in your own build file. When
the card channel's entry in getSdkConfig() has clientSession set, or sdk equal to
hosted_redirect, the provider collects the card on its own page and your code only opens it.
import com.wajub.mobile.ui.HostedRedirectLauncher
import com.wajub.mobile.ui.PaymentActionHandler
// In your ViewModel:
var pendingClientSessionId: String? = null
viewModelScope.launch {
session.loadSession()
val slug = session.cardChannelSlug() ?: return@launch
val channel = session.getSdkConfig().channels[slug]
if (channel?.clientSession == true) {
val started = session.startClientSession(channel = slug, email = "[email protected]")
val clientSession = (started as? PaymentResult.RequiresAction)?.clientSession
?: return@launch
// Opening returns at once: the payer is still on the provider's page.
pendingClientSessionId = clientSession.id
HostedRedirectLauncher.open(context, clientSession.hostedUrl!!)
} else if (channel?.sdk == "hosted_redirect") {
val result = session.payCardHostedRedirect(
channelSlug = slug,
billing = mapOf("email" to "[email protected]"), // every field in requiredFields
)
PaymentActionHandler.handle(context, result)
}
}
// In the Activity: the payer comes back when the Custom Tab closes, or through your returnUrl.
override fun onResume() {
super.onResume()
val id = viewModel.pendingClientSessionId ?: return
viewModel.pendingClientSessionId = null
lifecycleScope.launch {
val result = session.completeClientSession(id)
// Complete, or Processing until the provider confirms.
}
}Opening a Custom Tab does not wait for the payer. Verify only once your screen is resumed: called
straight after opening, completeClientSession finds nothing paid yet and answers Processing.
The package's own PaymentSheet does the same, on Lifecycle.Event.ON_RESUME after the screen was
left.
PaymentActionHandler opens Custom Tabs, not a WebView
It routes Redirect, Confirm, Confirm3ds and ClientSession 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, written as an https App Link: the API
refuses any other scheme, so a custom one such as myapp://paid answers 422.
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.