Skip to content

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 · GA

Maven 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

build.gradle.kts
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.

ArtifactWhat it isWhat it holds
com.wajub:wajub-mobile-coreA plain Kotlin/JVM jarWajubMobile, WajubSession, the models, the realtime client
com.wajub:wajub-mobile-composeAn Android AARThe PaymentSheet composable, Stripe, Custom Tabs
RequirementValue
minSdk24, so Android 7.0
compileSdk35
Java and Kotlin target17
ComposeBOM 2024.12.01, Material 3
Also pulled instripe-android 21.2.0, androidx.browser 1.8.0, OkHttp, Moshi, Pusher

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.

The whole integration
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.

BranchWhat happened
CompleteSettled. transaction carries the reference
ProcessingSent to the operator. instruction is the text to show the payer
RequiresAction3DS or a bank redirect, already opened in Custom Tabs
Failederror 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.

Read the session, then pay
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",
        ),
    )
}
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(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

Follow the outcome

watchStatus is a Flow
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.

Stripe, driven by the session
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)
}

Errors

WajubError extends Exception, so it is thrown from the suspend functions and it is also the payload inside PaymentResult.Failed.

Two places the same type appears
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)
}
PropertyWhat 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
httpStatusThe 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.

What did you think of this content?