For iOS
Comparison of iOS WebView
| Capability | WKWebView | SFSafariViewController |
|---|---|---|
| Embed inside your own UI | Yes | No (modal browser) |
Receive postMessage from Payment capture page in native code | Yes | No — use successUrl / cancelUrl redirects |
| Apple Pay | Yes (iOS 16+) | Yes (all supported iOS versions) |
| Google Pay | Yes | Limited / not supported officially |
| Initial setup effort | Moderate | Minimal |
SFSafariViewController
The simplest integration. iOS provides the full browser environment, so Apple Pay, 3DS and external scheme redirects all work without extra setup. successUrl and cancelUrl is mandatory to redirects users back to the app.
https://hosted-global-sandbox.ezypay.com/paymentmethod/embed?token=eyJhbGciOi...&countryCode=AU&successUrl=https%3A%2F%2Fexample.com%2Fsuccess&cancelUrl=https%3A%2F%2Fexample.com%2Fcancel
import SafariServices
let config = SFSafariViewController.Configuration()
config.entersReaderIfAvailable = false
config.barCollapsingEnabled = false
let vc = SFSafariViewController(url: pcpURL, configuration: config)
vc.dismissButtonStyle = .done
present(vc, animated: true)When Payment capture page finishes or is cancelled, it redirects to the successUrl / cancelUrl configured. Use a custom URL scheme or universal link to return control to your app, and handle it in your AppDelegate / SceneDelegate.
WKWebView
Use this integration when Payment capture page needs to be rendered inside a native screen. Apple Pay within WKWebView requires iOS 16 or later.
Required Setup
-
Set both
WKNavigationDelegateandWKUIDelegate. These delegates must:- Forward non-HTTP(S) navigations (
tel:,mailto:, wallet schemes, bank deep links) toUIApplication.shared.open(_:). - Handle window.open() with a hybrid rule:
- Google Pay popups (and about:blank bootstrap popups) stay inside a WKWebView overlay.
- Other popup URLs (for example many 3DS flows) open in SFSafariViewController.
- Forward non-HTTP(S) navigations (
-
Install a JavaScript bridge to receive the payment method token via
window.postMessage(...). Bridge behavior (particularly with Apple Pay) can vary by iOS version and device
Minimal WKWebView Setup
import WebKit
final class PaymentWebViewCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
private weak var mainPCPWebView: WKWebView?
}
let coordinator = PaymentWebViewCoordinator()
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []
let webView = WKWebView(frame: .zero, configuration: config)
let currentUserAgent = webView.value(forKey: "userAgent") as? String ?? ""
coordinator.mainPCPWebView = webView // keep a reference for popup close handling
webView.customUserAgent = currentUserAgent + " GOOGLE_PAY_SUPPORTED"
webView.navigationDelegate = coordinator
webView.uiDelegate = coordinator
webView.load(URLRequest(url: pcpURL))Forwarding Non-Web Schemes
WKWebView blocks custom URL schemes by default. Intercept them in decidePolicyFor and hand off to the OS. The base handler below covers all non-web schemes; add payment-method-specific branches as needed.
func webView(_ webView: WKWebView,
decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if let url = action.request.url, shouldForwardToExternalApp(url) {
forwardToExternalApp(url)
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
private func shouldForwardToExternalApp(_ url: URL) -> Bool {
let scheme = url.scheme?.lowercased() ?? ""
return scheme != "https" && scheme != "http" && scheme != "about" && !scheme.isEmpty
}
private func forwardToExternalApp(_ url: URL) {
// Payment-method-specific branches go here (see GCash example).
UIApplication.shared.open(url)
}Handling window.open (3DS Popups)
window.open (3DS Popups)When Payment capture page opens a popup, use WKWebView overlay forwallet authorisation, Google Pay and about:blank bootstrap popups. Use SFSafariViewController for other popup URLs that does not work with WKWebView.
func webView(_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for action: WKNavigationAction,
windowFeatures: WKWindowFeatures) -> WKWebView? {
guard action.targetFrame == nil else { return nil }
if shouldUseOverlayPopup(action.request.url) {
let popup = WKWebView(frame: webView.frame, configuration: configuration)
popup.translatesAutoresizingMaskIntoConstraints = false
popup.navigationDelegate = webView.navigationDelegate // required for deep links inside popup
popup.uiDelegate = webView.uiDelegate
webView.addSubview(popup)
NSLayoutConstraint.activate([
popup.leadingAnchor.constraint(equalTo: webView.leadingAnchor),
popup.topAnchor.constraint(equalTo: webView.topAnchor),
popup.trailingAnchor.constraint(equalTo: webView.trailingAnchor),
popup.bottomAnchor.constraint(equalTo: webView.bottomAnchor),
])
return popup
}
guard let url = action.request.url else { return nil }
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = scene.windows.first?.rootViewController {
root.present(SFSafariViewController(url: url), animated: true)
}
return nil
}
func webViewDidClose(_ webView: WKWebView) {
// Remove popup overlays only — keep the main PCP embed page.
guard webView !== mainPCPWebView else { return }
webView.removeFromSuperview()
}
private func shouldUseOverlayPopup(_ url: URL?) -> Bool {
guard let url else { return true } // nil popup URL
if url.scheme?.lowercased() == "about" { return true } // about:blank bootstrap
return false
}Use JavaScript Bridge for postMessage Handling
postMessage HandlingThis setup sends the create postMessage to Payment capture page and receives the payment method token result.
Which bridge mode to use:
| Bridge mode | Installed when… | Use when… |
|---|---|---|
| Early bridge | Before Payment capture page loads | Default — try this first |
| Late-bind bridge | User taps your app’s Submit button | Early bridge fails and you use your app Submit button |
| Poll buffer bridge | Payment capture page finishes initial loading | Early bridge fails and you use Payment capture page Submit button (submitbutton=true) |
Early / Late-bind Bridge
Coordinator updates
Add WKScriptMessageHandler conformance and the receive handler to PaymentWebViewCoordinator:
final class PaymentWebViewCoordinator: NSObject,
WKScriptMessageHandler,
WKNavigationDelegate,
WKUIDelegate {
private weak var mainPCPWebView: WKWebView?
private var bridgeInstalled = false
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard message.name == "ezypay" else { return }
let body: String
if let str = message.body as? String {
body = str
} else if
let data = try? JSONSerialization.data(withJSONObject: message.body),
let str = String(data: data, encoding: .utf8) {
body = str
} else {
return
}
handlePCPMessage(body) // see section 4
}
}Declare script
(function () {
if (window.__pcpBridgeInstalled) {
return;
}
window.__pcpBridgeInstalled = true;
function send(name, payload) {
try {
window.webkit.messageHandlers[name].postMessage(payload);
} catch (_) {}
}
window.addEventListener("message", function (evt) {
send(
"ezypay",
typeof evt.data === "string"
? evt.data
: JSON.stringify(evt.data)
);
});
})();Early Bridge specific update
In your WKWebView setup, before webView.load(...):
let ucc = config.userContentController
ucc.add(coordinator, name: "ezypay")
ucc.addUserScript(
WKUserScript(
source: bridgeScript,
injectionTime: .atDocumentStart,
forMainFrameOnly: false
)
)No further coordinator changes are needed—messages arrive via:
userContentController(_:didReceive:)Late-bind Bridge specific update
Add to PaymentWebViewCoordinator:
func installBridgeAndCreate(
webView: WKWebView,
origin: String
) {
if !bridgeInstalled {
bridgeInstalled = true
webView.configuration.userContentController.add(
self,
name: "ezypay"
)
webView.evaluateJavaScript(bridgeScript)
}
webView.evaluateJavaScript(
"window.postMessage({actionType: 'create'}, '\(origin)');"
)
}
func webView(
_ webView: WKWebView,
didStartProvisionalNavigation navigation: WKNavigation!
) {
bridgeInstalled = false // re-install after navigation (e.g. 3DS)
}Call from your native Create button:
coordinator.installBridgeAndCreate(
webView: webView,
origin: "https://hosted-global-staging.ezypay.com"
)origin must be the Payment capture page host only, not the full URL with query parameters.
Poll Buffer Bridge
No WKScriptMessageHandler is required.
Add to PaymentWebViewCoordinator:
private var pollTimer: Timer?
func webView(
_ webView: WKWebView,
didFinish navigation: WKNavigation!
) {
webView.evaluateJavaScript(bufferScript)
startPolling(webView: webView)
}
private func startPolling(webView: WKWebView) {
pollTimer?.invalidate()
pollTimer = Timer.scheduledTimer(
withTimeInterval: 0.25,
repeats: true
) { [weak self, weak webView] _ in
guard let self, let webView else { return }
webView.evaluateJavaScript(
"JSON.stringify(window.__pcpMessages.splice(0))"
) { result, _ in
guard
let json = result as? String,
let data = json.data(using: .utf8),
let messages = try? JSONSerialization.jsonObject(with: data) as? [String],
!messages.isEmpty
else {
return
}
for message in messages {
self.handlePCPMessage(message) // see section 4
}
}
}
}bufferScript — inject at didFinish:
(function () {
if (window.__pcpBufferInstalled) {
return;
}
window.__pcpBufferInstalled = true;
window.__pcpMessages = [];
window.addEventListener("message", function (e) {
try {
window.__pcpMessages.push(
typeof e.data === "string"
? e.data
: JSON.stringify(e.data)
);
} catch (_) {}
});
})();Payment Method-specific Setup
Apple Pay
- Apple Pay merchant registration and domain verification are handled by Ezypay
No custom Apple Pay Merchant ID orcom.apple.developer.in-app-paymentsentitlement is required - Payment capture page must be served from a verified domain.
- Device requirements:
- A card is provisioned in Apple Wallet
- The user is signed in to iCloud
- Apple Pay does not work reliably on a simulator.
- Inside
WKWebView, Apple Pay is available from iOS 16. - For earlier iOS versions, use
SFSafariViewController. - If using Early Bridge mode, check whether:
ApplePaySession.canMakePayments()returnsfalsein the JavaScript console. If it does, try:- Late-bind Bridge, or
- Poll Buffer Bridge.
Google Pay
WKWebView embedding Payment capture page
Append GOOGLE_PAY_SUPPORTED to the WKWebView user agent so that Google Pay SDK works.
let currentUserAgent =
webView.value(forKey: "userAgent") as? String ?? ""
webView.customUserAgent =
currentUserAgent + " GOOGLE_PAY_SUPPORTED"Handling window.open popups
Extend shouldUseOverlayPopup:
private func shouldUseOverlayPopup(_ url: URL?) -> Bool {
guard let url else { return true }
if url.scheme?.lowercased() == "about" {
return true
}
let host = url.host?.lowercased() ?? ""
return
host == "pay.google.com" ||
host.hasSuffix(".pay.google.com") ||
host == "payments.google.com" ||
host.hasSuffix(".payments.google.com") ||
host == "accounts.google.com"
}GCash / 3DS Setup
Forwarding non-web schemas
Extend forwardToExternalApp and update Info.plist.
Scenarios
| Scenario | What your app must do |
|---|---|
| User taps Open GCash app in the auth page | Cancel web navigation and call UIApplication.shared.open(gcashURL) |
| GCash not installed | Show an in-app message and open the App Store (itms-apps://itunes.apple.com/app/id520020791) |
Test wallet authorization on a physical iPhone with GCash installed. The iOS Simulator cannot open
gcash://or App Store links reliably.
private func forwardToExternalApp(_ url: URL) {
let scheme = url.scheme?.lowercased() ?? ""
if scheme == "gcash" {
let gcashInstalled =
UIApplication.shared.canOpenURL(
URL(string: "gcash://")!
)
// requires Info.plist to be updated
if gcashInstalled {
UIApplication.shared.open(url)
} else {
showGCashNotInstalledMessage()
openGCashInAppStore()
}
return
}
UIApplication.shared.open(url)
}private func openGCashInAppStore() {
let appStoreURL =
URL(string: "itms-apps://itunes.apple.com/app/id520020791")!
let appStoreWebURL =
URL(string: "https://apps.apple.com/ph/app/gcash/id520020791")!
UIApplication.shared.open(appStoreURL) { success in
if !success {
UIApplication.shared.open(appStoreWebURL)
}
}
}Info.plist update
<key>LSApplicationQueriesSchemes</key>
<array>
<string>gcash</string>
<!-- Add other wallet schemes you support -->
</array>Closing overlay popups
Overlay popups (Google Pay and about:blank bootstrap popups) are closed by implementing webViewDidClose. When the popup calls window.close(), the popup is removed from the view hierarchy and the main Payment capture page WKWebView is left in place.
Popups opened in SFSafariViewController do not need webViewDidClose — the user dismisses Safari with the Done button.
Receiving messages from Payment capture page
After a successful payment method capture, the page delivers the result to the host app via window.postMessage(...). A JavaScript bridge must be installed in the WKWebView so the native app can receive these messages.
This is called from:
| Bridge mode | Called from |
|---|---|
| Early / Late-bind | userContentController(_:didReceive:) |
| Poll buffer | startPolling drain loop |
Troubleshooting
- Use a new access token when loading the page.
- If Google Pay button does not appears ensures Mobile Safari user agent is correctly set
- The page loads without JavaScript console errors. If you see
ApplePaySessionis not available, review the Javascript Bridge setup. - Popup handling is hybrid in
WKWebViewmode, and after the popup the user is redirected back to Payment capture page:- Wallet authorization / Google Pay /
about:blankpopups open asWKWebViewoverlays. - Other popups (including many 3DS flows) open in
SFSafariViewController.
- Wallet authorization / Google Pay /
- After tapping the Apple Pay button and authorising in the sheet, tap Create. The native
WKScriptMessageHandlerreceives a message with"type": "success"and"purpose": "PAYMENT_METHOD_TOKEN". If it doesn't arrive, confirm the bridge is being installed beforewindow.postMessage({actionType: "create"})is sent.
Updated 6 days ago