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

Two artifacts, and you usually want both

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

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.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.

BranchWhat happened
CompleteSettled. transaction carries the reference
RequiresActionSomething 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
ProcessingAccepted and not confirmed yet, for instance a provider checkout the provider has not settled. instruction may carry a text for the payer
Failederror 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.

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

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 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.

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)
}

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.

A card the provider collects itself
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.

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?