Skip to content

Mobile SDKs

Four packages, one contract, and a native payment sheet without a WebView.

The four mobile SDKs, Flutter, React Native, Android and iOS, implement one contract, spec v2, the one Wajub.js 1.8 follows on the web. They take the same options, fire the same callbacks in the same order, report the same states and results, raise the same error codes and accept the same appearance object as the web Components. What differs between them is the language.

Your server creates the payment and hands the app an authorization_token. The app presents a native sheet with it. The sheet takes the payment to its end, approvals, confirmation steps and provider pages included, and resolves exactly once, when it closes. Your server then confirms the payment, by webhook, before anything ships.

There is no WebView for Wajub's own screens. Mobile Money is a native form. Pages the sheet cannot avoid, a card provider's checkout, a 3-D Secure challenge or a wallet's approval page, open in the system browser.

The four packages

PlatformPackageRegistryVersionPage
Flutterwajub_mobilepub.dev1.1.1Flutter
React Native@wajub/react-nativenpm1.1.1React Native
Androidcom.wajub:wajub-mobile-compose, com.wajub:wajub-mobile-coreMaven Central1.2.1Android
iOSWajubMobileUI, WajubMobileSwift Package Manager1.0.0iOS

These pages describe the first releases of spec v2: Flutter 1.6.0, React Native 1.5.0, Android 1.3.0 and iOS 1.0.0. Code written for Flutter 1.5, React Native 1.4 or Android 1.2 still compiles; what changed in its behaviour is in the migration notes of each platform page.

What each SDK does

The sheet is the same on the four platforms. Where they differ is how you open it, how a Stripe card reaches it, and what your own interface can reuse.

FlutterReact NativeAndroidiOS
Open the sheetpresentWajubCheckout(context, options)usePayment().present(options)PaymentSheet(options) { result -> }WajubCheckout.present(options, from:)
The result arrives asA FutureA PromiseThe onComplete lambdaAn async throws return
Mobile Money, every operator and aggregateYesYesYesYes
Card through StripeBuilt in, flutter_stripeWith @stripe/stripe-react-native, an optional peerBuilt in, stripe-androidThrough a CardCollector your app writes with Stripe's SDK
Card on the provider's page, Paystack, Flutterwave, Adyen, CinetPay…System browserSystem browserCustom TabsSFSafariViewController
Sandbox test card formYesYesYesYes
One-tap wallets, DjamoYesYesYesYes
Confirmation steps, OTP, PIN, phone, birthday, addressYesYesYesYes
Realtime and pollingYesYesYesYes
inherit readsTheme.of(context)The colour scheme onlyMaterialThemeThe presenting screen's tint, background and text colours
LanguagesEnglish, FrenchEnglish, FrenchEnglish, FrenchEnglish, French
For your own interfaceWajubSessionWajubSessionWajubSession, CheckoutControllerWajubSession, CheckoutController
Runtime floorFlutter 3.24React Native 0.74, React 18Android 7.0, API 24iOS 15

The sheet shows Mobile Money channels, the card channel and one-tap wallets. Every other channel type, bank transfer, crypto, USSD only or buy now pay later, is not offered. Saved payment methods, instalments, the merchant's custom checkout fields and currency conversion are not part of this contract.

The flow, once

Where each half runs
Your server                            Your app
───────────                            ────────
POST /payments with sk.
  returns authorization_token   ──▶    present({ sessionToken })
                                         │
                                         ├─ GET /pay/session and /pay/sdk-config, in parallel
                                         ├─ method → form → POST /pay/process
                                         ├─ approval, confirmation step or provider page
                                         └─ watches the status until an outcome
                                             │
                                         complete · processing · failed · cancelled
webhook payment.succeeded
  fulfils the order

The token is the only credential the app ever holds. It is scoped to one payment, it expires, and it cannot list, refund or read anything else on your account. Your secret key stays on your server.

The webhook is still what fulfils the order

The result the app receives is what the payer saw, not proof of payment. A Mobile Money approval can land after the payer closed the app, and a phone can lose signal mid-approval. Use the result to pick the next screen, then confirm with GET /payments/{id} or the payment.succeeded webhook before you release the goods.

