Skip to content

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 · GA

Swift 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:

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"),
    ]),
]
RequirementValue
iOS15.0 or later
Swift5.9 or later, so Xcode 15 or later
DependenciesNone. Stripe is optional and lives in your app, see Cards
TargetAn 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.

ProductWhat it holds
WajubMobileFoundation 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
WajubMobileUIThe 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.

Your own endpoint, your own client
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.

The whole integration
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.

OptionTypeDefaultWhat it does
sessionTokenStringRequiredThe payment's authorization_token
localeString?nil"fr" or "en". Without it, the session's language, else the device's. A tag starting with fr is French, anything else English
appearanceAppearance?nilThe sheet's look, see Appearance
loaderLoaderStyle.skeleton.skeleton, pulsing placeholder blocks, or .spinner
readyTimeoutInt, milliseconds15000Past it, a sheet still loading shows "We couldn't load the payment" with Retry and reports ready_timeout to onLoadError. 0 disables it
returnURLString?nilAn 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
mobileMoneyMobileMoneyOptionsMobileMoneyOptions()country to start on (ISO-2), countryLocked to hide the country selector, operatorMode .choose (the operator list, default) or .detect
realtimeRealtimeOptions?nilRealtimeOptions(enabled:host:port:tls:): another Pusher-protocol host, a self-hosted Reverb or Soketi, or enabled: false for polling only
cancelOnDismissBooltrueClosing the sheet before any attempt cancels the payment. false leaves it open so you can present it again
autoCloseSuccessInt, milliseconds3000Before the success screen closes itself. 0 keeps it
callbacksCheckoutCallbacksCheckoutCallbacks()See Callbacks and states
cardCollectorCardCollector?nilYour Stripe card collector. Without one, a card routed to Stripe is not offered
Options in the initializer
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.

CallbackPayloadWhen
onReadyReadyEvent: 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
onStateChangeStateChangeEvent: state, method (mobile_money, card, wallet or unknown)After onReady, then on every state change
onLoadErrorWajubErrorThe sheet cannot offer a payment
onPaymentStartedPaymentStartedEvent: method, channelThe first 2xx of an attempt. From now on, closing the sheet resolves .processing
onRedirectRedirectEvent: url, method (always "GET")Right before a provider page opens
onSuccessPaymentSuccessThe payment succeeded, with method "unknown" when it was already paid at load
onErrorWajubErrorEvery failure the payer sees, retryable ones included. Field validation is not reported
onCancelCancelEvent: reason, redirectURLThe sheet closes as .cancelled
onCompleteCheckoutResultThe sheet closes, with what present resolves with
Callbacks in the initializer
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.

CaseWhen
.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 pending when 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 with onLoadError when it failed, was cancelled or expired, and the approval or waiting screen it was on, watched, when it is still processing.
  • 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 than no_payment_method.
  • A payment request that gets no answer, a 5xx included, 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 same Idempotency-Key.
  • A 400 makes the sheet read the status: an outcome is shown, a processing payment or a status that cannot be read is watched, and a payment still pending fails with not_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 closesResultCall to Wajub
While the payment loads, before the session answered.cancelled, .dismissedNone. Once it answered, its status decides at once: processing resolves .processing
Before any attempt.cancelled, .dismissed, with the cancel's redirectURLPOST /pay/cancel, unless cancelOnDismiss is false
While an attempt runs, or the payer approves, dials a code or confirms a step.processingNone
On the failure screen.failed with the error shown, onCancel not calledPOST /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.completeNone

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.

A preset, a scheme and a few variables
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, fontFamily comes before borderRadius.
  • Wajub.js's "none" is .noPreset for theme and .noShadow for shadow, so neither can be read as the optional's own none.
  • The keys are Wajub.js's: theme, colorScheme, inherit, variables (sixteen tokens, colorPrimary to spacingGridColumn) and the top-level primaryColor, secondaryColor, backgroundColor, fontFamily, borderRadius, buttonTextColor, inputBackgroundColor, inputBorderColor, textMutedColor, successColor, errorColor, shadow, labels and disableAnimations. 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, rules included, are ignored.
  • inherit: true reads the presenting screen: its tintColor as the primary colour, systemBackground, label, secondaryLabel and separator resolved 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.

MobileMoneyOptionsDefaultWhat it does
countrynilThe country to start on, ISO-2. Without it, the payer's country when a channel covers it
countryLockedfalseHides 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 toIn 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
StripePay by card through your CardCollector. Without one, the card is not offered
SandboxThe 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.

A CardCollector built on Stripe
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.

StateScreen
OTP_REQUIREDThe code the payer received
PIN_REQUIREDThe card PIN
PHONE_REQUIREDA phone number to confirm
BIRTHDAY_REQUIREDA date of birth
ADDRESS_REQUIREDA billing address
3DS_REQUIREDIn 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.

FieldWhat it is for
typeWajubErrorType: .apiError, .authenticationError, .invalidRequestError, .paymentError, .rateLimitError, with Wajub.js's names as raw values
codeA code of the catalogue, listed as constants in WajubError.Code, or the API's own code passed through
messageFor the payer, in the sheet's language
declineCodeOn a decline (402), the provider's code
retryableWhether Try again can work
paramThe field at fault, data.phone for instance
httpStatus, correlationIdThe HTTP status and the response's X-Request-Id. Quote the second to support
retryAfterSecondsOn rate_limited
detailsThe API's field errors, one message per field
blockReferenceOn shield_blocked
apiMessageThe API's own English text, for your logs

toJSON() gives the contract's shape, with snake_case keys.

Reading a failed result
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.

MethodCall
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
A payment without the sheet
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 (a CheckoutScreen), with state, methods, mobileMoney, cardForm, fieldErrors, banner and the rest, redraws on onChange, and wires your buttons to its actions: selectMethod, setPhone, pickOperator, submitMobileMoney, setCardField, submitCard, payWallet, submitStep, submitThreeDSCode, continuePayment, tryAgain, cancelPayment, dismiss, appWillEnterForeground, hostGone and the others. When your sheet cannot be shown, call controller.hostGone(). Call dismiss's completion once your sheet is gone.
  • Your BrowserOpening opens provider pages: .inApp (card pages, 3-D Secure, client sessions) in an SFSafariViewController, calling onClose once it is closed, and .external (a wallet's page) with UIApplication.open. It returns false when nothing can open the page.
  • Both completion and onClose are @MainActor @Sendable, so they can go into system completion handlers in a Swift 6 app.
Your own sheet and browser
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), in X-Wajub-Client and at the end of the User-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 reason 35F9.1: it times the SDK's waits on the phone and never leaves it. No UserDefaults: 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 ConnectWhat the SDK sends
Contact Info, NameFirst and last name, or the 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 number 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_… 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 IDThe SHA-256 of the random install identifier, neither the advertising identifier nor identifierForVendor, for fraud prevention
Other DataA 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: 000000 success, 000001 insufficient funds, 000002 failure, 000003 timeout, 000004 cancelled, +237670000000 for 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 4000056655665556 asks for the 3-D Secure email code.

Every number and card is on Test scenarios.

What did you think of this content?