Aller au contenu

Android (Kotlin)

Deux artefacts, une interface de paiement Compose qui mène tout le paiement, et le client en dessous.

Le SDK Android encaisse les paiements dans votre application native. L'interface de paiement est une feuille dessinée avec des composants Material 3 stables, qui mène tout le paiement : le Mobile Money avec l'approbation de l'opérateur, les cartes, les portefeuilles en un geste, les étapes de confirmation qu'un prestataire demande, et le résultat. Le widget de carte de Stripe s'y trouve quand Stripe porte la carte, et la page d'un prestataire s'ouvre dans Custom Tabs, jamais dans une 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

Couvre

  • Paiements
  • Mobile Money
  • Cartes

Cette page décrit la version 1.3.0, qui suit le contrat commun aux quatre SDKs mobiles et à Wajub.js 1.8 : les mêmes options, callbacks, états, résultats, erreurs et le même objet d'apparence. Ce qu'ils partagent est sur SDKs mobiles ; cette page en est le côté Android.

Installer

build.gradle.kts
dependencies {
    implementation("com.wajub:wajub-mobile-compose:1.3.0")
}

Cette seule ligne suffit pour l'interface de paiement : le module Compose déclare le cœur comme dépendance api et l'apporte. Prenez com.wajub:wajub-mobile-core:1.3.0 seul uniquement si vous dessinez toute l'interface vous-même.

