Comparison of iOS WebView

CapabilityWKWebViewSFSafariViewController
Embed inside your own UIYesNo (modal browser)
Receive postMessage from Payment capture page in native codeYesNo — use successUrl / cancelUrl redirects
Apple PayYes (iOS 16+)Yes (all supported iOS versions)
Google PayYesLimited / not supported officially
Initial setup effortModerateMinimal

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

  1. Set both WKNavigationDelegate and WKUIDelegate. These delegates must:

    • Forward non-HTTP(S) navigations (tel:, mailto:, wallet schemes, bank deep links) to UIApplication.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.
  2. 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)

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

This setup sends the create postMessage to Payment capture page and receives the payment method token result.

Which bridge mode to use:

Bridge modeInstalled when…Use when…
Early bridgeBefore Payment capture page loadsDefault — try this first
Late-bind bridgeUser taps your app’s Submit buttonEarly bridge fails and you use your app Submit button
Poll buffer bridgePayment capture page finishes initial loadingEarly 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 or com.apple.developer.in-app-payments entitlement 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()returns false in 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

ScenarioWhat your app must do
User taps Open GCash app in the auth pageCancel web navigation and call UIApplication.shared.open(gcashURL)
GCash not installedShow 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 modeCalled from
Early / Late-binduserContentController(_:didReceive:)
Poll bufferstartPolling 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 ApplePaySession is not available, review the Javascript Bridge setup.
  • Popup handling is hybrid in WKWebView mode, and after the popup the user is redirected back to Payment capture page:
    • Wallet authorization / Google Pay / about:blank popups open as WKWebView overlays.
    • Other popups (including many 3DS flows) open in SFSafariViewController.
  • After tapping the Apple Pay button and authorising in the sheet, tap Create. The native WKScriptMessageHandler receives a message with "type": "success" and "purpose": "PAYMENT_METHOD_TOKEN". If it doesn't arrive, confirm the bridge is being installed before window.postMessage({actionType: "create"}) is sent.


Did this page help you?