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
| Platform | Package | Registry | Version | Page |
|---|---|---|---|---|
| Flutter | wajub_mobile | pub.dev | 1.1.1 | Flutter |
| React Native | @wajub/react-native | npm | 1.1.1 | React Native |
| Android | com.wajub:wajub-mobile-compose, com.wajub:wajub-mobile-core | Maven Central | 1.2.1 | Android |
| iOS | WajubMobileUI, WajubMobile | Swift Package Manager | 1.0.0 | iOS |
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.
| Flutter | React Native | Android | iOS | |
|---|---|---|---|---|
| Open the sheet | presentWajubCheckout(context, options) | usePayment().present(options) | PaymentSheet(options) { result -> } | WajubCheckout.present(options, from:) |
| The result arrives as | A Future | A Promise | The onComplete lambda | An async throws return |
| Mobile Money, every operator and aggregate | Yes | Yes | Yes | Yes |
| Card through Stripe | Built in, flutter_stripe | With @stripe/stripe-react-native, an optional peer | Built in, stripe-android | Through a CardCollector your app writes with Stripe's SDK |
| Card on the provider's page, Paystack, Flutterwave, Adyen, CinetPay… | System browser | System browser | Custom Tabs | SFSafariViewController |
| Sandbox test card form | Yes | Yes | Yes | Yes |
| One-tap wallets, Djamo | Yes | Yes | Yes | Yes |
| Confirmation steps, OTP, PIN, phone, birthday, address | Yes | Yes | Yes | Yes |
| Realtime and polling | Yes | Yes | Yes | Yes |
inherit reads | Theme.of(context) | The colour scheme only | MaterialTheme | The presenting screen's tint, background and text colours |
| Languages | English, French | English, French | English, French | English, French |
| For your own interface | WajubSession | WajubSession | WajubSession, CheckoutController | WajubSession, CheckoutController |
| Runtime floor | Flutter 3.24 | React Native 0.74, React 18 | Android 7.0, API 24 | iOS 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
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 orderThe 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
onSuccessfires withmethodset tounknown. - A payment that failed, was cancelled or expired opens on a "this payment has ended" notice, with
onLoadErrorandsession_terminal_failed,session_terminal_cancelledorsession_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_errorwith Retry, notno_payment_method.
States
onStateChange reports Wajub.js's CheckoutState names, exactly as the
web Components do. On mobile each one is a screen.
| State | Screen | Entered when |
|---|---|---|
INITIATED | The method list | The payment loaded with more than one method |
COLLECTING_DETAILS | A method's form | A method is chosen, or is the only one; also after a refused field or Try again |
PROCESSING | The form, its Pay button busy | The 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_REQUIRED | A confirmation step | The provider asks for it |
3DS_REQUIRED | The sandbox 3-D Secure email code, or 3-D Secure in the browser | The bank asks for it |
VERIFYING | The step screen, busy | A step's answer is sent |
PENDING | "Payment in progress", with Continue payment when a provider page can be reopened | A provider page is open, the answer is not in yet, or the provider has not settled |
SUCCESS | The success screen: amount, merchant, reference, receipt link | The payment succeeded, or was already paid when the sheet opened |
FAILED | The failure screen, Try again when the error is retryable | A 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.
| Callback | Payload | Fires |
|---|---|---|
onReady | sessionId, environment (sandbox or live), amount, currency, methods | Once per load, when the first real screen is drawn, also for a payment already paid or ended |
onStateChange | state, method | After onReady, then on every change, never twice in a row with the same state |
onLoadError | WajubError | The sheet cannot offer a payment: a load code |
onPaymentStarted | method, channel | The first accepted answer of an attempt. From then on, closing the sheet resolves processing |
onRedirect | url, method (GET) | Right before a provider page opens in the browser |
onSuccess | PaymentSuccess | The payment succeeded, with method set to unknown when it was paid before the sheet opened |
onError | WajubError | Every failure the payer sees, retryable ones included, field validation excluded |
onCancel | reason, redirectUrl | The sheet closes as cancelled |
onComplete | CheckoutResult | Exactly 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.
| Result | When | Carries |
|---|---|---|
complete | The sheet closed after the success screen, which closes itself after 3 seconds unless autoCloseSuccess says otherwise | payment (PaymentSuccess), transaction |
processing | The sheet closed while an attempt was running, or 15 minutes of watching ended without an outcome. Money may still move | transaction, the last one seen, possibly none |
failed | The sheet closed on a failure, an expired payment, a load error, or a payment cancelled elsewhere (payment_cancelled) | error, transaction |
cancelled | Cancel payment, a dismiss before any attempt, or your app removing the sheet before any attempt | reason (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 payer | API call | Result |
|---|---|---|
| Taps Cancel payment, offered on the method list and a form while no attempt is undecided | POST /pay/cancel | cancelled, payer_cancelled, with the redirectUrl the cancel returned |
| Closes the sheet while the payment loads | None | cancelled, dismissed |
| Closes it before any attempt, or back on the form after a refusal | POST /pay/cancel, unless cancelOnDismiss is false | cancelled, dismissed |
| Closes it on the failure screen | POST /pay/cancel when the payment can be cancelled and cancelOnDismiss is on. onCancel does not fire | failed, with the error the payer saw |
| Closes it while approving, dialling, confirming a step or waiting, or while a request has no answer yet | None | processing |
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
realtimeoption points it at another host, port or TLS setting, or turns it off. - Polling
GET /pay/statusevery 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 reads | The sheet |
|---|---|
succeeded, refunded, partially_refunded | SUCCESS |
failed | FAILED, payment_failed, not retryable: the payment has ended |
cancelled, refused on the phone or cancelled elsewhere | CANCELLED, payment_cancelled |
expired, an approval nobody answered | EXPIRED, session_expired |
pending again after processing | FAILED with the refusal's code, insufficient_funds for instance, else payment_attempt_ended: nothing was charged, retryable |
401, 403, 404 | FAILED, session_not_found |
410 | EXPIRED, session_terminal_expired |
Mobile Money
One form covers every operator, with the web checkout's rules.
- Country.
mobileMoney.countryif you set it, else the payer's country when a Mobile Money channel covers it, else the first one offered.countryLockedhides the selector. - Operators. One choice per operator. An aggregate,
cm.mobilein 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: detectthe 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,operatorgoes 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 says | The sheet |
|---|---|
No card entry, or available: false | No card |
client_session: true, Paystack, Flutterwave or Adyen | The 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_elements | Stripe'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 instance | Exactly the fields listed in required_fields, then the provider's page |
sdk: form on a sandbox payment | Wajub's test card form |
| Anything else | No 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.
| Step | Screen | State |
|---|---|---|
confirm_otp | A code of 4 to 10 digits, "sent to" the masked contact | OTP_REQUIRED |
confirm_pin | The card PIN, masked, 4 to 6 digits | PIN_REQUIRED |
confirm_phone | A phone number | PHONE_REQUIRED |
confirm_birthday | A date picker | BIRTHDAY_REQUIRED |
confirm_address | Address, city, state and ZIP, all required | ADDRESS_REQUIRED |
| Sandbox 3-D Secure | The code sent by email, with its countdown | 3DS_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,darkorauto, which follows the phone live while the sheet is open.colorScheme(ortheme) first, then the preset's, then your app's withinherit, then the merchant's choice in the Dashboard, elseauto. - Presets.
stripe,night,flatandnone, with Wajub.js's values. - Precedence, per token.
variables, then the top-level keys, then the preset, then your app's theme withinherit, 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()orrgba(), lengths as8px,8or0.5rem. Anything else, an unknown key, andrules, which has no meaning on mobile, is ignored, never an error.borderRadiusstops at 22. - Fonts. A
fontFamilyis used only if your app ships it, else the platform's. - Loading.
loaderis 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 andready_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.
| Group | Codes | Reaches |
|---|---|---|
| Load | session_not_found, session_terminal_expired, session_terminal_cancelled, session_terminal_failed, load_error and ready_timeout (retryable), no_payment_method | onLoadError, result failed |
| Transport | network_error, timeout, server_error, rate_limited (retryable after retry_after_seconds), request_failed | onError |
| Form | phone_required, invalid_phone, operator_mismatch, operator_required, operator_unavailable, field_required, invalid_email, validation_error | The field, never onError |
| Payment | payment_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_unavailable | onError |
| Confirmation steps | invalid_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_timeout | The step screen, or onError |
| Client sessions | client_session_unavailable, client_session_expired | onError |
| Your code | sheet_already_presented | The 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.
Payment attempts are rate limited
Payment requests, client sessions and step answers share a limit of 10 per 5 minutes for one
token from one address. The sheet does not retry a rate_limited answer: it shows a countdown
of retry_after_seconds before Try again. Do not put a retry loop around the low-level calls
either.
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, inX-Wajub-Clientand, where the platform lets the SDK set it, at the end of theUser-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.
| Platform | Where the install identifier is kept |
|---|---|
| Flutter | shared_preferences, key wajub_mobile.install_id |
| React Native | AsyncStorage, key @wajub/install-id, or the storage you pass |
| Android | A file in the app's no-backup directory |
| iOS | A 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 Connect | What the SDK sends |
|---|---|
| Contact Info, Name | First and last name or card holder, when the payment method asks for them |
| Contact Info, Email Address | When the card provider asks for it |
| Contact Info, Phone Number | The Mobile Money number, or a phone a card provider asks for |
| Contact Info, Physical Address | Address, city, ZIP code and country, when the card provider asks for them |
| Financial Info, Payment Info | The Mobile Money account, the Stripe pm_…, a card PIN a provider asks for, and test cards in sandbox |
| Identifiers, Device ID | The install fingerprint, for fraud prevention |
| Other Data | A 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:
000000succeeds,000001is declined for insufficient funds,000002fails,000003times out,000004is cancelled.+237670000000succeeds 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.
4000056655665556asks for the 3-D Secure code sent by email.
Every number and card is on Test scenarios.
Pick your platform
Related pages
Related pages
- Create a paymentThe server call that produces the token.
- Sessions & securityWhat a session token may and may not do.
- WebhooksThe confirmation that ships the order.
- AppearanceThe object the web and the sheet share.
- Payment methodsEvery operator and channel slug.
- Choose your SDKRuntime floors and the constraints that rule one out.