ArtefactTypeContenu
com.wajub:wajub-mobile-coreJar Kotlin/JVM simpleWajubMobile, WajubSession, CheckoutController (l'interface de paiement sans son UI), les règles Mobile Money, le suivi du statut par polling et Pusher
com.wajub:wajub-mobile-composeAAR AndroidLe composable PaymentSheet, le widget de carte de Stripe, Custom Tabs
PrérequisValeur
minSdk24, soit Android 7.0
compileSdk35. La bibliothèque ne fixe aucun targetSdk : votre application garde le sien
Cible Java et Kotlin17
Construit avecKotlin 2.0.21, Android Gradle Plugin 8.7.3
ComposeMaterial 3 1.3 (BOM 2024.12.01) ou 1.4 (BOM 2025.09.01) : l'interface n'utilise aucune API expérimentale
Apporté par le module Composestripe-android 21.2.0, androidx.browser 1.8.0, activity-compose 1.9.3, lifecycle 2.8.7, material-icons-core, Coil 2.7.0 avec son décodeur SVG
Apporté par le cœurOkHttp 4.12.0, coroutines 1.9.0, org.json, pusher-java-client 2.4.4

Le manifeste du module Compose ajoute deux entrées à votre manifeste fusionné : un content provider, WajubInitProvider, non exporté, qui renseigne le cœur sur l'appareil au démarrage de l'application, et un bloc <queries> pour les navigateurs qui proposent Custom Tabs. Si vous retirez le provider, l'interface fait ce travail elle-même avant sa première requête. Les deux artefacts fournissent leurs règles R8 : une application minifiée n'en a besoin d'aucune.

Démarrage rapide

Trois étapes, dont seule la deuxième s'exécute dans l'application.

  1. Votre serveur crée le paiement avec POST /payments et sa clé secrète, puis transmet authorization_token à l'application. Rien dans ces artefacts n'appelle /payments, et ils ne détiennent aucune clé secrète.
  2. L'application affiche PaymentSheet avec ce jeton. onComplete reçoit le résultat une seule fois, à la fermeture de l'interface.
  3. Votre serveur confirme le paiement, avec le webhook payment.succeeded ou GET /payments/{id}, avant de livrer. Le résultat dans l'application est ce que le payeur a vu, pas une preuve de paiement.
Toute l'intégration
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 est une classe scellée : le when est exhaustif sans else. L'interface conserve son état lors des changements de configuration (rotation, mode sombre, langue) dans un ViewModel. Après un changement, les callbacks appelés sont ceux de l'écran désormais affiché, et un résultat qui arrive pendant la recréation de l'écran parvient au nouveau. L'interface se règle quand l'écran qui l'affiche disparaît pour de bon : Cancelled avec HostGone avant toute tentative, Processing une fois une tentative lancée, Complete après un succès. Elle n'annule jamais le paiement dans ce cas. Afficher une deuxième PaymentSheet pendant qu'une autre est ouverte règle la deuxième aussitôt avec Failed et le code sheet_already_presented, sans toucher à la première.

Pour passer par votre propre OkHttpClient (un proxy, l'épinglage de certificat, des logs), créez la session vous-même, une fois par jeton.

Votre propre OkHttpClient
val session = remember(authorizationToken) {
    WajubMobile.createSession(authorizationToken, okHttpClient)
}

PaymentSheet(session, PresentOptions()) { result -> /* … */ }

Avec une session, PresentOptions.sessionToken n'est pas lu. L'interface est liée au paiement, pas à l'objet : une session recréée lors d'une recomposition reste la même interface, et remember évite seulement ce travail. Le SDK appelle l'API avec un client dérivé du vôtre par newBuilder() : vos intercepteurs, votre proxy, votre épinglage et votre pool de connexions sont conservés, ses propres délais remplacent les vôtres (10 secondes pour se connecter, puis 30 par appel, 60 pour une requête de paiement), et votre client reste intact.

Options

PresentOptions est une data class, et tous ses champs ont une valeur par défaut, sauf le jeton.

OptionType, défautEffet
sessionTokenStringL'authorization_token de votre serveur
localeString?, null"fr" ou "en". Sans elle, la langue de la session, sinon celle de l'appareil : fr… donne le français, tout le reste l'anglais
appearanceAppearance?, nullL'apparence de l'interface, voir Apparence
loaderLoaderStyle, SkeletonSkeleton, des blocs de substitution animés, ou Spinner
readyTimeoutLong en millisecondes, 15_000Une interface encore en chargement passé ce délai affiche une erreur avec Réessayer et signale ready_timeout à onLoadError. Si le paiement arrive plus tard, l'interface l'affiche. 0 le désactive
returnUrlString?, nullUn App Link https qui ramène dans votre application, envoyé aux pages des prestataires comme return_url. Sans lui, le payeur revient par lui-même
mobileMoneyMobileMoneyOptionscountry (le pays ISO-2 de départ), countryLocked (masque le sélecteur quand country est fourni), operatorMode (OperatorMode.Choose ou OperatorMode.Detect)
realtimeRealtimeOptions?, nullRealtimeOptions(enabled, host, port, tls) : un Reverb ou un Soketi auto-hébergé derrière un autre nom d'hôte, ou enabled = false pour le polling seul. Sans lui, la connexion indiquée par la session
cancelOnDismissBoolean, trueFermer l'interface tant que le paiement peut encore être annulé l'annule, voir Annuler et fermer. false le laisse ouvert pour afficher l'interface à nouveau
autoCloseSuccessLong en millisecondes, 3_000L'écran de succès se ferme seul passé ce délai. 0 le garde jusqu'à ce que le payeur touche Terminé
callbacksCheckoutCallbacksVoir ci-dessous

RealtimeOptions se trouve dans com.wajub.mobile.realtime, les autres dans com.wajub.mobile.checkout.

Callbacks et états

CheckoutCallbacks prend neuf lambdas facultatives. Elles s'exécutent sur le thread principal, dans l'ordre de Wajub.js, et un callback qui lève une exception est journalisé sans casser l'interface.

Tous les callbacks
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
    ),
)
CallbackDéclenchement
onReadyUne fois, quand le premier vrai écran s'affiche, y compris pour un paiement déjà payé ou terminé. environment vaut "sandbox" ou "live", amount est amount_total, methods les slugs des canaux affichés
onStateChangeAprès onReady avec le premier état, puis à chaque changement, jamais deux fois de suite avec le même état. method vaut mobile_money, card, wallet ou unknown
onLoadErrorLe paiement ne peut pas être proposé : session_not_found, session_terminal_expired, session_terminal_cancelled, session_terminal_failed, load_error, ready_timeout, no_payment_method
onPaymentStartedLa première réponse acceptée d'une tentative. À partir de là, fermer l'interface donne Processing
onRedirectJuste avant l'ouverture de la page d'un prestataire
onSuccessLe paiement a réussi. method vaut unknown quand il était payé avant l'ouverture de l'interface
onErrorChaque échec que voit le payeur, y compris ceux qu'il peut retenter, hors erreurs de champ
onCancelL'interface se ferme sur Cancelled
onCompleteUne seule fois, à la fermeture de l'interface

StateChangeEvent.state est un CheckoutState, une énumération qui porte les 17 noms de Wajub.js. Sa propriété value donne la chaîne exacte, ce qui compte pour l'un d'eux : la constante est THREE_DS_REQUIRED et sa valeur 3DS_REQUIRED.

