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 · GAMaven 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
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.
| Artefact | Type | Contenu |
|---|---|---|
com.wajub:wajub-mobile-core | Jar Kotlin/JVM simple | WajubMobile, 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-compose | AAR Android | Le composable PaymentSheet, le widget de carte de Stripe, Custom Tabs |
Le cœur ne contient rien d'Android
wajub-mobile-core est publié comme kotlin("jvm"), pas comme bibliothèque Android. Il n'a ni
manifeste, ni ressources, ni Context. C'est pourquoi il tourne dans un simple test JVM, et
pourquoi l'interface, le lanceur de navigateur et le tokenizer Stripe se trouvent tous dans
l'artefact Compose.
| Prérequis | Valeur |
|---|---|
minSdk | 24, soit Android 7.0 |
compileSdk | 35. La bibliothèque ne fixe aucun targetSdk : votre application garde le sien |
| Cible Java et Kotlin | 17 |
| Construit avec | Kotlin 2.0.21, Android Gradle Plugin 8.7.3 |
| Compose | Material 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 Compose | stripe-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œur | OkHttp 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.
Le groupe est com.wajub
co.wajub ne correspond à rien. Les deux artefacts se trouvent sous com.wajub, le même groupe
que wajub-java côté serveur.
Démarrage rapide
Trois étapes, dont seule la deuxième s'exécute dans l'application.
- Votre serveur crée le paiement avec
POST /paymentset sa clé secrète, puis transmetauthorization_tokenà l'application. Rien dans ces artefacts n'appelle/payments, et ils ne détiennent aucune clé secrète. - L'application affiche
PaymentSheetavec ce jeton.onCompletereçoit le résultat une seule fois, à la fermeture de l'interface. - Votre serveur confirme le paiement, avec le webhook
payment.succeededouGET /payments/{id}, avant de livrer. Le résultat dans l'application est ce que le payeur a vu, pas une preuve de paiement.
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.
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.
| Option | Type, défaut | Effet |
|---|---|---|
sessionToken | String | L'authorization_token de votre serveur |
locale | String?, 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 |
appearance | Appearance?, null | L'apparence de l'interface, voir Apparence |
loader | LoaderStyle, Skeleton | Skeleton, des blocs de substitution animés, ou Spinner |
readyTimeout | Long en millisecondes, 15_000 | Une 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 |
returnUrl | String?, null | Un 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 |
mobileMoney | MobileMoneyOptions | country (le pays ISO-2 de départ), countryLocked (masque le sélecteur quand country est fourni), operatorMode (OperatorMode.Choose ou OperatorMode.Detect) |
realtime | RealtimeOptions?, null | RealtimeOptions(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 |
cancelOnDismiss | Boolean, true | Fermer 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 |
autoCloseSuccess | Long en millisecondes, 3_000 | L'écran de succès se ferme seul passé ce délai. 0 le garde jusqu'à ce que le payeur touche Terminé |
callbacks | CheckoutCallbacks | Voir 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.
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
),
)| Callback | Déclenchement |
|---|---|
onReady | Une 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 |
onStateChange | Aprè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 |
onLoadError | Le 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 |
onPaymentStarted | La première réponse acceptée d'une tentative. À partir de là, fermer l'interface donne Processing |
onRedirect | Juste avant l'ouverture de la page d'un prestataire |
onSuccess | Le paiement a réussi. method vaut unknown quand il était payé avant l'ouverture de l'interface |
onError | Chaque échec que voit le payeur, y compris ceux qu'il peut retenter, hors erreurs de champ |
onCancel | L'interface se ferme sur Cancelled |
onComplete | Une 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, PROCESSING | La 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, VERIFYING | Une é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, CANCELLED | Les issues, avec Réessayer sur un échec qui le permet |
Le moment où chaque état commence est sur SDKs mobiles.
Résultats
CheckoutResult | Quand | Contenu |
|---|---|---|
Complete | L'interface s'est fermée après un succès, y compris quand le paiement était déjà payé à l'ouverture | payment: PaymentSuccess, transaction |
Processing | Fermée pendant une tentative non tranchée, ou 15 minutes après Payer sans issue, comptées sur l'horloge monotone du téléphone | transaction, la dernière vue, peut être null |
Failed | Fermé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 |
Cancelled | Annuler le paiement, une fermeture avant toute tentative, ou l'hôte disparu avant | reason: 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 ferme | Résultat | POST /pay/cancel |
|---|---|---|
| Pendant le chargement du paiement | Cancelled, Dismissed | Non |
Avant toute tentative, le paiement pending | Cancelled, Dismissed | Oui, 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érification | Processing | Jamais : une approbation peut encore arriver |
| Après un refus définitif, ou Réessayer, de retour sur le formulaire ou la liste des moyens | Comme avant toute tentative | Oui, avec cancelOnDismiss |
| Sur l'écran d'échec | Failed, 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.
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()ourgba(); les longueurs"8px","8"ou"0.5rem". Une valeur illisible est ignorée, jamais une erreur, etfromMapé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, votreMaterialThemeavecinherit = true, l'image de marque du marchand dans le Dashboard, puis la palette claire ou sombre par défaut de Wajub. inherit = truelitMaterialTheme.colorScheme(primary,surface,onSurface,onSurfaceVariant,outline), le rayon deMaterialTheme.shapes.smallet la police deMaterialTheme.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(outheme), sinon celui de votre thème avecinherit, 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 laFontFamilyque votre application a enregistrée sous ce nom avecWajubFonts.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 deres/fontou des assets n'atteignent l'interface que parWajubFonts;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
disableAnimationsou 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, avecoperatorsur un canal agrégé commecm.mobilequand 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 carte | Dans 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_elements | Le 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 sandbox | Le 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-mail | OTP_REQUIRED |
| Le code PIN de la carte | PIN_REQUIRED |
| Un numéro de téléphone | PHONE_REQUIRED |
Une date de naissance, saisie au format AAAA-MM-JJ ou choisie dans le DatePickerDialog de la plateforme | BIRTHDAY_REQUIRED |
| Une adresse de facturation | ADDRESS_REQUIRED |
| Le code 3-D Secure envoyé par e-mail en sandbox, avec son compte à rebours | THREE_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 |
|---|---|
type | WajubErrorType : ApiError, AuthenticationError, InvalidRequestError, PaymentError, RateLimitError. type.value donne le nom de Wajub.js, payment_error |
code | Le code du catalogue, ou celui de l'API transmis tel quel |
message | Pour le payeur, dans la langue de l'interface |
declineCode | Sur un refus (402), le code du prestataire |
retryable | Si Réessayer peut aboutir |
param | Le champ en cause, data.phone par exemple |
httpStatus | Le statut HTTP, quand il y en a un |
correlationId | Le X-Request-Id de la réponse : à communiquer au support |
retryAfterSeconds | L'attente sur rate_limited |
details | Les erreurs de champ de l'API, un message par champ |
blockReference | Sur shield_blocked, la référence pour le support du marchand |
apiMessage | Le 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.
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
}
}
}WajubSession | Rô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 |
language | La 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
Processingsans transaction est une réponse jamais arrivée, ou un5xx. Suivez le statut, n'affichez jamais d'échec. S'il indique encorepending60 secondes aprèsunansweredSince, sans refus plus récent que celui que portait déjà le paiement, renvoyez la mêmeProcessRequest, avec la mêmeidempotencyKey: 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.stepdésigne l'étape de confirmation d'unconfirm_otp,confirm_pin,confirm_phone,confirm_birthdayouconfirm_address. Unconfirm_3dsavec unstepIdest le code envoyé par e-mail en sandbox, auquel on répond avecStepKind.ThreeDs; avec uneurl, c'est une page à ouvrir.- Quand
SessionData.altchaest renseigné, chaque requête demande un défi résolu.Altcha.solve(challenge)donne uneAltchaSolution, envoyée commedata["altcha"] = solution.toMap()ou paropenClientSession(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.
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
PaymentSheet(wajubSession = session, onDismiss = { showSheet = false }, onResult = { result -> handle(result) })
// 1.3
PaymentSheet(PresentOptions(sessionToken = token)) { result -> handle(result) }| 1.2 | 1.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, completeClientSession | openClientSession, confirmClientSession |
watchStatus, watchStatusPolling, RealtimeSubscriber, StatusPoller | watch(), ou watch(RealtimeOptions(enabled = false)) |
SessionData.branding, chaque valeur en texte | SessionData.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.
onResultreçoit chaque réponse à une requête de paiement :RequiresActionavecPushApprovalpour une notification, avecRedirect,ConfirmouConfirm3dspour 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 secondCompleteissu 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. onDismissarrive 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 lisentGET /pay/statusau lieu de recharger la session. Une session disparue (401,403,404,410) termine le flux parFailedavec l'erreur que donnent les appels 1.2 pour cette réponse, uneAuthenticationErrorqu'on ne peut pas réessayer pour401,404et410, là où 1.2.1 interrogeait indéfiniment. Un paiement revenu àpendingaprèsprocessingle termine parFailed, le code de refus oupayment_attempt_ended, là où 1.2.1 signalaitProcessingindé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 sursucceeded,refundedetpartially_refunded, plus surpaid,success,successfuloucomplete, que l'API n'envoie jamais. - Les méthodes de 1.2 envoient ce qu'elles envoyaient :
buildMobileMoneyRequestgarde le numéro tel quel, et elles gardent leurPaymentResultet leurs erreurs. Les signaux de l'appareil et le numéro E.164 viennent avecprocess(ProcessRequest)etMobileMoneyRules.requestData. - Chaque requête, ancienne ou nouvelle, porte désormais
X-Wajub-Client,X-Request-IdetAccept-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
copyde 1.2.1 dePayClient(new PayClient()depuis Java compris),MobileMoneyInput,WajubError,SessionTransaction,ProcessingContext,SessionChanneletSessionDatasont 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 :
| Valeur | 1.2.1 | 1.3 |
|---|---|---|
SessionChannel.nameFr | Lu depuis name_fr, que l'API n'envoie jamais : toujours null | Lu depuis la clé name.fr de l'API |
Un canal sans min_amount ou max_amount | NaN | null |
Une session sans locale | SessionData.locale vaut le texte "null" | "en", et sessionLocale vaut null |
Un echo sans key | EchoConfig.key vaut "null" | "" |
Idempotency-Key | wajub- suivi d'un UUID | Un 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 ».
| Sandbox | Valeur |
|---|---|
| Numéros Mobile Money | Le 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 |
| Cartes | Les 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.
Pages associées
- SDKs mobilesLe contrat que partagent les quatre.
- JavaLe côté serveur, sous le même groupe com.wajub.
- Créer un paiementL'appel serveur qui produit le jeton.
- WebhooksLa confirmation qui déclenche la livraison.
- FlutterLa même interface, en Dart.
- React NativeLa même interface, en TypeScript.
- iOSLa même interface, en Swift.