Loading

The sheet loads the payment and its SDK configuration in parallel, behind a skeleton or a spinner. The payment's status decides at once, without waiting for the configuration.

  • A payment already paid opens on the success screen, and onSuccess fires with method set to unknown.
  • A payment that failed, was cancelled or expired opens on a "this payment has ended" notice, with onLoadError and session_terminal_failed, session_terminal_cancelled or session_terminal_expired.
  • A payment still processing, because the app was killed during an approval for instance, opens on the screen it was on, approval, USSD code, sandbox 3-D Secure code or "Payment in progress", and the sheet watches it.
  • A pending payment opens on its methods. If the SDK configuration cannot be read, Mobile Money still works. A card or a wallet needs it: when they are all the payment offers, the sheet shows the retryable load_error with Retry, not no_payment_method.

States

onStateChange reports Wajub.js's CheckoutState names, exactly as the web Components do. On mobile each one is a screen.

StateScreenEntered when
INITIATEDThe method listThe payment loaded with more than one method
COLLECTING_DETAILSA method's formA method is chosen, or is the only one; also after a refused field or Try again
PROCESSINGThe form, its Pay button busyThe payment request is sent
APPROVAL_REQUIRED"Approve the payment on your phone"A Mobile Money push was sent
USSD_REQUIRED"Dial this code"The provider asks the payer to dial a USSD code
OTP_REQUIRED, PIN_REQUIRED, PHONE_REQUIRED, BIRTHDAY_REQUIRED, ADDRESS_REQUIREDA confirmation stepThe provider asks for it
3DS_REQUIREDThe sandbox 3-D Secure email code, or 3-D Secure in the browserThe bank asks for it
VERIFYINGThe step screen, busyA step's answer is sent
PENDING"Payment in progress", with Continue payment when a provider page can be reopenedA provider page is open, the answer is not in yet, or the provider has not settled
SUCCESSThe success screen: amount, merchant, reference, receipt linkThe payment succeeded, or was already paid when the sheet opened
FAILEDThe failure screen, Try again when the error is retryableA refusal, a failure, a load error
EXPIRED"This payment has expired"The payment or its approval expired
CANCELLED"Payment cancelled"Cancel payment, or a payment cancelled elsewhere

Try again goes back to the same method's form, and the next Pay gets a new Idempotency-Key. SUCCESS, EXPIRED and CANCELLED are final. The method reported with a state is the family: mobile_money, card, wallet, or unknown before one is chosen.

Callbacks

All optional, all called on the main thread, in this order for a payment: onReady, then onStateChange for each state, onPaymentStarted and onRedirect as they happen, then onSuccess, onError or onCancel, and onComplete last, exactly once. A callback that throws is caught and logged, and the sheet carries on.

CallbackPayloadFires
onReadysessionId, environment (sandbox or live), amount, currency, methodsOnce per load, when the first real screen is drawn, also for a payment already paid or ended
onStateChangestate, methodAfter onReady, then on every change, never twice in a row with the same state
onLoadErrorWajubErrorThe sheet cannot offer a payment: a load code
onPaymentStartedmethod, channelThe first accepted answer of an attempt. From then on, closing the sheet resolves processing
onRedirecturl, method (GET)Right before a provider page opens in the browser
onSuccessPaymentSuccessThe payment succeeded, with method set to unknown when it was paid before the sheet opened
onErrorWajubErrorEvery failure the payer sees, retryable ones included, field validation excluded
onCancelreason, redirectUrlThe sheet closes as cancelled
onCompleteCheckoutResultExactly once, as the sheet closes, with the value the call resolves with

PaymentSuccess is Wajub.js's PaymentSuccess: id is Wajub's payment id, the one GET /payments/{id} takes, reference is yours, amount is what the payer paid, fee included, in major units, then currency, method, status (success), timestamp and the receipt URL when there is one.

Results

The call that presents the sheet resolves exactly once, with one of four results.