CheckoutStateÉcran
INITIATED, COLLECTING_DETAILS, PROCESSINGLa liste des moyens, le formulaire d'un moyen, le formulaire avec Payer occupé
APPROVAL_REQUIRED, USSD_REQUIRED« Validez le paiement sur votre téléphone », ou un code USSD à composer
OTP_REQUIRED, PIN_REQUIRED, PHONE_REQUIRED, BIRTHDAY_REQUIRED, ADDRESS_REQUIRED, THREE_DS_REQUIRED, VERIFYINGUne étape de confirmation, puis sa vérification
PENDING« Paiement en cours », avec Continuer le paiement quand la page d'un prestataire peut être rouverte
SUCCESS, FAILED, EXPIRED, CANCELLEDLes issues, avec Réessayer sur un échec qui le permet

Le moment où chaque état commence est sur SDKs mobiles.

Résultats

CheckoutResultQuandContenu
CompleteL'interface s'est fermée après un succès, y compris quand le paiement était déjà payé à l'ouverturepayment: PaymentSuccess, transaction
ProcessingFermée pendant une tentative non tranchée, ou 15 minutes après Payer sans issue, comptées sur l'horloge monotone du téléphonetransaction, la dernière vue, peut être null
FailedFermée sur un échec, un paiement expiré, une erreur de chargement, ou un paiement annulé sur le téléphone (payment_cancelled)error: WajubError, transaction
CancelledAnnuler le paiement, une fermeture avant toute tentative, ou l'hôte disparu avantreason: CancelReason, redirectUrl

Chaque sous-classe porte aussi status, la chaîne du contrat : complete, processing, failed, cancelled. CancelReason vaut PayerCancelled, Dismissed ou HostGone, chacun avec son nom de contrat dans value.

PaymentSuccess est celui de Wajub.js : id (l'identifiant Wajub du paiement, celui que prend GET /payments/{id}), reference (la vôtre), amount (ce que le payeur a payé, frais compris, en unité principale), currency, method, status (toujours success), timestamp (ISO 8601) et receiptUrl.

Un processus qui meurt ne peut livrer aucun résultat. Au lancement suivant, interrogez votre serveur ou lisez WajubSession.status().

Annuler et fermer

Le payeur ferme l'interface avec son bouton de fermeture, en la faisant glisser vers le bas, avec le bouton Retour, ou en touchant la zone assombrie autour d'elle. L'interface est un Dialog Compose plein écran : ces quatre gestes mènent à la même fermeture. Son effet dépend de l'étape du paiement.

Quand le payeur la fermeRésultatPOST /pay/cancel
Pendant le chargement du paiementCancelled, DismissedNon
Avant toute tentative, le paiement pendingCancelled, DismissedOui, avec cancelOnDismiss (par défaut)
De Payer jusqu'à une réponse définitive, et sur les écrans d'approbation, USSD, « Paiement en cours », d'étape et de vérificationProcessingJamais : une approbation peut encore arriver
Après un refus définitif, ou Réessayer, de retour sur le formulaire ou la liste des moyensComme avant toute tentativeOui, avec cancelOnDismiss
Sur l'écran d'échecFailed, avec l'erreur affichée. onCancel n'est pas appeléOui, en arrière-plan, si le paiement peut encore être annulé et que cancelOnDismiss est actif

Un refus définitif est un refus de paiement, un refus signalé par le statut, une étape de confirmation terminée pour de bon, un code 3-D Secure refusé ou expiré, ou une session client refusée : le paiement redevient pending. L'interface ne demande une annulation que si le paiement est chargé, que son dernier statut connu est pending et qu'aucune tentative n'attend sa réponse. Avec cancelOnDismiss = false, une fermeture n'annule jamais : vous pouvez afficher l'interface à nouveau.

Annuler le paiement est proposé aux mêmes conditions, sur la liste des moyens et sur un formulaire inactif. Il donne Cancelled avec PayerCancelled et le redirectUrl renvoyé par l'API. Une annulation que l'API refuse lit le statut et se règle sur ce qu'il dit. Pendant une annulation, une fermeture masque l'interface et l'annulation la règle.

Le statut de la session décide aussitôt, sans attendre GET /pay/sdk-config : un paiement déjà payé s'ouvre sur l'écran de succès, un paiement terminé sur un avis qui signale son code session_terminal_, et un paiement déjà processing sur son écran d'approbation ou « Paiement en cours ». Quand GET /pay/sdk-config échoue et qu'aucun moyen ne peut être proposé sans lui, l'interface signale load_error, qu'on peut réessayer, plutôt que no_payment_method, et Réessayer relance les deux chargements.

Votre application qui retire le composable, ou termine son activité, n'est pas le payeur qui ferme l'interface : elle se règle sur HostGone avant toute tentative, sur Processing après le début d'une tentative, et n'appelle jamais /pay/cancel. Les règles communes aux quatre SDKs sont sur SDKs mobiles.

Apparence

L'objet d'apparence est celui de Wajub.js, clé pour clé : l'objet qui habille vos Components web habille l'interface native.

Apparence
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, live
        variables = AppearanceVariables(
            colorPrimary = "#0F766E",
            borderRadius = "12px",
            fontFamily = "Poppins",          // the name registered above
        ),
        buttonTextColor = "#FFFFFF",      // Wajub.js's top-level keys work too
        inherit = true,                   // your MaterialTheme fills what you leave unset
    ),
)

