BlendPartner API
blendapp.ai

Getting started

OverviewQuickstartHow the integration worksAuthenticationAny backend

Catalog

Listing eventsEvent detail & ticketsWhich events you see

Checkout

Buyer sessionsInventory & seat holdsReserving an orderTaking the paymentConfirming & settlement

Mobile apps

Native UI (no webview)Embedding the webviewiOS (Swift)Android (Kotlin)React Native

Reference

ErrorsRate limitsTesting & go-live

Android (Kotlin)

On 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.

What you are building

  1. An Activity that owns one WebView

    JavaScript and DOM storage on, cookies accepted and persistent, and navigation kept inside the WebView for Blend's hosts.

  2. A JavaScript interface registered as BlendChannelPay

    With a @JavascriptInterface method postMessage(String), registered before loadUrl. The page checks for it when it renders the Pay button.

  3. A charge callback into your own backend

    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.

No Blend credentials in the app

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.

Requirements

RequirementTypeDescription
minSdkVersion 17 or higherrequired@JavascriptInterface is honoured from API 17. Below that the binding is unsafe and Blend does not support it.
settings.javaScriptEnabled = truerequiredThe checkout and the bridge are JavaScript.
settings.domStorageEnabled = truerequiredOff by default on Android; the page keeps transient checkout state here.
CookieManager accepts cookiesrequiredsetAcceptCookie(true) and setAcceptThirdPartyCookies(webView, true). Blend's session is an httpOnly cookie scoped to /channel.
android.permission.INTERNETrequiredIn the manifest, as for any network activity.
Default user agentrequiredLeave 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.

The session URL comes from your backend

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.

POST/v1/channel/session

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.

BlendBackend.kt, talks to YOUR backend, never to Blend
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()
        }
    }
}

The Activity

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.

BlendCheckoutActivity.kt
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:

Launching the checkout
startActivity(
    Intent(this, BlendCheckoutActivity::class.java)
        .putExtra(BlendCheckoutActivity.EXTRA_APP_AUTH_TOKEN, session.token)
)

How the message arrives

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.

What the page calls
window.BlendChannelPay.postMessage(JSON.stringify({
  type: 'charge',
  orderId: '<string>',
  amount: 42.5,
  currency: 'USD',
}));
FieldTypeDescription
typeStringAlways "charge". Ignore any other value.
orderIdStringThe order Blend just reserved for this session's buyer. The one field your backend acts on.
amountNumberMajor currency units, for your payment sheet's label only.
currencyStringISO 4217, upper case. Display only.
If the Pay button says it cannot take payment here

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.

postMessage runs off the main thread

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.

Powered byBlend

The trust model

The bridge message is a display hint, never the charge instruction

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.

  1. Origin-check before acting

    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.

  2. Your backend re-fetches the amount and charges that

    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.

  3. The WebView is not shared

    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.

orderId is the real authorization boundary

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.

After charging

Your backend confirms to Blend, signed and server-to-server. The app is not involved.

POST/v1/channel/orders/confirm

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.

Checklist

  • 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.
  • Only orderId leaves the app. The amount your backend charges comes from Blend.
  • The WebView loads Blend only; other hosts leave through ACTION_VIEW.
  • Back goes through canGoBack() / goBack() first.
  • Retry after a failed first load mints a new session; it never reloads the consumed token URL.
  • userAgentString is left alone.
  • minSdkVersion is 17 or higher. No Blend key or secret is in the APK, BuildConfig, or resources.

Troubleshooting

  • The page loads but the buyer is sent back to the start on the next screen. Cookies are not persisting. Check the CookieManager calls, and make sure nothing clears cookies while the Activity is alive.
  • The Pay button says it cannot take payment here. The interface name is wrong, was registered after 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.
  • A crash inside postMessage. A view was touched from the WebView thread. Everything after the annotation must go through webView.post.
  • A ticket link opens Chrome. Your shouldOverrideUrlLoading is sending a Blend host out. tickets.blendapp.ai is Blend; the isBlendHost suffix rule covers it.
  • Rotation restarts the checkout. Add configChanges to the Activity as shown in the manifest, or retain the WebView across recreation.
  • The seat map shows a desktop layout. userAgentString was changed. Remove that line.

Next

Taking the paymentThe bridge and the trust model in full.Confirming & settlementSign and send the confirm call from your backend.Buyer sessionsThe session endpoint your backend calls.ErrorsError shapes and what to do about each.Embedding the webviewThe platform-neutral requirements.iOS (Swift)The same screen on iOS.React NativeThe same screen in react-native-webview.
Powered byBlend© 2026 Blend · Ticketing & event infrastructure