ResultWhenCarries
completeThe sheet closed after the success screen, which closes itself after 3 seconds unless autoCloseSuccess says otherwisepayment (PaymentSuccess), transaction
processingThe sheet closed while an attempt was running, or 15 minutes of watching ended without an outcome. Money may still movetransaction, the last one seen, possibly none
failedThe sheet closed on a failure, an expired payment, a load error, or a payment cancelled elsewhere (payment_cancelled)error, transaction
cancelledCancel payment, a dismiss before any attempt, or your app removing the sheet before any attemptreason (payer_cancelled, dismissed, host_gone), redirectUrl

The call never hangs. When the screen that presented the sheet goes away for good, a route popped, a composable gone, a view controller dismissed, a component unmounted, it resolves cancelled with host_gone before any attempt, processing once one started, complete after a success, and stops watching. It never cancels the payment then. Presenting a second sheet while one is open fails at once with sheet_already_presented and leaves the open sheet alone.

A process the system killed cannot resolve anything. At the next launch, read the outcome on your server, or with WajubSession.status().

Cancelling and closing

The sheet sends POST /pay/cancel only when the payment loaded, its last known status is pending, and no attempt of this sheet is undecided. An attempt is undecided from the press of Pay until a definitive answer: a decline, a refusal the status reports, a confirmation step that failed for good, or an ended payment. The API would also cancel a payment the payer is still approving on the phone, which is why the sheet never asks it to.

The payerAPI callResult
Taps Cancel payment, offered on the method list and a form while no attempt is undecidedPOST /pay/cancelcancelled, payer_cancelled, with the redirectUrl the cancel returned
Closes the sheet while the payment loadsNonecancelled, dismissed
Closes it before any attempt, or back on the form after a refusalPOST /pay/cancel, unless cancelOnDismiss is falsecancelled, dismissed
Closes it on the failure screenPOST /pay/cancel when the payment can be cancelled and cancelOnDismiss is on. onCancel does not firefailed, with the error the payer saw
Closes it while approving, dialling, confirming a step or waiting, or while a request has no answer yetNoneprocessing

After a definitive refusal the payment is pending again, so Cancel payment is offered again and closing the sheet cancels it as before any attempt. While the payment loads, closing sends nothing; once the session has answered, its status decides at once, and a payment already processing resolves processing.

A cancelled payment is cancelled for good: Wajub sends payment.cancelled, and its token stops a minute later. A cancel call that fails changes nothing for the payer, the result is still cancelled. A cancel the API refuses makes the sheet read the status and resolve with what it says. cancelOnDismiss: false keeps the payment open on a dismiss before any attempt, so you can present the sheet again.

This is where mobile and the web differ on purpose: closing a Wajub.js frame cancels nothing, while a payer who swipes a native sheet away is leaving.

A payment without an answer

The sheet never shows a failure it cannot be sure of. A payment request is retried up to twice on a network error, a timeout or a 502, 503 or 504, with the same body and the same Idempotency-Key. When it still gets no answer the sheet can decide on, a 500 included, the outcome is unknown: the sheet moves to PENDING and watches the status.

If that status still reads a plain pending 60 seconds after the last try, never processing and with no refusal newer than the one it already carried before this attempt, the request most likely never reached Wajub. The sheet goes back to the form with a connection error the payer can retry, and the next Pay sends the same Idempotency-Key. A request that did arrive after all is answered with its first answer, or payment_in_progress, and is never charged twice. A newer refusal, on the other hand, is this attempt's answer: the sheet shows it as a failure with its code, and the payment can be cancelled again.

A 400 makes the sheet read the status before it decides. An ended payment is shown as such. A payment still processing, or a status the sheet cannot read, is watched as PENDING. A plain pending fails with not_processable.

Following the payment

From the first accepted answer of an attempt until an outcome, the sheet watches the payment two ways at once.

  • Realtime, over the Pusher protocol, on the server the session names: hosted Pusher, or a self-hosted Reverb or Soketi. The realtime option points it at another host, port or TLS setting, or turns it off.
  • Polling GET /pay/status every 5 seconds. After two minutes without a change each wait is 1.5 times the last, up to 15 seconds. While the socket is connected it polls every 15 seconds, and on errors it backs off up to a minute.