// The very object your web checkout uses, decoded from JSON:
val shared = Appearance.fromMap(mapOf("theme" to "night", "variables" to mapOf("colorPrimary" to "#7C3AED")))
  • Chaque valeur est une String, comme dans Wajub.js. Les couleurs s'écrivent #rgb, #rrggbb, #rrggbbaa, rgb() ou rgba() ; les longueurs "8px", "8" ou "0.5rem". Une valeur illisible est ignorée, jamais une erreur, et fromMap écarte les clés inconnues.
  • Pour chaque valeur, l'interface prend la première fournie : vos variables, vos clés de premier niveau, le preset, votre MaterialTheme avec inherit = true, l'image de marque du marchand dans le Dashboard, puis la palette claire ou sombre par défaut de Wajub.
  • inherit = true lit MaterialTheme.colorScheme (primary, surface, onSurface, onSurfaceVariant, outline), le rayon de MaterialTheme.shapes.small et la police de MaterialTheme.typography.bodyLarge, et ne garde une couleur que si elle contraste assez. Le schéma vient de la surface de votre thème.
  • Le schéma est votre colorScheme (ou theme), sinon celui de votre thème avec inherit, sinon le choix du marchand, sinon celui du téléphone, que l'interface suit en direct.
  • fontFamily, la vôtre ou celle du marchand, est un nom, et une pile CSS donne sa première famille. L'interface utilise la FontFamily que votre application a enregistrée sous ce nom avec WajubFonts.register(name, family), sans tenir compte de la casse, sinon une police système de ce nom (sans-serif-condensed, serif…), sinon celle de la plateforme. Les polices de res/font ou des assets n'atteignent l'interface que par WajubFonts ; unregister(name) en retire une.
  • Le logo, le nom commercial et les couleurs du marchand complètent ce que vous laissez vide. Une couleur principale du marchand qui ne ressort pas sur le fond retombe sur celle par défaut, et un marchand en marque blanche masque « Sécurisé par Wajub ».
  • Le squelette s'anime et les écrans s'enchaînent en fondu, sauf avec disableAnimations ou quand les animations du téléphone sont désactivées.

Les clés, les presets et les variables sont décrits une fois, pour le web et le mobile, sur Apparence. Ce que le mobile lit autrement est sur SDKs mobiles.

Mobile Money

Tous les canaux Mobile Money du paiement se regroupent en un seul moyen. Le formulaire présente le pays, les opérateurs avec leurs logos (un badge de marque quand un logo manque ou ne se charge pas), et le numéro avec son indicatif, groupé pendant la saisie.

  • Le préfixe du numéro présélectionne son opérateur tant que le payeur n'en choisit pas un lui-même. Un numéro d'un autre opérateur proposé est refusé avant tout appel, en nommant l'opérateur, sauf pour les portefeuilles qui acceptent tout numéro, comme Wave, et en sandbox.
  • Le numéro part au format E.164, +237677123456, avec operator sur un canal agrégé comme cm.mobile quand le payeur l'a désigné.
  • Après Payer, l'interface affiche « Validez le paiement sur votre téléphone » avec l'instruction de l'opérateur, repli USSD compris, et suit le paiement jusqu'à son issue.

