Warming up the decks…
Warming up the decks…
BlendPartner APIblendapp.aiOn Android the Blend checkout runs inside a WebView. Your app asks your own backend for a session URL, loads it, and exposes one JavaScript interface named BlendChannelPay before the page loads. When the buyer taps Pay, that interface receives a JSON string 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 Activity you can drop into your app.
JavaScript and DOM storage on, cookies accepted and persistent, and navigation kept inside the WebView for Blend's hosts.
BlendChannelPayWith a @JavascriptInterface method postMessage(String), registered before loadUrl. 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. An APK is trivially unpacked; a secret in it is public. See Embedding the webview for the platform-neutral rules this page implements.
| Requirement | Type | Description |
|---|---|---|
| minSdkVersion 17 or higher | required | @JavascriptInterface is honoured from API 17. Below that the binding is unsafe and Blend does not support it. |
| settings.javaScriptEnabled = true | required | The checkout and the bridge are JavaScript. |
| settings.domStorageEnabled = true | required | Off by default on Android; the page keeps transient checkout state here. |
| CookieManager accepts cookies | required | setAcceptCookie(true) and setAcceptThirdPartyCookies(webView, true). Blend's session is an httpOnly cookie scoped to /channel. |
| android.permission.INTERNET | required | In the manifest, as for any network activity. |
| Default user agent | required | Leave settings.userAgentString alone. The seat map is touch-optimised. |
<uses-permission android:name="android.permission.INTERNET" />
<application .>
<activity
android:name=".blend.BlendCheckoutActivity"
android:exported="false"
android:configChanges="orientation|screenSize|keyboardHidden" />
</application>configChanges keeps the Activity alive across rotation so the WebView is not recreated mid-checkout. The pages are responsive; landscape is fine.
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.
package com.example.app.blend
import org.json.JSONObject
import java.io.BufferedReader
import java.net.HttpURLConnection
import java.net.URL
/**
* Asks your own backend for a Blend session URL. Your backend holds the Blend
* key and secret, calls POST https://api.blendapp.ai/api/v1/channel/session,
* and returns only { "url": "." }.
*/
object BlendSessionClient {
/** Replace with the route on your API that mints a Blend session for the signed-in user. */
private const val SESSION_ENDPOINT = "https://api.example.com/api/blend/session"
/** Blocking. Call from Dispatchers.IO. */
fun fetchSessionUrl(appAuthToken: String): String {
val conn = (URL(SESSION_ENDPOINT).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Accept", "application/json")
// Your app's normal user auth, so your backend knows who the buyer is.
setRequestProperty("Authorization", "Bearer $appAuthToken")
connectTimeout = 15_000
readTimeout = 15_000
}
try {
if (conn.responseCode !in 200.299) {
throw IllegalStateException("Session request failed: " + conn.responseCode)
}
val body = conn.inputStream.bufferedReader().use(BufferedReader::readText)
return JSONObject(body).getString("url")
} finally {
conn.disconnect()
}
}
}
/**
* 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).
*/
object PartnerPayments {
private const val CHARGE_ENDPOINT = "https://api.example.com/api/blend/charge"
/** Blocking. Call from Dispatchers.IO. */
fun chargeBlendOrder(
orderId: String,
displayAmount: Double,
displayCurrency: String,
appAuthToken: String,
) {
// displayAmount / displayCurrency are only for the label on your own
// payment sheet ("You are about to pay 42.50 USD"). Show your sheet
// first if your rail needs buyer interaction, then send orderId on.
val conn = (URL(CHARGE_ENDPOINT).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
setRequestProperty("Content-Type", "application/json")
setRequestProperty("Authorization", "Bearer $appAuthToken")
connectTimeout = 15_000
readTimeout = 30_000
}
try {
val payload = JSONObject().put("orderId", orderId).toString()
conn.outputStream.use { it.write(payload.toByteArray()) }
if (conn.responseCode !in 200.299) {
throw IllegalStateException("Charge failed: " + conn.responseCode)
}
// 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.
} finally {
conn.disconnect()
}
}
}Everything the checkout needs is in one file: WebView settings, cookie policy, interface registration, origin check, navigation policy, loading and error states, back navigation and pull-to-refresh. The layout is built in code so the sample has no XML dependency; move it to a layout resource if you prefer.
package com.example.app.blend
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.webkit.CookieManager
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
class BlendCheckoutActivity : AppCompatActivity() {
private lateinit var webView: WebView
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var progress: ProgressBar
private lateinit var errorView: LinearLayout
private var hasLoadedOnce = false
/** Your app's own user auth, passed in by whoever starts this Activity. */
private val appAuthToken: String by lazy {
intent.getStringExtra(EXTRA_APP_AUTH_TOKEN).orEmpty()
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
webView = WebView(this)
buildLayout()
// Required. The checkout and the bridge are JavaScript, and the page
// keeps transient checkout state in DOM storage (off by default).
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
// Leave settings.userAgentString alone. The seat map is touch-optimised;
// a desktop user agent gets the pointer layout.
// Cookies must persist across navigations. Blend sets an httpOnly
// session cookie scoped to /channel; losing it loses the session.
CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true)
// Register BEFORE loadUrl. The page checks for window.BlendChannelPay
// when it renders the Pay button; an interface added afterwards is not
// seen. The name must be exactly this.
webView.addJavascriptInterface(BlendChannelPayBridge(), BRIDGE_NAME)
// Multiple-window support stays off (the default), so links that ask
// for a new window - the ticket, for one - open in this WebView.
webView.webViewClient = BlendWebViewClient()
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (webView.canGoBack()) webView.goBack() else finish()
}
})
swipeRefresh.setOnRefreshListener {
// Reloading the CURRENT page is safe: the session lives on the
// cookie, not on the original token URL.
webView.reload()
swipeRefresh.isRefreshing = false
}
loadFreshSession()
}
override fun onDestroy() {
webView.removeJavascriptInterface(BRIDGE_NAME)
webView.destroy()
super.onDestroy()
}
// -- Session ---------------------------------------------------------------
private fun loadFreshSession() {
showLoading()
lifecycleScope.launch {
try {
val url = withContext(Dispatchers.IO) {
BlendSessionClient.fetchSessionUrl(appAuthToken)
}
webView.loadUrl(url)
} catch (e: Exception) {
showError()
}
}
}
// -- The bridge ------------------------------------------------------------
private inner class BlendChannelPayBridge {
/**
* The page calls window.BlendChannelPay.postMessage(JSON.stringify({.})).
* Android bindings accept primitives only, so the message is a JSON string.
* This runs on a WebView background thread: hop to main before touching views.
*/
@JavascriptInterface
fun postMessage(message: String) {
webView.post { handleBridgeMessage(message) }
}
}
private fun handleBridgeMessage(message: String) {
// 1. Origin check. A JavaScript interface is origin-blind: any script
// running in this WebView can call it. Only act when the WebView is
// currently showing a Blend page. Discard anything else silently.
val currentHost = Uri.parse(webView.url ?: "").host
if (!isBlendHost(currentHost)) 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.
val json = try {
JSONObject(message)
} catch (e: Exception) {
return
}
if (json.optString("type") != "charge") return
val orderId = json.optString("orderId")
if (orderId.isEmpty()) return
val displayAmount = json.optDouble("amount", 0.0)
val displayCurrency = json.optString("currency")
// 3. Charge through YOUR backend, which re-fetches the authoritative
// amount for this orderId from Blend and charges that.
lifecycleScope.launch {
try {
withContext(Dispatchers.IO) {
PartnerPayments.chargeBlendOrder(
orderId, displayAmount, displayCurrency, appAuthToken,
)
}
// Nothing more to do. The page is already polling Blend for the outcome.
} catch (e: Exception) {
// 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.
}
}
}
// -- Navigation ------------------------------------------------------------
private inner class BlendWebViewClient : WebViewClient() {
// The String overload is called directly below API 24 and forwarded to
// by the WebResourceRequest overload from API 24, so one override covers
// every supported level.
@Deprecated("Overridden on purpose: covers API < 24; the newer overload forwards here.")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
// Blend hosts stay in this WebView. An external browser has neither
// the session cookie nor the payment bridge.
if (isBlendHost(Uri.parse(url).host)) return false
// Anything else leaves, so this WebView only ever renders Blend.
// That is what keeps the bridge registration safe to hold.
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
} catch (e: Exception) {
// No app can open it. Nothing to do.
}
return true
}
override fun onPageFinished(view: WebView, url: String?) {
hasLoadedOnce = true
showContent()
}
// Same pattern: the newer overload forwards main-frame errors here.
@Deprecated("Overridden on purpose: covers API < 23; the newer overload forwards here.")
override fun onReceivedError(
view: WebView,
errorCode: Int,
description: String?,
failingUrl: String?,
) {
// 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()
}
}
// -- States and layout -----------------------------------------------------
private fun showLoading() {
progress.visibility = View.VISIBLE
errorView.visibility = View.GONE
swipeRefresh.visibility = View.INVISIBLE
}
private fun showContent() {
progress.visibility = View.GONE
errorView.visibility = View.GONE
swipeRefresh.visibility = View.VISIBLE
}
private fun showError() {
progress.visibility = View.GONE
errorView.visibility = View.VISIBLE
swipeRefresh.visibility = View.INVISIBLE
}
private fun buildLayout() {
swipeRefresh = SwipeRefreshLayout(this).apply {
addView(webView, MATCH_PARENT, MATCH_PARENT)
}
progress = ProgressBar(this)
errorView = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
addView(TextView(context).apply { text = "Could not load the checkout." })
addView(Button(context).apply {
text = "Try again"
// A session URL is single use. If the first load failed part-way
// the token may be consumed, so retry always mints a new session.
setOnClickListener { loadFreshSession() }
})
}
val root = FrameLayout(this)
root.addView(swipeRefresh, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))
root.addView(progress, FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.CENTER))
root.addView(errorView, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))
setContentView(root)
}
companion object {
const val EXTRA_APP_AUTH_TOKEN = "appAuthToken"
/** The interface name Blend's page looks for. It must be exactly this. */
private const val BRIDGE_NAME = "BlendChannelPay"
/**
* blendapp.ai and every subdomain of it (checkout.blendapp.ai,
* tickets.blendapp.ai) are Blend. Everything else is not.
*/
fun isBlendHost(host: String?): Boolean {
val h = host?.lowercase() ?: return false
return h == "blendapp.ai" || h.endsWith(".blendapp.ai")
}
}
}Start it from anywhere in your app with the buyer's app-level auth token:
startActivity(
Intent(this, BlendCheckoutActivity::class.java)
.putExtra(BlendCheckoutActivity.EXTRA_APP_AUTH_TOKEN, session.token)
)The page calls the object you registered. Because addJavascriptInterface only passes primitives across the boundary, the page serialises the message to a JSON string first. Your postMessage(String) parses it.
window.BlendChannelPay.postMessage(JSON.stringify({
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 | Number | Major currency units, for your payment sheet's label only. |
| currency | String | ISO 4217, upper case. Display only. |
The page did not find window.BlendChannelPay when it rendered. Check the name character by character, it is Pascal case with a capital B, unlike the iOS name, and confirm addJavascriptInterface ran before loadUrl. Also confirm javaScriptEnabled is true; without it the interface is never reachable.
Methods annotated @JavascriptInterface are called on a WebView-owned background thread. Reading webView.url or touching any view from there is a crash. The sample posts to the WebView's handler first and does everything on the main thread.
A JavaScript interface is origin-blind. It can be called by 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 Activity above enforces three rules. Keep all three when you adapt it.
webView.url is parsed and its host checked against Blend's hosts before the body is read. This does not make the channel authenticated, nothing about an interface call 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 WebView is created inside onCreate and destroyed in onDestroy. Do not keep it in a pool or a singleton, and do not call addJavascriptInterface(…, "BlendChannelPay") on a WebView that loads ads, marketing pages or anything that is not this checkout. Its navigation policy sends every non-Blend URL out of the WebView for the same reason.
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 the page 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 call evaluateJavascript on it after charging. Reference on Confirming & settlement.
javaScriptEnabled and domStorageEnabled are true.setAcceptCookie(true) and setAcceptThirdPartyCookies(webView, true) are called. Nothing calls CookieManager.removeAllCookies while the checkout is open.addJavascriptInterface runs before loadUrl, with the name BlendChannelPay exactly.postMessage hops to the main thread, then checks webView.url before reading the body.orderId leaves the app. The amount your backend charges comes from Blend.ACTION_VIEW.canGoBack() / goBack() first.userAgentString is left alone.minSdkVersion is 17 or higher. No Blend key or secret is in the APK, BuildConfig, or resources.CookieManager calls, and make sure nothing clears cookies while the Activity is alive.loadUrl, or JavaScript is off. See above.postMessage is called but nothing happens. The origin check failed. Log webView.url on the main thread and confirm the host ends in blendapp.ai.postMessage. A view was touched from the WebView thread. Everything after the annotation must go through webView.post.shouldOverrideUrlLoading is sending a Blend host out. tickets.blendapp.ai is Blend; the isBlendHost suffix rule covers it.configChanges to the Activity as shown in the manifest, or retain the WebView across recreation.userAgentString was changed. Remove that line.