Coming back from a provider page reads the status at once. The watch ends 15 minutes after the press of Pay, timed on the phone's monotonic clock, whatever its time or the payment's expiry say, on "Payment in progress", and closing then resolves processing.

The status readsThe sheet
succeeded, refunded, partially_refundedSUCCESS
failedFAILED, payment_failed, not retryable: the payment has ended
cancelled, refused on the phone or cancelled elsewhereCANCELLED, payment_cancelled
expired, an approval nobody answeredEXPIRED, session_expired
pending again after processingFAILED with the refusal's code, insufficient_funds for instance, else payment_attempt_ended: nothing was charged, retryable
401, 403, 404FAILED, session_not_found
410EXPIRED, session_terminal_expired

Mobile Money

One form covers every operator, with the web checkout's rules.

  • Country. mobileMoney.country if you set it, else the payer's country when a Mobile Money channel covers it, else the first one offered. countryLocked hides the selector.
  • Operators. One choice per operator. An aggregate, cm.mobile in Cameroon today, lists each member, MTN and Orange, with its own name and logo. A logo that is missing or does not load is drawn as the operator's brand badge.
  • Number. The country's dial code as a fixed prefix, a phone keypad, and the number formatted as it is typed.
  • Detection. The number's prefix preselects its operator until the payer picks one by hand. A manual pick is never overridden. With operatorMode: detect the list stays hidden and the detected operator shows as a badge in the field, until the number does not tell or the payer opens the list.
  • Checks before sending. An empty number (phone_required), an invalid one (invalid_phone), a number of another offered operator (operator_mismatch: "This number belongs to MTN…"), no operator (operator_required). Wave, Express Union, Djamo, Capitec, OPay and PalmPay take any number and are never a mismatch. In sandbox a mismatch never blocks.
  • What is sent. The number in E.164, +237677123456. Côte d'Ivoire keeps its leading 0, where the web checkout drops it. On an aggregate, operator goes with it only when the payer picked it, or the number names the one selected.

After Pay, "Approve the payment on your phone" shows the amount, the number and the provider's instruction, sometimes a USSD code to dial instead. That instruction comes from the provider in English, so a French sheet shows it under an "Instructions" label. Pay is not offered again while the payment is processing, and there is no client-side time limit short of the 15-minute watch.

Cards

The card channel's entry in GET /pay/sdk-config decides what the sheet shows. First match wins.

sdk-config saysThe sheet
No card entry, or available: falseNo card
client_session: true, Paystack, Flutterwave or AdyenThe contact fields the provider still needs, then its hosted checkout in the browser. Live only: in sandbox the API answers client_session_unavailable
sdk: stripe_elementsStripe's card UI with the payment's own publishable key. Only the Stripe account that issues the pm_… can charge it
sdk: hosted_redirect, CinetPay, Kkiapay or Mollie for instanceExactly the fields listed in required_fields, then the provider's page
sdk: form on a sandbox paymentWajub's test card form
Anything elseNo card

Card numbers are never sent to Wajub in live: the API refuses them.

When the payer comes back from a client session, the sheet completes it at once. A paid session is a success. One the provider has not settled yet stays PENDING and watched. A declined one is a retryable failure. An expired one is client_session_expired, start again.

Provider pages open in the system browser, never a WebView: Custom Tabs on Android, SFSafariViewController on iOS. The returnUrl option is an https universal or app link into your app, sent to those pages as return_url. Without it the provider returns the payer to the merchant's page, and the payer comes back to the app by hand. Either way the sheet reads the status as soon as the app is in the foreground, and Continue payment reopens the page.

Confirmation steps

Some providers, Paystack card charges today, ask the payer for one more thing after Pay. Each step has a native screen and its own endpoint.