PresentOptions.mobileMoney prépare le formulaire. MobileMoneyOptions(country = "CI", countryLocked = true) démarre sur un pays et masque le sélecteur, et operatorMode = OperatorMode.Detect masque la liste et affiche l'opérateur détecté dans le champ, en n'ouvrant la liste que si le numéro ne suffit pas.

Pour votre propre formulaire, le cœur fournit les mêmes règles : MobileMoney (nationalDigits, isValid, detectOperator, e164, formatAsYouType, et data() pour les pays et leurs indicatifs) et MobileMoneyRules (choices, defaultCountry, validate, requestData). Le fonctionnement de ces règles est sur SDKs mobiles.

Cartes

GET /pay/sdk-config décide comment le canal carte est payé, et l'interface le suit.

Canal carteDans l'interface
client_session (Paystack, Flutterwave, Adyen)Les champs de contact qui manquent encore au prestataire, puis son checkout hébergé dans Custom Tabs, finalisé au retour du payeur
stripe_elementsLe CardInputWidget de Stripe, dans l'interface, avec la clé publiable propre au paiement
hosted_redirect (CinetPay, Kkiapay, Mollie…)Exactement les required_fields, puis la page du prestataire dans Custom Tabs
form, en sandboxLe formulaire de carte de test de Wajub. Aucun numéro de carte n'est envoyé en live

Stripe ne vous demande rien : stripe-android vient avec le module Compose, et la carte ne passe jamais par votre code. Quand le payeur revient de Custom Tabs, l'interface lit le statut aussitôt, et Continuer le paiement rouvre la page.

Pour dessiner vous-même la carte sur Stripe, utilisez StripeTokenizer.createPaymentMethod(context, publishableKey, card, cardholderName) avec la card de votre propre CardInputWidget (paymentMethodCreateParams?.card), et ajoutez implementation("com.stripe:stripe-android:21.2.0") à votre propre fichier de build : le module Compose utilise Stripe sans l'exposer.

Une session client peut aussi être confiée au SDK Android du prestataire. Le ClientSession que renvoie openClientSession porte publicKey et accessCode pour Paystack, et publicKey, encryptionKey, reference, amount et currency pour Flutterwave. Ces SDKs ne sont pas fournis. Une fois le payeur revenu, confirmClientSession(id) la finalise comme pour la page hébergée.

Étapes de confirmation

Certains prestataires de carte demandent davantage au payeur après Payer. Chaque étape a son écran, appelle son propre endpoint, affiche les essais restants et propose Renvoyer le code 60 secondes après son ouverture.

ÉtapeÉtat
Un code envoyé par SMS ou par e-mailOTP_REQUIRED
Le code PIN de la cartePIN_REQUIRED
Un numéro de téléphonePHONE_REQUIRED
Une date de naissance, saisie au format AAAA-MM-JJ ou choisie dans le DatePickerDialog de la plateformeBIRTHDAY_REQUIRED
Une adresse de facturationADDRESS_REQUIRED
Le code 3-D Secure envoyé par e-mail en sandbox, avec son compte à reboursTHREE_DS_REQUIRED

Une valeur erronée avec des essais restants reste sur l'étape. Trop d'essais, ou une étape expirée, fait échouer la tentative, et le payeur peut recommencer. Le sens de chaque réponse est sur SDKs mobiles.

Erreurs

WajubError étend Exception. L'interface la transmet à onLoadError, à onError et à CheckoutResult.Failed, et les appels de session la renvoient dans leurs résultats ou la lèvent.

PropriétéUsage
typeWajubErrorType : ApiError, AuthenticationError, InvalidRequestError, PaymentError, RateLimitError. type.value donne le nom de Wajub.js, payment_error
codeLe code du catalogue, ou celui de l'API transmis tel quel
messagePour le payeur, dans la langue de l'interface
declineCodeSur un refus (402), le code du prestataire
retryableSi Réessayer peut aboutir
paramLe champ en cause, data.phone par exemple
httpStatusLe statut HTTP, quand il y en a un
correlationIdLe X-Request-Id de la réponse : à communiquer au support
retryAfterSecondsL'attente sur rate_limited
detailsLes erreurs de champ de l'API, un message par champ
blockReferenceSur shield_blocked, la référence pour le support du marchand
apiMessageLe texte anglais de l'API, pour vos journaux

toMap() donne la forme avec les clés de Wajub.js, decline_code, correlation_id et les autres. ErrorCatalog liste chaque code avec son type, s'il permet de réessayer, et ses messages anglais et français : ErrorCatalog.message(code, WajubLanguage.Fr, null). Les codes et leur sens sont sur SDKs mobiles.

