iOS (Swift)
The WajubMobile Swift package, a SwiftUI payment sheet, and no WebView.
The iOS SDK collects payments inside your native application. It presents Mobile Money, card and wallet payments in a SwiftUI sheet, with no WebView for Wajub's own screens, and opens a provider's card page or a 3-D Secure check in Safari. It implements the shared mobile contract, spec v2: the same options, callbacks, states, results, errors and appearance object as the Flutter, Android and React Native SDKs and as Wajub.js 1.8.
WajubMobileUI
Stable · GASwift Package Manager
- Version
- 1.0.0
- Runtime
- iOS 15+, Swift 5.9+ (Xcode 15+)
- Frameworks
- SwiftUI sheet, UIKit presenter
Covers
- Payments
- Mobile Money
- Cards
Install
The package is added with Swift Package Manager. In Xcode, File › Add Package Dependencies…
with https://github.com/wajubhq/wajub-ios, or in your own Package.swift:
dependencies: [
.package(url: "https://github.com/wajubhq/wajub-ios.git", from: "1.0.0"),
],
targets: [
.target(name: "App", dependencies: [
.product(name: "WajubMobileUI", package: "wajub-ios"),
]),
]| Requirement | Value |
|---|---|
| iOS | 15.0 or later |
| Swift | 5.9 or later, so Xcode 15 or later |
| Dependencies | None. Stripe is optional and lives in your app, see Cards |
| Target | An app. The sheet presents from the app's windows (UIApplication.shared), so not from an app extension |
The package has two products, and WajubMobileUI brings WajubMobile with it.
| Product | What it holds |
|---|---|
WajubMobile | Foundation only: the models, the API client (timeouts, retries, Idempotency-Key), the errors, the Mobile Money tables and rules, the status watch (polling and a Pusher-protocol socket), and CheckoutController, the sheet's state machine without any UI |
WajubMobileUI | The SwiftUI sheet and its UIKit presenter, WajubCheckout.present(_:from:) |
Import WajubMobile alone when you draw your own payment screens.
Quick start
Your server creates the payment, the app presents the sheet with its token, and your server
confirms the outcome. Nothing in the package talks to /payments or holds a secret key.
1. Create the payment on your server with POST /payments and your
secret key, then hand its authorization_token to the app through your own endpoint.
struct CheckoutResponse: Decodable {
let authorizationToken: String
}
func fetchToken(cartId: String) async throws -> String {
var request = URLRequest(url: URL(string: "https://api.yourshop.com/checkout")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(["cart_id": cartId])
let (data, _) = try await URLSession.shared.data(for: request)
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(CheckoutResponse.self, from: data).authorizationToken
}2. Present the sheet from a view controller. present resolves exactly once, when the sheet is
gone, with the same value onComplete receives, so another present can follow at once. One
called from onComplete, or right after the presenting screen went away, waits for the settled
sheet to be gone; only a sheet still open makes it throw sheet_already_presented.
import UIKit
import WajubMobile
import WajubMobileUI
@MainActor
func pay(token: String, from viewController: UIViewController) async {
var options = PresentOptions(sessionToken: token)
options.returnURL = "https://shop.example.com/app/return" // optional, a universal link into your app
options.callbacks.onStateChange = { event in print("checkout:", event.state.rawValue) }
do {
switch try await WajubCheckout.present(options, from: viewController) {
case .complete(let payment, _):
// Paid. Confirm on your server before delivering.
await confirmOnServer(paymentId: payment.id)
case .processing:
// Closed while a payment was running: money may still move.
showPending()
case .failed(let error, _):
showError(error.message)
case .cancelled(let reason, _):
print("cancelled:", reason.rawValue)
}
} catch let error as WajubError {
// Only sheet_already_presented: a sheet is already open.
print(error.code)
} catch {}
}from: is optional. Without it the sheet presents from the top-most view controller of the key
window. There is no SwiftUI view or modifier for the sheet: from a SwiftUI screen, call
WajubCheckout.present without from:, or with the view controller that hosts your view.
3. Confirm on your server before you deliver anything, with GET /payments/{id} or the
payment.succeeded webhook. The result in the app is what
the payer saw, not a proof of payment: a Mobile Money approval can land after the payer closed the
sheet.
Options
PresentOptions is a struct of var properties. Its initializer takes every one of them as a
labelled argument with the default below, so set them there or on the value afterwards.
| Option | Type | Default | What it does |
|---|---|---|---|
sessionToken | String | Required | The payment's authorization_token |
locale | String? | nil | "fr" or "en". Without it, the session's language, else the device's. A tag starting with fr is French, anything else English |
appearance | Appearance? | nil | The sheet's look, see Appearance |
loader | LoaderStyle | .skeleton | .skeleton, pulsing placeholder blocks, or .spinner |
readyTimeout | Int, milliseconds | 15000 | Past it, a sheet still loading shows "We couldn't load the payment" with Retry and reports ready_timeout to onLoadError. 0 disables it |
returnURL | String? | nil | An https universal link back into your app, sent as return_url to provider pages. Without it the payer comes back to the app by hand |
mobileMoney | MobileMoneyOptions | MobileMoneyOptions() | country to start on (ISO-2), countryLocked to hide the country selector, operatorMode .choose (the operator list, default) or .detect |
realtime | RealtimeOptions? | nil | RealtimeOptions(enabled:host:port:tls:): another Pusher-protocol host, a self-hosted Reverb or Soketi, or enabled: false for polling only |
cancelOnDismiss | Bool | true | Closing the sheet before any attempt cancels the payment. false leaves it open so you can present it again |
autoCloseSuccess | Int, milliseconds | 3000 | Before the success screen closes itself. 0 keeps it |
callbacks | CheckoutCallbacks | CheckoutCallbacks() | See Callbacks and states |
cardCollector | CardCollector? | nil | Your Stripe card collector. Without one, a card routed to Stripe is not offered |
let options = PresentOptions(
sessionToken: token,
locale: "fr",
loader: .spinner,
returnURL: "https://shop.example.com/app/return",
mobileMoney: MobileMoneyOptions(country: "CI", countryLocked: true, operatorMode: .detect),
realtime: RealtimeOptions(host: "ws.shop.example", port: 443, tls: true),
cancelOnDismiss: false
)LoaderStyle(wajubJS:) reads the loader value of a Wajub.js configuration: "skeleton" is
.skeleton, anything else, "none" included, is .spinner.
Callbacks and states
Every callback is optional and is called on the main thread, in this order: onReady, then
onStateChange as the sheet moves, onPaymentStarted and onRedirect, then onSuccess,
onError or onCancel, and onComplete exactly once. Each is typed
(@MainActor (Payload) throws -> Void)?: a callback that throws is caught and logged, and the
sheet carries on.
| Callback | Payload | When |
|---|---|---|
onReady | ReadyEvent: sessionId, environment ("sandbox" or "live"), amount, currency, methods (the channel slugs shown) | The first screen is drawn, also for a payment already paid or ended |
onStateChange | StateChangeEvent: state, method (mobile_money, card, wallet or unknown) | After onReady, then on every state change |
onLoadError | WajubError | The sheet cannot offer a payment |
onPaymentStarted | PaymentStartedEvent: method, channel | The first 2xx of an attempt. From now on, closing the sheet resolves .processing |
onRedirect | RedirectEvent: url, method (always "GET") | Right before a provider page opens |
onSuccess | PaymentSuccess | The payment succeeded, with method "unknown" when it was already paid at load |
onError | WajubError | Every failure the payer sees, retryable ones included. Field validation is not reported |
onCancel | CancelEvent: reason, redirectURL | The sheet closes as .cancelled |
onComplete | CheckoutResult | The sheet closes, with what present resolves with |
options.callbacks = CheckoutCallbacks(
onReady: { event in print("ready:", event.environment, event.methods) },
onPaymentStarted: { event in print("paying with", event.channel) },
onError: { error in print(error.code, error.correlationId ?? "-") }
)CheckoutState is a String enum with Wajub.js's 17 names as its raw values: .initiated
(INITIATED), .collectingDetails, .processing, .approvalRequired, .ussdRequired,
.otpRequired, .pinRequired, .phoneRequired, .birthdayRequired, .addressRequired,
.threeDSRequired (3DS_REQUIRED), .verifying, .pending, .success, .failed, .expired
and .cancelled. Compare on the case, log rawValue. What each state shows is on
Mobile SDKs.
Results
CheckoutResult is an enum with four cases, and status gives its name as a string, complete,
processing, failed or cancelled.
| Case | When |
|---|---|
.complete(payment:transaction:) | The sheet closed after the success screen, also for a payment already paid when it opened |
.processing(transaction:) | The sheet closed while an attempt was running, or after the 15 minute watch: money may still move |
.failed(error:transaction:) | The sheet closed on a failure, an expiry, a load error, or a payment cancelled elsewhere (payment_cancelled). Closing on a failure also releases a payment that can still be cancelled, see Cancelling and closing |
.cancelled(reason:redirectURL:) | Cancel payment (.payerCancelled), a close before any attempt (.dismissed), or the presenting screen going away first (.hostGone) |
PaymentSuccess is Wajub.js's: id (Wajub's payment id, what GET /payments/{id} takes),
reference (yours), amount (what the payer paid, fee included, in major units), currency,
method, status (always "success"), timestamp (ISO 8601) and receiptURL.
present settles whatever happens to the screen you presented from. When that screen is dismissed
or deallocated while the sheet is open, the sheet resolves .cancelled with .hostGone before any
attempt, .processing once one started and .complete after a success, and it never cancels the
payment. With no window to present from, or a presentation UIKit refuses, it resolves .hostGone
at once.
If the app's process dies, nothing can resolve. At the next launch, read the outcome on your server,
or with WajubSession(sessionToken:).status().
Opening and unanswered payments
- A payment that is not
pendingwhen the sheet opens is drawn from the session at once, without waiting for the payment methods' setup (GET /pay/sdk-config): the success screen when it is paid, a "this payment has ended" notice withonLoadErrorwhen it failed, was cancelled or expired, and the approval or waiting screen it was on, watched, when it is stillprocessing. - When the payment methods' setup cannot be read, Mobile Money is still offered. If nothing can be
offered without it, a card or a wallet only, the sheet reports
load_error, which is retryable, rather thanno_payment_method. - A payment request that gets no answer, a
5xxincluded, is never shown as a failure: the sheet shows "Payment in progress" and watches the payment. A refusal newer than the attempt shows at once. If the payment still has not started 60 seconds after the last try, the sheet goes back to the form with a connection error, and the next Pay sends the sameIdempotency-Key. - A
400makes the sheet read the status: an outcome is shown, aprocessingpayment or a status that cannot be read is watched, and a payment stillpendingfails withnot_processable. - The watch ends 15 minutes after the press of Pay, measured on the phone's monotonic clock, whatever its time or the payment's expiry say.
The full rules are on Mobile SDKs.
Cancelling and closing
The payer closes the sheet with its close button or a swipe down. While a request runs (a payment, a verification, a cancel, your card collector), the sheet cannot be swiped away.
| The payer closes | Result | Call to Wajub |
|---|---|---|
| While the payment loads, before the session answered | .cancelled, .dismissed | None. Once it answered, its status decides at once: processing resolves .processing |
| Before any attempt | .cancelled, .dismissed, with the cancel's redirectURL | POST /pay/cancel, unless cancelOnDismiss is false |
| While an attempt runs, or the payer approves, dials a code or confirms a step | .processing | None |
| On the failure screen | .failed with the error shown, onCancel not called | POST /pay/cancel when the payment can still be cancelled and cancelOnDismiss is on. Not after unsupported_action or redirect_unavailable, when the payment may still run |
| After the success screen | .complete | None |
The sheet cancels only a payment that has loaded, whose last known status is pending, and with no
attempt undecided. An attempt is undecided from the press of Pay until a definitive answer: a
decline, a step or 3-D Secure failed for good, a refused card session, or a pending status with a
new refusal code. After such a refusal the payment is pending again and can be cancelled.
Cancel payment is offered on the method list, on a form and on a failure the payer can retry,
never while an attempt is undecided. It cancels the payment for good and resolves .cancelled with
.payerCancelled. A cancel the API refuses reads the payment's
status and resolves with what it says. The full rules are on
Mobile SDKs.
Appearance
PresentOptions.appearance takes Wajub.js 1.8's appearance object, key for key, so your web
Components and the native sheet share one style.
options.appearance = Appearance(
theme: .stripe, // presets: .stripe, .night, .flat, .noPreset
colorScheme: .auto, // .light, .dark, .auto (follows the phone, live)
variables: AppearanceVariables(
colorPrimary: "#0F766E",
fontFamily: "Poppins", // used when your app ships it
borderRadius: "12px"
),
buttonTextColor: "#FFFFFF"
)- Arguments follow the declaration order of the initializers, as Swift requires: in
AppearanceVariables,fontFamilycomes beforeborderRadius. - Wajub.js's
"none"is.noPresetforthemeand.noShadowforshadow, so neither can be read as the optional's ownnone. - The keys are Wajub.js's:
theme,colorScheme,inherit,variables(sixteen tokens,colorPrimarytospacingGridColumn) and the top-levelprimaryColor,secondaryColor,backgroundColor,fontFamily,borderRadius,buttonTextColor,inputBackgroundColor,inputBorderColor,textMutedColor,successColor,errorColor,shadow,labelsanddisableAnimations. Values are strings, as on the web, and a value that does not parse is ignored. - The object your web checkout already uses can be read as is:
Appearance(json: try JSONValue.parse(jsonString)). Unknown keys,rulesincluded, are ignored. inherit: truereads the presenting screen: itstintColoras the primary colour,systemBackground,label,secondaryLabelandseparatorresolved for its appearance, and its light or dark style. A colour is kept only when it contrasts enough with the background.- Per token, the first value set wins:
variables, the top-level keys, the preset, the inherited value, the merchant's branding from the Dashboard, then Wajub's default palette. - The merchant's branding fills what you leave unset: colours, radius, font (used only when your app ships it), and the logo and business name in the header. A white-label merchant hides "Secured by Wajub".
disableAnimations: true, or Reduce Motion on the phone, stops the skeleton's pulse and the cross-fades.
The scheme rules and the default palettes are on Mobile SDKs, the keys themselves on Appearance.
Mobile Money
One form covers every operator: the country, the number with its dial code, formatted as it is
typed, and the operators of the session, an aggregate such as cm.mobile listed one operator per
line. The operator the number belongs to is preselected until the payer picks one by hand, and a
number of another offered operator is refused before any call. Wave and the other wallets that
take any number are never refused. The number goes out in E.164, and Côte d'Ivoire keeps its
leading 0.
After Pay, the sheet shows "Approve the payment on your phone" with the number and the operator's instruction, and follows the payment until it settles.
MobileMoneyOptions | Default | What it does |
|---|---|---|
country | nil | The country to start on, ISO-2. Without it, the payer's country when a channel covers it |
countryLocked | false | Hides the country selector, when country is offered |
operatorMode | .choose | .choose lists the operators. .detect shows the detected operator as a badge in the number field, and the list only when the number does not settle the operator or the payer opens it |
Operator logos load from their https URL. A logo that is missing or does not load as an image,
an SVG for instance, is drawn as the operator's brand badge.
For your own screens, MobileMoneyData.shared exposes the same rules: nationalDigits(_:country:),
isValid(_:country:), detectOperator(_:country:), e164(_:country:), formatAsYouType(_:) and
badge(operatorId:fallbackName:). The behaviour shared by every SDK is on
Mobile SDKs.
Cards
The provider behind the card channel decides the screen. The sheet reads it from the session's SDK configuration.
| Card routed to | In the sheet |
|---|---|
| Paystack, Flutterwave or Adyen (a client session) | The contact fields the provider still needs, then its page in SFSafariViewController, verified when the payer comes back |
| A provider's own page (CinetPay, Kkiapay, Mollie…) | The provider's required_fields, then its page in SFSafariViewController |
| Stripe | Pay by card through your CardCollector. Without one, the card is not offered |
| Sandbox | The test-card form. Card numbers never leave the device in live |
A one-tap wallet such as Djamo, and a Mobile Money page such as Wave's, is handed to iOS with
UIApplication.open, so the wallet's own app opens. When the payer comes back, Safari closed or
the app back in the foreground, the sheet reads the payment's status at once. While a page is away,
Continue payment reopens it.
Stripe through your own collector
Only Stripe's own SDK can create a Stripe pm_…, and the package does not depend on stripe-ios.
Implement CardCollector in your app: the sheet calls it when the payer taps Pay by card, then
sends the pm_… it returns.
import StripePaymentsUI
import WajubMobile
final class StripeCardCollector: CardCollector {
@MainActor
func collectPaymentMethod(_ request: CardCollectionRequest) async throws -> String? {
STPAPIClient.shared.publishableKey = request.publishableKey
// CardEntryScreen is your own screen with an STPPaymentCardTextField, presented over the sheet.
guard let card = await CardEntryScreen.present(amount: request.amount, currency: request.currency) else {
return nil // the payer backed out
}
let paymentMethod = try await STPAPIClient.shared.createPaymentMethod(with: card.paymentMethodParams)
return paymentMethod.stripeId
}
}
options.cardCollector = StripeCardCollector()CardCollectionRequest carries publishableKey (the payment's own Stripe key, from the session),
amount, currency, customerEmail, customerName and language, the sheet's. Return nil when
the payer backs out: the sheet stays on the card form and nothing is charged. A thrown error is
shown on the card form: a WajubError as it is, any other error as validation_error, with the
error's own description when it has one (Stripe's errors do), else "Check the information
entered.", and its text kept in apiMessage. From the tap on Pay by card, a close resolves .processing
and nothing is sent once the sheet is gone.
Confirmation steps
Some card providers ask the payer for more after the first submit. Each step has its own screen, with the attempts left in the error after a wrong answer and Resend code offered 60 seconds after the screen opens.
| State | Screen |
|---|---|
OTP_REQUIRED | The code the payer received |
PIN_REQUIRED | The card PIN |
PHONE_REQUIRED | A phone number to confirm |
BIRTHDAY_REQUIRED | A date of birth |
ADDRESS_REQUIRED | A billing address |
3DS_REQUIRED | In sandbox, the 3-D Secure code sent by email, with its expiry countdown. In live, 3-D Secure in Safari |
A wrong code keeps the screen. A step that expires or runs out of attempts ends on the failure screen, and Try again goes back to choosing a method. The codes are on Mobile SDKs.
Errors
Every failure is a WajubError, a struct conforming to Error and LocalizedError, whose
errorDescription is message.
| Field | What it is for |
|---|---|
type | WajubErrorType: .apiError, .authenticationError, .invalidRequestError, .paymentError, .rateLimitError, with Wajub.js's names as raw values |
code | A code of the catalogue, listed as constants in WajubError.Code, or the API's own code passed through |
message | For the payer, in the sheet's language |
declineCode | On a decline (402), the provider's code |
retryable | Whether Try again can work |
param | The field at fault, data.phone for instance |
httpStatus, correlationId | The HTTP status and the response's X-Request-Id. Quote the second to support |
retryAfterSeconds | On rate_limited |
details | The API's field errors, one message per field |
blockReference | On shield_blocked |
apiMessage | The API's own English text, for your logs |
toJSON() gives the contract's shape, with snake_case keys.
case .failed(let error, _):
switch error.code {
case WajubError.Code.insufficientFunds:
showError(error.message) // retryable: another number or method
case WajubError.Code.shieldBlocked:
showError(error.message) // carries error.blockReference
default:
showError(error.message)
}Codes you will meet: insufficient_funds, payment_declined, payment_attempt_ended (refused,
nothing was charged), payment_failed, shield_blocked, merchant_restricted, rate_limited,
session_terminal_expired, unsupported_action, redirect_unavailable, security_check_failed.
The whole catalogue, with its English and French messages, is on
Mobile SDKs.
Your own UI
WajubMobile gives you the API client and the sheet's state machine, for an app that draws its
own payment screens.
WajubSession(sessionToken:language:) is the low-level client. It sends the contract's headers,
device signals, timeouts, retries and Idempotency-Key, and returns the API's shapes.
| Method | Call |
|---|---|
session() | GET /pay/session: amount, channels, branding, locale |
sdkConfig() | GET /pay/sdk-config: each channel's provider and flavour |
status() | GET /pay/status. Its reference is Wajub's payment id, not yours |
process(channel:data:idempotencyKey:) | POST /pay/process, with data._client added. Returns a ProcessOutcome |
startClientSession(fields:returnURL:restart:altcha:) | POST /pay/client-session, a card on the provider's page |
completeClientSession(id:) | POST /pay/client-session/complete, once the payer is back |
cancel() | POST /pay/cancel: the payment is cancelled for good |
verify(_:id:fields:), resend(_:id:) | A confirmation step, ConfirmStepKind .otp, .pin, .phone, .birthday or .address |
send3DSCode(threeDSId:), verify3DSCode(threeDSId:code:) | The sandbox 3-D Secure email code |
broadcastingAuth(socketId:channelName:) | The signature of the payment's private realtime channel |
import WajubMobile
func payHeadless(token: String) async throws {
let session = WajubSession(sessionToken: token)
let payment = try await session.session()
print(payment.transaction.amountTotal, payment.channels.map(\.slug))
let outcome = await session.process(
channel: "cm.mobile",
data: ["phone": "+237677123456", "country": "CM", "operator": "mtn"]
)
switch outcome {
case .complete(let transaction):
print("paid", transaction.id)
case .processing(_, let instruction, let kind):
// .approval, .ussd or .pending: follow GET /pay/status, never show a failure.
print(kind, instruction ?? "")
case .requiresAction(let required):
if required.action == .confirmOTP, let stepId = required.stepId {
let answer = try await session.verify(.otp, id: stepId, fields: ["code": "123456"])
print(answer)
}
case .failed(let error, _):
print(error.code, error.message)
}
}process generates a new Idempotency-Key unless you pass one, and its automatic retries reuse
it. An answer that never came, or a 5xx, comes back as .processing with kind .pending: watch
the status, never show it as a failure.
Checkout.present(_:presenter:browser:) runs the whole checkout under your own UI, with the same
rules as WajubCheckout.present: one sheet at a time, and a result once your sheet is gone. It
creates the CheckoutController, the sheet's state machine, from your options, hands it to your
CheckoutPresenting.present(_:), starts it, and once the checkout settles calls your
dismiss(_:completion:) and resolves with the CheckoutResult.
- Your presenter draws the controller's
screen(aCheckoutScreen), withstate,methods,mobileMoney,cardForm,fieldErrors,bannerand the rest, redraws ononChange, and wires your buttons to its actions:selectMethod,setPhone,pickOperator,submitMobileMoney,setCardField,submitCard,payWallet,submitStep,submitThreeDSCode,continuePayment,tryAgain,cancelPayment,dismiss,appWillEnterForeground,hostGoneand the others. When your sheet cannot be shown, callcontroller.hostGone(). Calldismiss'scompletiononce your sheet is gone. - Your
BrowserOpeningopens provider pages:.inApp(card pages, 3-D Secure, client sessions) in anSFSafariViewController, callingonCloseonce it is closed, and.external(a wallet's page) withUIApplication.open. It returnsfalsewhen nothing can open the page. - Both
completionandonCloseare@MainActor @Sendable, so they can go into system completion handlers in a Swift 6 app.
import Foundation
import WajubMobile
@MainActor
final class MySheet: CheckoutPresenting {
private var controller: CheckoutController?
func present(_ controller: CheckoutController) {
self.controller = controller
controller.onChange = { [weak self] in self?.render() }
// Show your sheet; its buttons call the controller's actions.
}
func dismiss(_ controller: CheckoutController, completion: @escaping @MainActor @Sendable () -> Void) {
// Hide your sheet, then:
self.controller = nil
completion()
}
private func render() {
guard let controller else { return }
print(controller.state?.rawValue ?? "loading") // draw controller.screen
}
}
@MainActor
final class MyBrowser: BrowserOpening {
func open(_ url: URL, mode: BrowserMode, onClose: @escaping @MainActor @Sendable () -> Void) async -> Bool {
// .inApp: present an SFSafariViewController and call onClose() once it is closed.
// .external: return await UIApplication.shared.open(url).
false // nothing could open the page
}
}
@MainActor
func payWithMySheet(_ options: PresentOptions) async throws -> CheckoutResult {
try await Checkout.present(options, presenter: MySheet(), browser: MyBrowser())
}Without Checkout, you can also create CheckoutController(options:browser:) yourself, set its
onChange and onFinish, which receives the CheckoutResult once, when your sheet must close, and
call start().
Privacy
To score fraud as the web checkout does, every POST /pay/process sends Wajub a few device
signals, and every call names the SDK.
- The time zone, the first preferred language and the languages (
Accept-Language), the main screen's size in pixels and a colour depth of 24. - The SDK and the system,
wajub-ios/1.0.0 (ios 18.1), inX-Wajub-Clientand at the end of theUser-Agent, after your app's name and version. - An install fingerprint: the SHA-256 of a random identifier the SDK creates on first use. It is
not derived from a hardware or advertising identifier. It is kept in a file of the app's
Application Support directory,
com.wajub.mobile/install-id, excluded from backups, and it goes with the app when the app is uninstalled.
WajubMobile ships a privacy manifest, PrivacyInfo.xcprivacy, which Xcode adds to your app's
privacy report (Product › Archive, then Generate Privacy Report in the Organizer). It
declares:
- No tracking, and no tracking domains.
- One required-reason API, the system boot time (
ProcessInfo.systemUptime), with reason35F9.1: it times the SDK's waits on the phone and never leaves it. NoUserDefaults: the install identifier is the file above. - Collected data, all linked to the user, not used for tracking, for App Functionality: name, email address, phone number, physical address, payment info, device ID and other data.
The manifest does not fill in App Store Connect for you. Declare what the SDK sends to Wajub, your payment processor, under App Privacy as data your app collects, linked to the user's identity, not used for tracking, for App Functionality, together with what your app collects on its own.
| App Store Connect | What the SDK sends |
|---|---|
| Contact Info, Name | First and last name, or the 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 number 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_… your collector returns, a card PIN a provider asks for, and test cards in sandbox. Card numbers never leave the device in live |
| Identifiers, Device ID | The SHA-256 of the random install identifier, neither the advertising identifier nor identifierForVendor, for fraud prevention |
| Other Data | A date of birth when a provider asks for it, and the device's time zone, language and screen size, for fraud prevention |
A card number typed on a provider's page in Safari goes to that provider, not through the SDK. The signals every SDK sends are on Mobile SDKs.
Before 1.0
There is no earlier version to migrate from. Until this package, an iOS app collected a Wajub payment through the hosted checkout in Safari, or through the Flutter or React Native SDK.
Moving a native app to the sheet changes nothing on your server. POST /payments returns the
authorization_token the sheet takes next to the authorization_url the hosted checkout opens,
and the same payment.succeeded webhook confirms the payment.
Testing
The API origin is a constant, so there is no base URL to redirect. A payment your server creates with a sandbox key puts the whole flow in sandbox, sheet included, and the sheet shows a "Test mode" badge.
- Mobile Money test numbers are an operator prefix and seven digits ending with the scenario:
000000success,000001insufficient funds,000002failure,000003timeout,000004cancelled,+237670000000for instance. Sandbox prefixes do not follow real numbering plans, so the sheet only warns about an operator mismatch there. - The card form takes Wajub's test cards, and
4000056655665556asks for the 3-D Secure email code.
Every number and card is on Test scenarios.
Related pages
- Mobile SDKsThe contract all four share.
- Create a paymentThe server call that produces the token.
- WebhooksThe confirmation that ships the order.
- AppearanceThe appearance object the sheet shares with Wajub.js.
- FlutterThe same contract, in Dart.
- React NativeThe same contract, in TypeScript.
- AndroidThe same contract, in Kotlin and Compose.