StepScreenState
confirm_otpA code of 4 to 10 digits, "sent to" the masked contactOTP_REQUIRED
confirm_pinThe card PIN, masked, 4 to 6 digitsPIN_REQUIRED
confirm_phoneA phone numberPHONE_REQUIRED
confirm_birthdayA date pickerBIRTHDAY_REQUIRED
confirm_addressAddress, city, state and ZIP, all requiredADDRESS_REQUIRED
Sandbox 3-D SecureThe code sent by email, with its countdown3DS_REQUIRED

Resend is offered 60 seconds after the screen opens. A wrong answer keeps the screen, with the attempts left. An expired step or too many wrong answers is a retryable failure, back to the methods. An accepted answer whose payment is still processing moves to PENDING and keeps watching. A provider that chains two steps cannot show the second one today, because the API returns only the payment: the sheet keeps watching.

Earlier versions answered every step with unsupported_action.

Appearance

The appearance option takes Wajub.js 1.8's appearance object, key for key, so one object styles your web Components and the native sheet. The keys, presets and variables are on Appearance.

  • Scheme. light, dark or auto, which follows the phone live while the sheet is open. colorScheme (or theme) first, then the preset's, then your app's with inherit, then the merchant's choice in the Dashboard, else auto.
  • Presets. stripe, night, flat and none, with Wajub.js's values.
  • Precedence, per token. variables, then the top-level keys, then the preset, then your app's theme with inherit, then the merchant's branding, then the web checkout's default light or dark palette.
  • Merchant branding fills what you leave unset: logo, business name, colours, radius, font weights. A merchant primary colour below 3:1 against the background falls back to the default one, and a white-label merchant hides "Secured by Wajub".
  • Values. Colours as #rgb, #rrggbb, #rrggbbaa, rgb() or rgba(), lengths as 8px, 8 or 0.5rem. Anything else, an unknown key, and rules, which has no meaning on mobile, is ignored, never an error. borderRadius stops at 22.
  • Fonts. A fontFamily is used only if your app ships it, else the platform's.
  • Loading. loader is a pulsing skeleton in the resolved colours, or a spinner. disableAnimations, or the phone's reduced motion setting, stops the pulse and the fades. readyTimeout, 15 seconds by default where the web has none, replaces a load that takes too long with an error view, a Retry button and ready_timeout. If the payment arrives meanwhile, the sheet shows it.

Errors

