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
Info.plistiOS 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:
| Detail | Why it matters |
|---|---|
| URL-encode the return URL | Without it, the : and / in your scheme break the wallet's URL parser. |
Check canOpenURL first | Returns false if the wallet isn't installed or you didn't declare the scheme in LSApplicationQueriesSchemes. |
| Declare every wallet scheme you want to query | Apple'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
- Parse the return status → Handling the response
- Same flow for Android → App-to-app on Android
- Receive the authoritative webhook on your backend → Webhooks
- Embed payments directly inside your app instead → In-App Payments
- Sandbox test the flow → Sandbox and test cards
Updated 4 days ago
