Warming up the decks…
Warming up the decks…
BlendPartner APIblendapp.aiOn iOS the Blend checkout runs inside a WKWebView. Your app asks your own backend for a session URL, loads it, and registers one script message handler named blendChannelPay before the page loads. When the buyer taps Pay, that handler receives a small dictionary naming the order; your app passes the order id to your backend, which charges the buyer and confirms to Blend. This page gives you a complete view controller you can drop into a UIKit or SwiftUI app.
With its own WKWebViewConfiguration, a persistent data store so the session cookie survives, and navigation kept inside the webview for Blend's hosts.
blendChannelPayOn the configuration's userContentController, before load(_:) is called. The page checks for it when it renders the Pay button.
Receives orderId. Your backend re-fetches the amount from Blend, charges it on your rail, and confirms. The webview then finds the ticket on its own.
The app never calls api.blendapp.ai. The session comes from your backend, the charge goes to your backend, and your ck_live_xxxxxxxx / cs_live_xxxxxxxx pair, sent to you by email, stays there. See Embedding the webview for the platform-neutral rules this page implements.
Your backend calls Blend and returns only the URL. The route below is an example name on your own API; use whatever fits your service. The app authenticates to it the way it authenticates to everything else you run.
Called by your backend, not the app. Reference on Buyer sessions. What the app sees is one string, valid for 30 minutes and single use.
import Foundation
/// Fetches a Blend session URL from your own backend.
/// Your backend holds the Blend key and secret, calls
/// POST https://api.blendapp.ai/api/v1/channel/session, and returns only { url }.
enum BlendSessionClient {
/// The shape YOUR backend returns. Only the URL is needed by the app.
private struct SessionResponse: Decodable {
let url: String
}
/// Replace with the route on your API that mints a Blend session for the signed-in user.
private static let sessionEndpoint = URL(string: "https://api.example.com/api/blend/session")!
static func fetchSessionURL(appAuthToken: String) async throws -> URL {
var request = URLRequest(url: sessionEndpoint)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Accept")
// Your app's normal user auth, so your backend knows who the buyer is.
request.setValue("Bearer " + appAuthToken, forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200.<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
let body = try JSONDecoder().decode(SessionResponse.self, from: data)
guard let url = URL(string: body.url) else { throw URLError(.badURL) }
return url
}
}Everything the checkout needs is in one file: configuration, handler registration, origin check, navigation policy, loading and error states, back navigation, and pull-to-refresh. The sample uses Swift concurrency for the two network calls, which needs iOS 15 or later; the WKWebView parts have no such requirement.
import UIKit
import WebKit
// MARK: - Charging (your rail, through YOUR backend)
/// Called with the orderId from the bridge. The amount from the bridge is NOT
/// what gets charged: your backend re-fetches the authoritative amount for
/// orderId from Blend, charges THAT on your rail, and then calls
/// POST /v1/channel/orders/confirm (signed, server-to-server).
enum PartnerPayments {
private static let chargeEndpoint = URL(string: "https://api.example.com/api/blend/charge")!
static func chargeBlendOrder(orderId: String,
displayAmount: Double,
displayCurrency: String,
appAuthToken: String) async throws {
// displayAmount / displayCurrency are only for the label on your own
// payment sheet ("You are about to pay 42.50 USD"). Show your sheet here
// if your rail needs buyer interaction, then hand the orderId to your backend.
var request = URLRequest(url: chargeEndpoint)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer " + appAuthToken, forHTTPHeaderField: "Authorization")
request.httpBody = try JSONSerialization.data(withJSONObject: ["orderId": orderId])
let (_, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200.<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
// Done. Do not touch the webview: the page polls Blend every 3 seconds for
// up to 6 minutes and shows the ticket itself once the order is paid.
}
}
// MARK: - Weak handler proxy
/// WKUserContentController retains its handlers strongly. Forwarding through
/// a weak proxy lets the view controller deallocate normally.
final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
private weak var target: WKScriptMessageHandler?
init(_ target: WKScriptMessageHandler) {
self.target = target
}
func userContentController(_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage) {
target?.userContentController(userContentController, didReceive: message)
}
}
// MARK: - The checkout screen
final class BlendCheckoutViewController: UIViewController {
/// The handler name Blend's page looks for. It must be exactly this.
private static let bridgeName = "blendChannelPay"
/// blendapp.ai and every subdomain of it (checkout.blendapp.ai,
/// tickets.blendapp.ai) are Blend. Everything else is not.
static func isBlendHost(_ host: String?) -> Bool {
guard let host = host?.lowercased() else { return false }
return host == "blendapp.ai" || host.hasSuffix(".blendapp.ai")
}
private let appAuthToken: String
private var webView: WKWebView!
private let spinner = UIActivityIndicatorView(style: .large)
private let errorView = UIStackView()
private var hasLoadedOnce = false
init(appAuthToken: String) {
self.appAuthToken = appAuthToken
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("Use init(appAuthToken:)")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = "Checkout"
navigationItem.leftBarButtonItem = UIBarButtonItem(
title: "Back", style: .plain, target: self, action: #selector(backTapped))
// A configuration OWNED by this screen. Never reuse it for a webview that
// loads non-Blend content: any page on a webview sharing this controller
// could post a "charge" message.
let config = WKWebViewConfiguration()
// Persistent store: the httpOnly session cookie must survive navigations.
// .nonPersistent() would drop it and the buyer would lose the session.
config.websiteDataStore = .default()
// Register BEFORE load. The page checks for this handler when it renders
// the Pay button; a handler added afterwards is not seen.
config.userContentController.add(WeakScriptMessageHandler(self), name: Self.bridgeName)
webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = self
webView.uiDelegate = self
webView.allowsBackForwardNavigationGestures = true
// Leave customUserAgent unset. The seat map is touch-optimised; a desktop
// user agent would get the pointer layout.
// Pull-to-refresh reloads the CURRENT page, which is safe: the session
// lives on the cookie, not on the original token URL.
let refresh = UIRefreshControl()
refresh.addTarget(self, action: #selector(pullToRefresh(_:)), for: .valueChanged)
webView.scrollView.refreshControl = refresh
layoutSubviews()
loadFreshSession()
}
deinit {
webView?.configuration.userContentController
.removeScriptMessageHandler(forName: Self.bridgeName)
}
// MARK: Loading
private func loadFreshSession() {
showLoading()
Task { @MainActor [weak self] in
guard let self else { return }
do {
let url = try await BlendSessionClient.fetchSessionURL(appAuthToken: self.appAuthToken)
self.webView.load(URLRequest(url: url))
} catch {
self.showError()
}
}
}
@objc private func retryTapped() {
// A session URL is single use. If the first load failed part-way, the
// token may already be consumed, so retry always mints a new session.
loadFreshSession()
}
@objc private func pullToRefresh(_ sender: UIRefreshControl) {
webView.reload()
sender.endRefreshing()
}
@objc private func backTapped() {
if webView.canGoBack {
webView.goBack()
} else {
closeCheckout()
}
}
private func closeCheckout() {
if let nav = navigationController, nav.viewControllers.first !== self {
nav.popViewController(animated: true)
} else {
dismiss(animated: true)
}
}
// MARK: States
private func showLoading() {
spinner.startAnimating()
errorView.isHidden = true
webView.alpha = 0
}
private func showContent() {
spinner.stopAnimating()
errorView.isHidden = true
webView.alpha = 1
}
private func showError() {
spinner.stopAnimating()
errorView.isHidden = false
webView.alpha = 0
}
private func layoutSubviews() {
webView.translatesAutoresizingMaskIntoConstraints = false
spinner.translatesAutoresizingMaskIntoConstraints = false
errorView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(webView)
view.addSubview(spinner)
view.addSubview(errorView)
errorView.axis = .vertical
errorView.alignment = .center
errorView.spacing = 12
let label = UILabel()
label.text = "Could not load the checkout."
label.textColor = .secondaryLabel
let retry = UIButton(type: .system)
retry.setTitle("Try again", for: .normal)
retry.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
errorView.addArrangedSubview(label)
errorView.addArrangedSubview(retry)
NSLayoutConstraint.activate([
webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
spinner.centerXAnchor.constraint(equalTo: view.centerXAnchor),
spinner.centerYAnchor.constraint(equalTo: view.centerYAnchor),
errorView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
errorView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
}
// MARK: - The bridge
extension BlendCheckoutViewController: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage) {
guard message.name == Self.bridgeName else { return }
// 1. Origin check. A script message handler is origin-blind: any script
// running in this webview can post to it. Only act when the webview is
// currently showing a Blend page. Discard anything else silently.
guard Self.isBlendHost(message.webView?.url?.host) else { return }
// 2. Read the hint. Only orderId is acted on. amount and currency are for
// the label on your own payment sheet and nothing else.
guard let body = message.body as? [String: Any],
body["type"] as? String == "charge",
let orderId = body["orderId"] as? String,
!orderId.isEmpty else { return }
let displayAmount = (body["amount"] as? NSNumber)?.doubleValue ?? 0
let displayCurrency = body["currency"] as? String ?? ""
// 3. Charge through YOUR backend, which re-fetches the authoritative
// amount for this orderId from Blend and charges that.
Task { @MainActor [weak self] in
guard let self else { return }
do {
try await PartnerPayments.chargeBlendOrder(orderId: orderId,
displayAmount: displayAmount,
displayCurrency: displayCurrency,
appAuthToken: self.appAuthToken)
// Nothing more to do. The page is already polling Blend for the outcome.
} catch {
// Your rail declined, or the buyer cancelled your sheet. The page's
// polling ends on its own; show your own message if you want one.
}
}
}
}
// MARK: - Navigation policy
extension BlendCheckoutViewController: WKNavigationDelegate {
func webView(_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
return
}
// Blend hosts stay in this webview. An external browser has neither the
// session cookie nor the payment bridge, so the buyer would be lost there.
if Self.isBlendHost(url.host) {
decisionHandler(.allow)
return
}
// Anything else leaves. This webview only ever renders Blend, which is
// what keeps the bridge registration safe to hold.
if let scheme = url.scheme?.lowercased(), ["http", "https", "mailto", "tel"].contains(scheme) {
UIApplication.shared.open(url)
}
decisionHandler(.cancel)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
hasLoadedOnce = true
showContent()
}
func webView(_ webView: WKWebView,
didFailProvisionalNavigation navigation: WKNavigation!,
withError error: Error) {
handleLoadError(error)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
handleLoadError(error)
}
private func handleLoadError(_ error: Error) {
// A navigation we cancelled ourselves is not a failure.
if (error as NSError).code == NSURLErrorCancelled { return }
// Before first paint: show the retry affordance. After it: the page is
// still on screen and can recover on its own.
if hasLoadedOnce { showContent() } else { showError() }
}
}
// MARK: - New-window links
extension BlendCheckoutViewController: WKUIDelegate {
/// The ticket and a few other links ask for a new window. Load them here
/// instead; the navigation policy above still decides whether they stay.
func webView(_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures) -> WKWebView? {
if navigationAction.targetFrame == nil || navigationAction.targetFrame?.isMainFrame == false {
webView.load(navigationAction.request)
}
return nil
}
}import SwiftUI
import UIKit
struct BlendCheckoutView: UIViewControllerRepresentable {
let appAuthToken: String
func makeUIViewController(context: Context) -> UINavigationController {
UINavigationController(rootViewController: BlendCheckoutViewController(appAuthToken: appAuthToken))
}
func updateUIViewController(_ controller: UINavigationController, context: Context) {}
}
// Present it full screen so the checkout owns the whole surface:
//
// .fullScreenCover(isPresented: $showCheckout) {
// BlendCheckoutView(appAuthToken: session.token)
// }The page calls the standard WebKit message API. WebKit delivers the argument to your handler as a Swift dictionary; numbers arrive as NSNumber.
window.webkit.messageHandlers.blendChannelPay.postMessage({
type: 'charge',
orderId: '<string>',
amount: 42.5,
currency: 'USD',
});| Field | Type | Description |
|---|---|---|
| type | String | Always "charge". Ignore any other value. |
| orderId | String | The order Blend just reserved for this session's buyer. The one field your backend acts on. |
| amount | NSNumber | Major currency units, for your payment sheet's label only. |
| currency | String | ISO 4217, upper case. Display only. |
The page did not find window.webkit.messageHandlers.blendChannelPay when it rendered. Check the name character by character, it is camel case with a lower-case b, and confirm add(_:name:) ran on the configuration before load(_:). A handler added to a different configuration, or after the load, is not seen.
WKScriptMessageHandler is origin-blind. It fires for a postMessage call from any script running in the webview's JavaScript context. Blend's page, but also an injected ad, a compromised dependency, or anything else that ever executed there. From the message alone, native code cannot tell those apart. Never charge the amount in the message.
The view controller above enforces three rules. Keep all three when you adapt it.
message.webView?.url?.host is checked against Blend's hosts before the body is read. This does not make the channel authenticated, nothing about a script message can, but it discards the “some other page entirely” case.
PartnerPayments.chargeBlendOrder sends only orderId to your backend. Your backend asks Blend for that order (keyed, with your secret, see Confirming & settlement), charges the amount Blend returns, and confirms. The amount in the message reaches your payment sheet's label and goes no further.
The WKWebViewConfiguration and its userContentController are created inside viewDidLoad and die with the screen. Do not lift them into a shared singleton, and do not register blendChannelPay on a webview that loads ads, marketing pages or anything that is not this checkout.
An orderId can only name an order Blend itself just created for this session's buyer; the buyer's identity comes from the session cookie, never from the page. The worst a forged message can do is point your app at the buyer's own pending order, whose amount your backend fetches from Blend.
Your backend confirms to Blend, signed and server-to-server. The app is not involved.
The webview learns the outcome by itself: after posting the charge message it polls Blend every 3 seconds for up to 6 minutes and shows the ticket once the order is paid. Do not reload the webview, navigate it, or evaluate JavaScript in it after charging. Reference on Confirming & settlement.
config.websiteDataStore = .default(), not .nonPersistent(). The session cookie must survive navigations.add(_:name:) runs before load(_:), with the name blendChannelPay exactly.message.webView?.url?.host before reading the body.orderId leaves the app. The amount your backend charges comes from Blend.canGoBack / goBack() first.customUserAgent is left unset.message.webView?.url and confirm the host ends in blendapp.ai.tickets.blendapp.ai is Blend; the isBlendHost suffix rule covers it.customUserAgent is set. Remove it.removeScriptMessageHandler(forName:) is missing from deinit.