Warming up the decks…
Warming up the decks…
BlendPartner APIblendapp.aiBlend draws the checkout inside your app, but Blend never touches the buyer's money. When the buyer taps Pay, the web page hands a small note to your native app, “order such-and-such, this much” and your app charges the buyer on your own rail. This page shows how that hand-off works on iOS and Android, and, more importantly, why your app must treat that note as a hint and not as an instruction.
Blend's checkout page calls POST /v1/channel/session/events/:id/checkout and receives an orderId, amount and currency.
It posts a charge message through the platform bridge described below.
After re-fetching the authoritative amount from Blend server-to-server. See the trust model.
POST /v1/channel/orders/confirm, signed. Blend claims inventory and issues the ticket.
No callback into the webview is needed. The page asks Blend for the order's status every few seconds and shows the ticket when it is paid.
The message has the same shape on both platforms: a JSON object with type, orderId, amount and currency.
{
"type": "charge",
"orderId": "…",
"amount": 42.5,
"currency": "USD"
}Register a WKScriptMessageHandler named exactly blendChannelPay on the web view's user content controller. The page calls:
window.webkit.messageHandlers.blendChannelPay.postMessage({
type: 'charge',
orderId,
amount,
currency,
});import WebKit
final class BlendCheckoutController: UIViewController, WKScriptMessageHandler {
private let expectedHost = "blendapp.ai"
private var webView: WKWebView!
override func viewDidLoad() {
super.viewDidLoad()
let config = WKWebViewConfiguration()
// The name must be exactly "blendChannelPay".
config.userContentController.add(self, name: "blendChannelPay")
webView = WKWebView(frame: view.bounds, configuration: config)
view.addSubview(webView)
}
func userContentController(_ controller: WKUserContentController,
didReceive message: WKScriptMessage) {
guard message.name == "blendChannelPay" else { return }
// 1. Origin check: only act on messages from the Blend page.
guard message.frameInfo.securityOrigin.host == expectedHost else { return }
// 2. Read the hint. Only orderId is trusted from here.
guard let body = message.body as? [String: Any],
body["type"] as? String == "charge",
let orderId = body["orderId"] as? String else { return }
// 3. Ask YOUR backend to fetch the authoritative amount from Blend
// and charge that. Never charge body["amount"].
PaymentService.shared.chargeBlendOrder(orderId: orderId)
}
deinit {
webView?.configuration.userContentController
.removeScriptMessageHandler(forName: "blendChannelPay")
}
}Register a JavaScript interface named exactly BlendChannelPay exposing a postMessage(String) method. Android bindings only accept primitives, so the page sends the same object as a JSON string. The contract is otherwise identical.
window.BlendChannelPay.postMessage(JSON.stringify({
type: 'charge',
orderId,
amount,
currency,
}));import android.graphics.Bitmap
import android.net.Uri
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import org.json.JSONObject
class BlendChannelPayBridge(
private val onCharge: (orderId: String) -> Unit,
) {
private val expectedHost = "blendapp.ai"
// @JavascriptInterface methods run on a background thread, and WebView.url
// may only be read on the UI thread, so the host is recorded from the
// WebViewClient as pages load, and checked here.
@Volatile var currentHost: String? = null
@JavascriptInterface
fun postMessage(raw: String) {
// 1. Origin check: only act while the Blend page is the one loaded.
if (currentHost != expectedHost) return
// 2. Read the hint. Only orderId is trusted from here.
val msg = runCatching { JSONObject(raw) }.getOrNull() ?: return
if (msg.optString("type") != "charge") return
val orderId = msg.optString("orderId").takeIf { it.isNotBlank() } ?: return
// 3. Ask YOUR backend to fetch the authoritative amount from Blend
// and charge that. Never charge msg["amount"].
onCharge(orderId)
}
}
// Registration, the name must be exactly "BlendChannelPay".
val bridge = BlendChannelPayBridge(::chargeBlendOrder)
webView.settings.javaScriptEnabled = true
webView.addJavascriptInterface(bridge, "BlendChannelPay")
webView.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
bridge.currentHost = Uri.parse(url).host
}
}The Pay button shows a visible “can't take payment here” state instead of a dead button. If you see it during development, the handler name is wrong or the interface was not registered before the page loaded.
A webview message handler is origin-blind. Any script running in that webview, an injected ad, a compromised dependency, a man-in-the-middle on an unpinned connection, can post a message with the same shape. Your app must never take the amount in that message and charge it.
Your native code and backend must, in order:
Before acting, verify the message came from the Blend host you loaded. On iOS, use message.frameInfo.securityOrigin. On Android, check the web view's current URL host. Discard anything else silently.
Your backend calls POST /v1/channel/orders/status with the orderId (keyed, with your secret) and charges the amount it returns. That is the same number orders/confirm validates against, so a charge based on it can never trip AMOUNT_MISMATCH.
The amount in the bridge message exists so your UI can show something immediately, nothing more.
The WKUserContentController or addJavascriptInterface registration must belong to a webview that loads only Blend. A webview that also renders third-party pages would give those pages the same bridge.
An orderId can only name an order Blend just created for that session's buyer. A forged message can, at worst, point at the buyer's own pending order, and the amount for that order is fetched from Blend, not from the message. Blend's confirm endpoint then rejects any amount that does not match the order, and refuses any order that is not yours.
There is no callback from your app back into the webview. After posting the charge message, the page polls Blend for the order's status:
paid or failed.One polling loop covers every case. If your backend's confirm is a few seconds late, the page simply sees pending until it flips to paid. If the buyer cancelled on your payment sheet, the order never becomes paid and the page can say so. “Buyer cancelled” and “paid, but confirm is late” look different to the buyer because the loop waits long enough for a real confirm to arrive.
The buyer is watching a spinner for as long as it takes your backend to confirm. Call confirm the moment your rail reports success; do not batch it.