A reference for iOS developers selling Tebex packages inside their own app. It shows how to hand the shopper off to Tebex's hosted checkout, detect when they finish, and confirm the payment. You build no payment UI yourself.
This repo ships a working SwiftUI example (iOS 16+, no third party dependencies). To run it, see CONTRIBUTING.md.
- Create a basket via the Headless API and add packages to it.
- Open
pay.tebex.io/<basketIdent>in an in-app browser. - The shopper pays. Tebex redirects to your
complete_url(orcancel_url). - The app closes the browser and re-fetches the basket.
- The basket's
completeflag confirms the outcome.
Two pieces make this work, whichever browser you choose:
- Auto redirect. Create the basket with
complete_auto_redirect: trueso Tebex redirects on success instead of showing a "return to store" button. That redirect is the signal the app watches for. - Authoritative completion. A client side redirect is not proof of payment.
Always re-fetch the basket and read its
completeflag.
// Creating the basket (Headless API)
let body: [String: Any] = [
"complete_url": completeURL,
"cancel_url": cancelURL,
"complete_auto_redirect": true,
]The example ships both, selectable with Config.checkoutStrategy, so you can
compare them. ASWebAuthenticationSession is the preferred option: it is the
system Safari browser, so Apple Pay and 3DS work reliably and you write almost no
UI. SFSafariViewController is not an option here, as it cannot observe the
completion redirect needed to auto close.
ASWebAuthenticationSession (preferred) |
WKWebView |
|
|---|---|---|
| Browser chrome | System provided | You build it (including a Cancel button) |
| Apple Pay / 3DS | Robust (real Safari) | Workable, needs a WKUIDelegate for popups |
| Auto close on redirect | Yes, on the tebexpay:// callback scheme |
Yes, on any navigation including the tebexpay:// scheme |
| Project setup | None | None |
| One time system alert | Shown before first present | None |
The system runs the web flow and closes the moment checkout redirects to the callback scheme.
import AuthenticationServices
final class CheckoutSession: NSObject, ASWebAuthenticationPresentationContextProviding {
private var session: ASWebAuthenticationSession?
func start(url: URL, onClose: @escaping () -> Void) {
let session = ASWebAuthenticationSession(
url: url,
callbackURLScheme: "tebexpay" // matches complete_url / cancel_url
) { _, _ in onClose() } // fires on redirect or dismiss
session.presentationContextProvider = self
self.session = session
session.start()
}
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }?
.keyWindow ?? ASPresentationAnchor()
}
}Create the basket with a custom scheme callback so the session can intercept it:
let completeURL = "tebexpay://complete"
let cancelURL = "tebexpay://cancel"ASWebAuthenticationSession can only intercept a custom scheme, not an https
URL. The Headless API accepts tebexpay:// in complete_url / cancel_url.
Reference: CheckoutSession.swift.
WKWebView's navigation delegate sees every navigation, so it intercepts the
tebexpay:// redirect and closes. The cost is that you own the browser chrome
and must handle Apple Pay / 3DS popups yourself.
1. Wrap WKWebView in SwiftUI and provide your own Cancel button.
import SwiftUI
import WebKit
struct CheckoutWebView: View {
let url: URL
let onFinish: () -> Void // called once, on redirect or Cancel
var body: some View {
VStack(spacing: 0) {
HStack {
Button("Cancel", action: onFinish) // your only exit
Spacer()
}
.padding()
WebView(url: url, onFinish: onFinish)
}
}
}
private struct WebView: UIViewRepresentable {
let url: URL
let onFinish: () -> Void
func makeCoordinator() -> Coordinator { Coordinator(onFinish: onFinish) }
func makeUIView(context: Context) -> WKWebView {
let webView = WKWebView()
webView.navigationDelegate = context.coordinator
webView.uiDelegate = context.coordinator
webView.load(URLRequest(url: url))
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {}
}2. Intercept the redirect. Cancel the navigation to the callback URL and fire
onFinish once. Match on prefix, since the redirect may carry query params.
final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
private let onFinish: () -> Void
private var finished = false
init(onFinish: @escaping () -> Void) { self.onFinish = onFinish }
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
let target = navigationAction.request.url?.absoluteString ?? ""
if target.hasPrefix(completeURL) || target.hasPrefix(cancelURL) {
decisionHandler(.cancel)
guard !finished else { return } // callback can fire more than once
finished = true
onFinish()
return
}
decisionHandler(.allow)
}
3. Keep Apple Pay / 3DS popups alive. These often open with target=_blank.
WKWebView silently drops them unless you load them back into the same view.
func webView(
_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
if navigationAction.targetFrame == nil {
webView.load(navigationAction.request)
}
return nil
}
}The callback URLs are the same tebexpay:// scheme both strategies share — the
delegate matches them by prefix and never lets them load:
let completeURL = "tebexpay://complete"
let cancelURL = "tebexpay://cancel"Reference: CheckoutWebView.swift.
Present either browser off the checkout URL. When it closes, dismiss and confirm
the outcome by re-fetching the basket and reading its complete flag. Do not
trust the redirect alone as proof of payment.
CheckoutWebView(url: url) {
Task { await viewModel.checkoutClosed() } // re-fetch, then read `complete`
}