App-to-app on iOS

Launch the Scan to Pay wallet from your iOS app via URL scheme and receive the return via your registered scheme.

On iOS, app-to-app uses UIApplication.shared.open(_:) to launch the wallet via its custom URL scheme. You build the URL with the wallet's scheme + the 10-digit code + a URL-encoded return URL, and iOS routes it to the wallet app.


Prerequisites

1. Declare wallet schemes in Info.plist

iOS requires you to declare every URL scheme your app might open. Add a LSApplicationQueriesSchemes entry to your Info.plist listing all the wallet schemes you want to support:

<key>LSApplicationQueriesSchemes</key>
<array>
  <string>masterpass.absa.scheme</string>
  <string>masterpass.sbsa.scheme</string>
  <string>masterpass.nedbank.scheme</string>
  <string>masterpass.capitec.scheme</string>
  <string>masterpass.vodapay.scheme</string>
  <string>masterpass.spenda.scheme</string>
  <string>nedbank</string>
  <string>https://www.online.fnb.co.za/banking/mobileservices?codetype=masterpassdeeplink</string>
</array>

Without this list, UIApplication.canOpenURL(_:) returns false for any wallet scheme and your launch attempt will silently fail.

2. Register your own URL scheme

Define a scheme that the wallet uses to return to your app. Add CFBundleURLTypes:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLName</key>
    <string>com.merchant.example</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>your.app.scheme</string>
    </array>
  </dict>
</array>

3. Your backend creates a code

via POST /code/create — see Dynamic QR for the request shape. Your iOS app receives the 10-digit code from your backend.


Launch the wallet

import UIKit

func launchScanToPayWallet(code: String, walletScheme: String) {
    let returnUrl = "your.app.scheme://merchant.com"

    guard let encodedReturn = returnUrl.addingPercentEncoding(
              withAllowedCharacters: .urlQueryAllowed) else {
        return
    }

    let urlString = "\(walletScheme)://masterpass.oltio.co.za/\(code)/\(encodedReturn)"
    guard let url = URL(string: urlString) else { return }

    if UIApplication.shared.canOpenURL(url) {
        UIApplication.shared.open(url, options: [:], completionHandler: nil)
    } else {
        // Wallet not installed — fall back to QR display, or prompt to install
    }
}

// Usage
launchScanToPayWallet(code: "0123456789", walletScheme: "masterpass.absa.scheme")
- (void)launchScanToPayWalletWithCode:(NSString *)code walletScheme:(NSString *)walletScheme {
    NSString *returnUrl = @"your.app.scheme://merchant.com";
    NSString *encodedReturn = [returnUrl
        stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]];

    NSString *urlString = [NSString stringWithFormat:@"%@://masterpass.oltio.co.za/%@/%@",
                          walletScheme, code, encodedReturn];
    NSURL *url = [NSURL URLWithString:urlString];

    if ([[UIApplication sharedApplication] canOpenURL:url]) {
        [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
    }
}

Three things to get right:

DetailWhy it matters
URL-encode the return URLWithout it, the : and / in your scheme break the wallet's URL parser.
Check canOpenURL firstReturns false if the wallet isn't installed or you didn't declare the scheme in LSApplicationQueriesSchemes.
Declare every wallet scheme you want to queryApple's privacy policy limits canOpenURL to declared schemes from iOS 9 onwards.

Picking the right wallet

📘

iOS will not let your customer pick. If the customer has multiple Scan to Pay-enabled wallets installed, iOS opens the first one that responds — and you cannot programmatically pick. This is a platform constraint, not a Scan to Pay one.

Workaround: present a wallet picker in your own app before you launch, and use the scheme of whichever wallet the customer selected. Use canOpenURL on each scheme to dim the wallets the customer doesn't have installed.

let wallets: [(name: String, scheme: String)] = [
    ("ABSA",          "masterpass.absa.scheme"),
    ("Standard Bank", "masterpass.sbsa.scheme"),
    ("Nedbank",       "masterpass.nedbank.scheme"),
    ("Capitec",       "masterpass.capitec.scheme"),
    ("VodaPay",       "masterpass.vodapay.scheme"),
    ("Spenda",        "masterpass.spenda.scheme")
]

let installed = wallets.filter { wallet in
    UIApplication.shared.canOpenURL(URL(string: "\(wallet.scheme)://test")!)
}

Show only the installed wallets to keep your picker clean.

FNB universal link

FNB uses a universal link rather than a custom scheme. The URL is:

https://www.online.fnb.co.za/banking/mobileservices?codetype=masterpassdeeplink

Detect FNB separately and route through the universal link if your customer picks FNB.


Receive the return

When the customer finishes, the wallet opens your.app.scheme://merchant.com?status=SUCCESS&transactionId=.... iOS dispatches this to your app's URL handler:

@main
struct MerchantApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onOpenURL { url in
                    handleScanToPayReturn(url: url)
                }
        }
    }
}

func handleScanToPayReturn(url: URL) {
    guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
          let queryItems = components.queryItems else { return }

    let status        = queryItems.first(where: { $0.name == "status" })?.value
    let transactionId = queryItems.first(where: { $0.name == "transactionId" })?.value

    // Move UI to "Confirming..." and poll your backend for authoritative outcome
}
func application(_ app: UIApplication, open url: URL,
                 options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    handleScanToPayReturn(url: url)
    return true
}

The status values and what they mean are documented on Handling the response.

⚠️

The return URL is not proof of payment. It tells you the customer's flow ended; it doesn't tell you the bank approved the transaction. Always verify via your backend webhook before releasing goods. See App-to-app overview.


What's next