Votre propre interface

Deux niveaux se trouvent sous l'interface de paiement. WajubSession est le client d'API d'un paiement, et CheckoutController toute l'interface sans son UI.

Un paiement, vos écrans
import com.wajub.mobile.WajubMobile
import com.wajub.mobile.model.ProcessOutcome
import com.wajub.mobile.model.ProcessRequest
import com.wajub.mobile.realtime.WatchEvent

val session = WajubMobile.createSession(authorizationToken)

viewModelScope.launch {
    val data = session.loadSession()    // amount, channels, brand, echo…
    val config = session.getSdkConfig() // per channel: sdk, publishableKey, requiredFields, clientSession

    val outcome = session.process(
        ProcessRequest("cm.mobile", mapOf("phone" to "+237677123456", "country" to "CM", "operator" to "mtn")),
    )
    when (outcome) {
        is ProcessOutcome.Complete -> Unit
        is ProcessOutcome.Processing -> Unit     // kind: Approval, Ussd or Pending, with instruction
        is ProcessOutcome.RequiresAction -> Unit // a page to open (url) or a step (step, stepId, hint)
        is ProcessOutcome.Failed -> showError(outcome.error.message)
    }

    session.watch().events().collect { event ->
        when (event) {
            is WatchEvent.Succeeded -> showPaid()
            is WatchEvent.Failed, is WatchEvent.Cancelled, is WatchEvent.Expired -> showNotPaid()
            WatchEvent.StillProcessing -> showPending()
            is WatchEvent.StatusChanged -> Unit
        }
    }
}
WajubSessionRôle
loadSession(forceRefresh)GET /pay/session, mis en cache après le premier appel. SessionData.brand est l'image de marque du marchand
getSdkConfig()GET /pay/sdk-config, indexé par slug de canal brut (cm.mtn, jamais cm.mobile)
process(ProcessRequest)POST /pay/process avec les signaux de l'appareil ajoutés, traduit en ProcessOutcome. Dans ProcessRequest(channel, data, idempotencyKey), la clé vaut par défaut un nouvel UUID, que les nouvelles tentatives automatiques réutilisent. Un numéro de carte, un CVC ou un PIN dans data sur un paiement live est refusé avant tout envoi, raw_card_data_not_accepted
openClientSession(fields, returnUrl, restart, altcha, channel)POST /pay/client-session pour une carte payée sur la page du prestataire. RequiresAction.url est la page, clientSession ses détails
confirmClientSession(clientSessionId)Au retour du payeur : Complete, Processing tant que le prestataire n'a pas réglé, ou Failed
verifyStep(kind, stepId, values)Répond à une étape, StepKind.Otp, Pin, Phone, Birthday, Address ou ThreeDs, avec un StepResult : Accepted, Rejected (avec attemptsRemaining et canRetryHere) ou Invalid. Jamais relancé automatiquement. Un 5xx hors 3-D Secure ne dit rien de l'étape : il est levé, lisez status()
resendStep(kind, stepId)Un nouveau code. Pour ThreeDs, renvoie l'e-mail masqué
status()GET /pay/status. Son reference est l'identifiant Wajub du paiement, pas le vôtre
watch(realtime, seenProcessing)Un PaymentWatcher : events() est un Flow<WatchEvent> qui interroge le statut et écoute la connexion temps réel jusqu'à l'issue du paiement, 15 minutes au plus de l'horloge monotone de l'appareil, jamais comparées à l'expiration du paiement. pollNow() lit aussitôt, au retour du payeur
cancel()POST /pay/cancel, renvoie le redirect_url du marchand
languageLa langue des messages d'erreur, WajubLanguage.En ou Fr

Trois règles que l'interface applique pour vous, et que vos propres écrans doivent suivre :

  • Un résultat Processing sans transaction est une réponse jamais arrivée, ou un 5xx. Suivez le statut, n'affichez jamais d'échec. S'il indique encore pending 60 secondes après unansweredSince, sans refus plus récent que celui que portait déjà le paiement, renvoyez la même ProcessRequest, avec la même idempotencyKey : une requête bien arrivée reçoit une seule réponse et n'est jamais débitée deux fois. Un nouveau refus est la réponse de cette tentative.
  • RequiresAction.step désigne l'étape de confirmation d'un confirm_otp, confirm_pin, confirm_phone, confirm_birthday ou confirm_address. Un confirm_3ds avec un stepId est le code envoyé par e-mail en sandbox, auquel on répond avec StepKind.ThreeDs ; avec une url, c'est une page à ouvrir.
  • Quand SessionData.altcha est renseigné, chaque requête demande un défi résolu. Altcha.solve(challenge) donne une AltchaSolution, envoyée comme data["altcha"] = solution.toMap() ou par openClientSession(altcha = …). Un défi ne sert qu'une fois : rechargez la session pour le suivant.