Every failure is a WajubError with Wajub.js's fields, type, code, message, decline_code, retryable and param, and six mobile ones: http_status, correlation_id (the response's X-Request-Id, quote it to support), retry_after_seconds, details (the field errors), block_reference and api_message (the API's own English text, for your logs). message is for the payer, in the sheet's language. React Native keeps these names in snake_case; Flutter, Android and iOS write them in camelCase, declineCode, correlationId.

type is one of api_error, authentication_error, invalid_request_error, payment_error and rate_limit_error. retryable decides whether Try again can work. A code the catalogue does not know, a provider's own decline code for instance, is passed through.

GroupCodesReaches
Loadsession_not_found, session_terminal_expired, session_terminal_cancelled, session_terminal_failed, load_error and ready_timeout (retryable), no_payment_methodonLoadError, result failed
Transportnetwork_error, timeout, server_error, rate_limited (retryable after retry_after_seconds), request_failedonError
Formphone_required, invalid_phone, operator_mismatch, operator_required, operator_unavailable, field_required, invalid_email, validation_errorThe field, never onError
Paymentpayment_declined, insufficient_funds and other decline codes (retryable), merchant_not_verified, limit_exceeded, shield_blocked (with block_reference), merchant_restricted, payment_method_restricted, country_restricted, currency_restricted, channel_not_accepted, payment_failed, payment_attempt_ended (retryable), payment_cancelled, session_expired, not_processable, order_not_validated, raw_card_data_not_accepted, security_check_failed, unsupported_action, redirect_unavailableonError
Confirmation stepsinvalid_otp, invalid_pin, invalid_phone, invalid_birthday, invalid_address, invalid_code (the screen stays), otp_expired, pin_expired, phone_expired, birthday_expired, address_expired, max_attempts_exceeded, 3ds_failed, 3ds_rejected, 3ds_cancelled, 3ds_timeoutThe step screen, or onError
Client sessionsclient_session_unavailable, client_session_expiredonError
Your codesheet_already_presentedThe call that presents the sheet

The load codes are Wajub.js's, no_payment_method aside, with one difference: a pending payment past its expiry is session_terminal_expired here, where Wajub.js reports session_not_found. A paid payment is never a load error: the sheet opens on its success screen.

Device signals and privacy

To score fraud the way the web checkout does, every payment request carries a few device signals, and every call names the SDK.

  • The device's time zone, its first language and its languages (Accept-Language), the main screen's size in pixels and its colour depth.
  • The SDK's name and version and the OS version, wajub-flutter/1.6.0 (android 15) for instance, in X-Wajub-Client and, where the platform lets the SDK set it, at the end of the User-Agent.
  • An install fingerprint: the SHA-256 of a random identifier the SDK creates on first use and keeps in the app's private storage. It is never derived from a hardware or advertising identifier, it survives app updates, and it is gone when the app is uninstalled or its data cleared.
PlatformWhere the install identifier is kept
Fluttershared_preferences, key wajub_mobile.install_id
React NativeAsyncStorage, key @wajub/install-id, or the storage you pass
AndroidA file in the app's no-backup directory
iOSA file in the app's Application Support directory

The phone number, email and other fields the payer types go to Wajub only to make the payment, and the device signals and the install fingerprint go to Wajub, api.wajub.com, only. Besides Wajub, the sheet connects to:

  • Stripe, with the Stripe card form: the card goes from Stripe's field to Stripe, which returns the token the sheet sends to Wajub. Stripe's SDK collects what Stripe documents for its own fraud checks.
  • The realtime host the session names, Pusher or the merchant's own Reverb or Soketi. It sees the connection, so the device's IP address, and the channel name, and sends the payment's status.
  • The hosts of the operators' logos, plain image requests to the URLs the session gives.
  • The payment pages of the providers, opened in the system browser when the payment needs one.

What to declare, for what the SDK sends when someone pays, next to what your app collects on its own: linked to the user, not used for tracking, for app functionality, which covers processing the payment and preventing fraud. The iOS package lists it for App Store Connect, and ships a privacy manifest, PrivacyInfo.xcprivacy, that declares the same data:

App Store ConnectWhat the SDK sends
Contact Info, NameFirst and last name or card holder, when the payment method asks for them
Contact Info, Email AddressWhen the card provider asks for it
Contact Info, Phone NumberThe Mobile Money number, or a phone a card provider asks for
Contact Info, Physical AddressAddress, city, ZIP code and country, when the card provider asks for them
Financial Info, Payment InfoThe Mobile Money account, the Stripe pm_…, a card PIN a provider asks for, and test cards in sandbox
Identifiers, Device IDThe install fingerprint, for fraud prevention
Other DataA date of birth when a provider asks for it; the device's time zone, language and screen size, for fraud prevention

The Flutter package lists it for Google Play data safety: Device or other IDs (the install fingerprint), Personal info (phone number, email) and Financial info (purchase history), collected for Fraud prevention, security, and compliance and App functionality, shared with Wajub as your payment processor, encrypted in transit.

Sandbox

A payment created with a test key gives a sandbox token, and the whole flow runs in sandbox, sheet included. The SDKs read the environment from the payment itself, never from the token's shape, and there is no base URL to change: every call goes to https://api.wajub.com.

  • Mobile Money. A test number is the operator's prefix and seven digits, the last six choosing the outcome: 000000 succeeds, 000001 is declined for insufficient funds, 000002 fails, 000003 times out, 000004 is cancelled. +237670000000 succeeds in Cameroon. Any other number is refused with the list of valid ones. Sandbox prefixes do not follow real numbering plans, so the sheet never refuses a test number as another operator's.
  • Cards. The test card form takes the published test cards only. 4000056655665556 asks for the 3-D Secure code sent by email.

Every number and card is on Test scenarios.

Pick your platform

What did you think of this content?