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>
<!-- Standalone Scan to Pay app (historical iOS registration) -->
<string>masterpass.capitec.scheme</string>
<string>masterpass.absa.scheme</string>
<string>masterpass.sbsa.scheme</string>
<string>masterpass.nedbank.scheme</string>
<string>masterpass.vodapay.scheme</string>
<string>masterpass.spenda.scheme</string>
<string>nedbank</string>
</array>Without this list, UIApplication.canOpenURL(_:) returns false for any wallet scheme and your launch attempt will silently fail.
FNB / RMB use an HTTPS universal link, not a custom scheme. Do not put the complete FNB URL in LSApplicationQueriesSchemes; open the universal link directly as shown below.
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"
let unreserved = CharacterSet(
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
)
guard let encodedReturn = returnUrl.addingPercentEncoding(
withAllowedCharacters: unreserved) 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
}
}
// Standalone Scan to Pay app on iOS
launchScanToPayWallet(code: "0123456789", walletScheme: "masterpass.capitec.scheme")- (void)launchScanToPayWalletWithCode:(NSString *)code walletScheme:(NSString *)walletScheme {
NSString *returnUrl = @"your.app.scheme://merchant.com";
NSString *encodedReturn = [returnUrl
stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet alphanumericCharacterSet]];
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 does not show a system wallet chooser. If you support more than one wallet, present a wallet picker in your own app and launch the selected wallet's specific scheme.
Use canOpenURL on each scheme to hide or disable wallets the customer doesn't have installed.
let wallets: [(name: String, scheme: String)] = [
("Scan to Pay / Capitec", "masterpass.capitec.scheme"),
("ABSA", "masterpass.absa.scheme"),
("Standard Bank", "masterpass.sbsa.scheme"),
("Nedbank", "masterpass.nedbank.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 / RMB universal link
FNB and RMB use a universal link rather than a custom scheme. Supply the payment code and return URL using the exact query parameters below:
https://www.online.fnb.co.za/banking/mobileservices?codetype=masterpassdeeplink&urlencodedqr={URL-encoded 10-digit code}&urlencodedcallbackurl={URL-encoded return URL}
| Query parameter | Required value |
|---|---|
codetype | The fixed value masterpassdeeplink |
urlencodedqr | The URL-encoded 10-digit code returned by POST /code/create |
urlencodedcallbackurl | Your app's URL-encoded return URL |
Example:
https://www.online.fnb.co.za/banking/mobileservices?codetype=masterpassdeeplink&urlencodedqr=0123456789&urlencodedcallbackurl=your.app.scheme%3A%2F%2Fmerchant.comPercent-encode each value once. Detect FNB / RMB separately and open this HTTPS URL when the customer selects either wallet.
Receive the return
When the customer finishes, the wallet opens your.app.scheme://merchant.com?status=SUCCESS&ref=.... 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 ref = queryItems.first(where: { $0.name == "ref" })?.value
// Use your own order context plus ref to ask your backend for the authoritative outcome.
// Never release goods based on this return URL alone.
}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