CheckoutController(session, options, scope) exécute toute la logique de l'interface : le chargement, les moyens, les formulaires, les réponses, les étapes, le suivi, l'annulation et la fermeture, avec les mêmes callbacks et le même résultat. Renseignez urlOpener (par exemple { url -> HostedRedirectLauncher.open(context, url) }), appelez start(), dessinez son screen (un StateFlow<SheetScreen>) et transmettez les actions du payeur : selectMethod, setCountry, setPhone, pickOperator, payMobileMoney, payWallet, payStripe, setCardField, payCard, setStepField, submitStep, resendStep, continuePayment, tryAgain, cancelPayment, close et dismiss. Appelez onReturn() quand l'application reprend la main et hostGone() quand votre écran disparaît pour de bon. Chaque appel se fait sur le thread principal, où s'exécutent les callbacks.

Confidentialité

Pour évaluer la fraude comme le fait le checkout web, chaque POST /pay/process porte des signaux de l'appareil dans data._client : le fuseau horaire, la première langue, la taille de l'écran en pixels, la profondeur de couleur et une empreinte d'installation. Chaque requête envoie aussi X-Wajub-Client (wajub-kotlin/1.3.0 (android 14)), un X-Request-Id neuf, les langues de l'appareil dans Accept-Language, et un User-Agent qui se termine par la même valeur.

L'empreinte est le SHA-256 d'un identifiant aléatoire créé à la première utilisation. Elle ne dérive jamais d'un identifiant matériel ou publicitaire. Le module Compose garde cet identifiant dans un fichier du répertoire sans sauvegarde de l'application, wajub-install-id : privé à l'application, conservé lors des mises à jour, jamais restauré sur un autre appareil, effacé avec les données de l'application.

Une application qui n'utilise que wajub-mobile-core renseigne l'appareil elle-même. Sans cela, l'identifiant vit en mémoire sur Android, un nouveau par processus, et dans ~/.wajub/install-id sur une JVM simple. L'empreinte est calculée une seule fois, hors du thread principal.

Cœur seul : renseigner l'appareil
import com.wajub.mobile.WajubMobile
import com.wajub.mobile.device.DeviceEnvironment
import com.wajub.mobile.device.FileInstallIdStore
import java.io.File

WajubMobile.device = DeviceEnvironment(
    screen = { screenSizeInPixels() }, // "1080x2400", portrait
    installIds = FileInstallIdStore(File(context.noBackupFilesDir, "wajub-install-id")),
)

Ce qu'il faut déclarer dans les formulaires de l'App Store et de Google Play pour ces signaux est sur SDKs mobiles.

Migrer depuis 1.2

Le code écrit pour 1.2 compile toujours et obtient ce qu'il obtenait. Les noms de 1.2 sont dépréciés, avec leur remplaçant à côté.

1.2, puis 1.3
// 1.2
PaymentSheet(wajubSession = session, onDismiss = { showSheet = false }, onResult = { result -> handle(result) })

// 1.3
PaymentSheet(PresentOptions(sessionToken = token)) { result -> handle(result) }
1.21.3
PaymentSheet(wajubSession, onDismiss, onResult)PaymentSheet(options, onComplete) ou PaymentSheet(session, options, onComplete)
payMobileMoney, payCard, payCardHostedRedirect, payWallet, process(channel, data)process(ProcessRequest(channel, data)), avec PaymentMapper.buildMobileMoneyRequest ou buildHostedCardRequest pour construire data
startClientSession, completeClientSessionopenClientSession, confirmClientSession
watchStatus, watchStatusPolling, RealtimeSubscriber, StatusPollerwatch(), ou watch(RealtimeOptions(enabled = false))
SessionData.branding, chaque valeur en texteSessionData.brand, typé

Gardez vos handlers en passant à la nouvelle interface : onDismiss devient un résultat Cancelled avec Dismissed, et onResult le résultat, ou callbacks.onSuccess et onError.

Ce qui change sans modifier votre code :

  • L'interface 1.2 dessine les nouveaux écrans et signale comme le faisait 1.2.1. Le français, l'écran d'échec avec Réessayer, les opérateurs et leurs logos, les écrans d'approbation et de confirmation. onResult reçoit chaque réponse à une requête de paiement : RequiresAction avec PushApproval pour une notification, avec Redirect, Confirm ou Confirm3ds pour une page de prestataire qu'elle ouvre, Complete, Processing, et l'issue d'une carte payée sur la page du prestataire. Elle n'envoie jamais un second Complete issu de son propre suivi : une application qui suit elle-même le statut, comme le faisaient les applications 1.2, n'est pas prévenue deux fois. Son écran de succès ne se ferme pas seul.
  • onDismiss arrive aussitôt quand le payeur la ferme, pas quand votre application la retire. Comme en 1.2.1, le paiement n'est annulé en arrière-plan que si rien n'a été envoyé, jamais une fois Payer pressé, même après un numéro refusé ou Réessayer.
  • Les suivis de 1.2 gardent leurs flux, leur intervalMs, leurs résultats et leur absence de limite de durée. Ils lisent GET /pay/status au lieu de recharger la session. Une session disparue (401, 403, 404, 410) termine le flux par Failed avec l'erreur que donnent les appels 1.2 pour cette réponse, une AuthenticationError qu'on ne peut pas réessayer pour 401, 404 et 410, là où 1.2.1 interrogeait indéfiniment. Un paiement revenu à pending après processing le termine par Failed, le code de refus ou payment_attempt_ended, là où 1.2.1 signalait Processing indéfiniment. Une valeur n'est émise que si le résultat change, les erreurs espacent les appels de 5 secondes jusqu'à une minute, et le flux se termine sur succeeded, refunded et partially_refunded, plus sur paid, success, successful ou complete, que l'API n'envoie jamais.
  • Les méthodes de 1.2 envoient ce qu'elles envoyaient : buildMobileMoneyRequest garde le numéro tel quel, et elles gardent leur PaymentResult et leurs erreurs. Les signaux de l'appareil et le numéro E.164 viennent avec process(ProcessRequest) et MobileMoneyRules.requestData.
  • Chaque requête, ancienne ou nouvelle, porte désormais X-Wajub-Client, X-Request-Id et Accept-Language, avec son propre délai (30 secondes, 60 pour les appels qui atteignent un prestataire) et les nouvelles tentatives que le contrat autorise.
  • Les constructeurs et le copy de 1.2.1 de PayClient (new PayClient() depuis Java compris), MobileMoneyInput, WajubError, SessionTransaction, ProcessingContext, SessionChannel et SessionData sont conservés, pour les appelants Java et le code compilé contre 1.2.1.

Les valeurs que 1.3 lit autrement que 1.2.1, appels 1.2 compris :

Valeur1.2.11.3
SessionChannel.nameFrLu depuis name_fr, que l'API n'envoie jamais : toujours nullLu depuis la clé name.fr de l'API
Un canal sans min_amount ou max_amountNaNnull
Une session sans localeSessionData.locale vaut le texte "null""en", et sessionLocale vaut null
Un echo sans keyEchoConfig.key vaut "null"""
Idempotency-Keywajub- suivi d'un UUIDUn UUID seul

Tests

L'origine de l'API est une constante, API_URL : il n'y a pas d'URL de base à rediriger. Créez plutôt le paiement avec une clé sandbox, et le jeton place tout le parcours en sandbox, interface comprise, qui affiche un badge « MODE TEST ».

SandboxValeur
Numéros Mobile MoneyLe préfixe de l'opérateur et sept chiffres terminés par le scénario : 000000 succès, 000001 fonds insuffisants, 000002 échec, 000003 délai dépassé, 000004 annulé. +237670000000 réussit au Cameroun
CartesLes cartes de test publiées par Wajub, dans le formulaire sandbox. 4000056655665556 demande le code 3-D Secure envoyé par e-mail

Les numéros sandbox ne suivent pas les plans de numérotation réels : l'interface n'en refuse donc jamais un comme appartenant à un autre opérateur. Tous les scénarios sont sur Scénarios de test.

Comme le cœur est un simple jar JVM, il tourne dans un test unitaire sans émulateur, le bon endroit pour éprouver votre traitement des résultats et des erreurs.

Que pensez-vous de ce contenu ?