Warming up the decks…
Warming up the decks…
BlendPartner APIblendapp.aiIn React Native the Blend checkout runs inside react-native-webview. Your app asks your own backend for a session URL, loads it, and injects a small script before the page runs that defines window.BlendChannelPay and forwards it to the webview's own message channel. When the buyer taps Pay, your onMessage handler receives a JSON string naming the order; your app passes the order id to your backend, which charges the buyer and confirms to Blend. One component, one handler, both platforms.
JavaScript and DOM storage on, cookies persistent, navigation kept inside the webview for Blend's hosts, and a fresh session minted on retry.
BlendChannelPayDefined with injectedJavaScriptBeforeContentLoaded, so it exists before Blend's page checks for it. It forwards to window.ReactNativeWebView.postMessage, which is how the string reaches onMessage.
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.
Blend's page looks for the Android-shaped bridge, an object with a postMessage(string) method, on both platforms when it is running in react-native-webview. Because the shim defines that object on iOS too, the same string contract and the same onMessage handler serve both.
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. A JavaScript bundle is readable by anyone with the app; a secret in it is public. See Embedding the webview for the platform-neutral rules this page implements.
| Requirement | Type | Description |
|---|---|---|
| react-native-webview | required | Install with your package manager, or npx expo install react-native-webview in an Expo project. The samples use the prop names of the current major. |
| Android minSdkVersion 17 or higher | required | The bridge on Android is a JavaScript interface, which is safe from API 17. |
| javaScriptEnabled, domStorageEnabled | required | Set them explicitly on the WebView even where they default to on. |
| Cookies persist | required | sharedCookiesEnabled on iOS, thirdPartyCookiesEnabled on Android, and incognito off. Blend's session is an httpOnly cookie scoped to /channel. |
| Default user agent | required | Do not pass userAgent. The seat map is touch-optimised. |
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.
/**
* Everything in this file talks to YOUR backend. The app never calls
* api.blendapp.ai and never holds the Blend key or secret.
*/
/** Replace with your API's base URL. */
const API = 'https://api.example.com';
/**
* Asks your 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": "." }.
*/
export async function fetchBlendSessionUrl(appAuthToken: string): Promise<string> {
const res = await fetch(API + '/api/blend/session', {
method: 'POST',
headers: {
Accept: 'application/json',
// Your app's normal user auth, so your backend knows who the buyer is.
Authorization: 'Bearer ' + appAuthToken,
},
});
if (!res.ok) {
throw new Error('Session request failed: ' + res.status);
}
const body = (await res.json()) as { url: string };
return body.url;
}
/**
* 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).
*/
export async function chargeBlendOrder(
orderId: string,
displayAmount: number,
displayCurrency: string,
appAuthToken: string,
): Promise<void> {
// 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.
const res = await fetch(API + '/api/blend/charge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + appAuthToken,
},
body: JSON.stringify({ orderId }),
});
if (!res.ok) {
throw new Error('Charge failed: ' + res.status);
}
// 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.
}Everything the checkout needs is in one file: the injected bridge, the message handler with its origin check, navigation policy, loading and error states, Android hardware back, and pull-to-refresh. Props that belong to one platform (sharedCookiesEnabled on iOS, thirdPartyCookiesEnabled on Android) are ignored on the other.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
BackHandler,
Button,
Linking,
StyleSheet,
Text,
View,
} from 'react-native';
import { WebView, type WebViewMessageEvent, type WebViewNavigation } from 'react-native-webview';
import { chargeBlendOrder, fetchBlendSessionUrl } from './blendBackend';
/**
* blendapp.ai and every subdomain of it (checkout.blendapp.ai,
* tickets.blendapp.ai) are Blend. Everything else is not.
*/
export function isBlendHost(host: string | null | undefined): boolean {
if (!host) return false;
const h = host.toLowerCase();
return h === 'blendapp.ai' || h.endsWith('.blendapp.ai');
}
/** Host of an http(s) URL. Kept dependency-free: not every RN runtime ships a full URL parser. */
function hostOf(url: string | undefined): string | null {
if (!url) return null;
const match = new RegExp('^https?://([^/?#:]+)', 'i').exec(url);
return match ? match[1] : null;
}
/**
* Defines window.BlendChannelPay BEFORE Blend's page runs, forwarding to
* react-native-webview's own channel. This is the Android-shaped string
* contract, so one onMessage handler serves iOS and Android.
*
* The last statement must be "true;" or react-native-webview warns.
*/
const BRIDGE_SHIM = [
'window.BlendChannelPay = {',
' postMessage: function (message) {',
' window.ReactNativeWebView.postMessage(message);',
' }',
'};',
'true;',
].join('\n');
type Props = {
/** Your app's own user auth, used to call YOUR backend. */
appAuthToken: string;
/** Called when the buyer backs out with nothing left to go back to. */
onClose: () => void;
};
type Status = 'loading' | 'ready' | 'error';
export function BlendCheckout({ appAuthToken, onClose }: Props) {
const webViewRef = useRef<WebView>(null);
const canGoBack = useRef(false);
const [sessionUrl, setSessionUrl] = useState<string | null>(null);
const [status, setStatus] = useState<Status>('loading');
const loadFreshSession = useCallback(async () => {
setStatus('loading');
setSessionUrl(null);
try {
// A session URL is single use. If a load failed part-way the token may
// already be consumed, so a retry always mints a new session.
const url = await fetchBlendSessionUrl(appAuthToken);
setSessionUrl(url);
} catch {
setStatus('error');
}
}, [appAuthToken]);
useEffect(() => {
void loadFreshSession();
}, [loadFreshSession]);
// Android hardware back goes through the webview first.
useEffect(() => {
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
if (canGoBack.current) {
webViewRef.current?.goBack();
} else {
onClose();
}
return true;
});
return () => subscription.remove();
}, [onClose]);
const onNavigationStateChange = useCallback((nav: WebViewNavigation) => {
canGoBack.current = nav.canGoBack;
}, []);
// Blend hosts stay in this webview: an external browser has neither the
// session cookie nor the bridge. Anything else leaves, so this webview only
// ever renders Blend - which is what keeps the bridge safe to hold.
const onShouldStartLoadWithRequest = useCallback((request: { url: string }) => {
if (isBlendHost(hostOf(request.url))) return true;
Linking.openURL(request.url).catch(() => undefined);
return false;
}, []);
const onMessage = useCallback(
(event: WebViewMessageEvent) => {
const { url, data } = event.nativeEvent;
// 1. Origin check. The message channel 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.
if (!isBlendHost(hostOf(url))) 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.
let message: { type?: unknown; orderId?: unknown; amount?: unknown; currency?: unknown };
try {
message = JSON.parse(data);
} catch {
return;
}
if (message.type !== 'charge') return;
if (typeof message.orderId !== 'string' || message.orderId.length === 0) return;
const orderId = message.orderId;
const displayAmount = typeof message.amount === 'number' ? message.amount : 0;
const displayCurrency = typeof message.currency === 'string' ? message.currency : '';
// 3. Charge through YOUR backend, which re-fetches the authoritative
// amount for this orderId from Blend and charges that.
chargeBlendOrder(orderId, displayAmount, displayCurrency, appAuthToken).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.
});
// Nothing more to do. The page is already polling Blend for the outcome.
},
[appAuthToken],
);
if (status === 'error') {
return (
<View style={styles.center}>
<Text style={styles.message}>Could not load the checkout.</Text>
<Button
title="Try again"
onPress={() => {
void loadFreshSession();
}}
/>
</View>
);
}
return (
<View style={styles.fill}>
{sessionUrl ? (
<WebView
ref={webViewRef}
source={{ uri: sessionUrl }}
// Required: the checkout and the bridge are JavaScript, and the page
// keeps transient checkout state in DOM storage.
javaScriptEnabled
domStorageEnabled
// Cookies must persist across navigations. Blend sets an httpOnly
// session cookie scoped to /channel; losing it loses the session.
sharedCookiesEnabled
thirdPartyCookiesEnabled
incognito={false}
// The bridge, defined before the page's own scripts run. The page
// checks for it when it renders the Pay button.
injectedJavaScriptBeforeContentLoaded={BRIDGE_SHIM}
onMessage={onMessage}
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
onNavigationStateChange={onNavigationStateChange}
onLoadEnd={() => setStatus('ready')}
onError={() => setStatus((current) => (current === 'loading' ? 'error' : current))}
// Reloading the CURRENT page is safe: the session lives on the cookie.
pullToRefreshEnabled
allowsBackForwardNavigationGestures
// Links that ask for a new window (the ticket, for one) open here.
setSupportMultipleWindows={false}
// Do not pass userAgent. The seat map is touch-optimised.
style={styles.fill}
/>
) : null}
{status === 'loading' ? (
<View style={[StyleSheet.absoluteFill, styles.center]} pointerEvents="none">
<ActivityIndicator size="large" />
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 },
message: { opacity: 0.7 },
});Mount it full screen so the checkout owns the whole surface:
import { BlendCheckout } from './BlendCheckout';
export function CheckoutScreen({ navigation, session }) {
return (
<BlendCheckout
appAuthToken={session.token}
onClose={() => navigation.goBack()}
/>
);
}Blend's page calls the object the shim defined. The shim hands the string to window.ReactNativeWebView.postMessage, and react-native-webview delivers it to your onMessage as event.nativeEvent.data, with the webview's current URL in event.nativeEvent.url.
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. The usual causes: the shim is in injectedJavaScript (which runs after load) instead of injectedJavaScriptBeforeContentLoaded; the name is misspelled (Pascal case, capital B); or the shim throws before it defines the object. Open the webview in a debugger and evaluate typeof window.BlendChannelPay.
react-native-webview evaluates the injected script and warns if the last expression is not a value it can serialise. Keep true; as the final statement of BRIDGE_SHIM. Do not append anything after it.
window.ReactNativeWebView.postMessage is origin-blind. Any script running in the webview's JavaScript context can call it. Blend's page, but also an injected ad, a compromised dependency, or anything else that ever executed there, and it can post any string it likes, including one that spells BlendChannelPay correctly. From the message alone, onMessage cannot tell those apart. Never charge the amount in the message.
The component above enforces three rules. Keep all three when you adapt it.
event.nativeEvent.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 a webview message can, but it discards the “some other page entirely” case.
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 element lives inside BlendCheckout and unmounts with it. Do not reuse this component, its shim, or its onMessage for a webview that loads ads, marketing pages or anything that is not this checkout. Its onShouldStartLoadWithRequest 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 call reload(), change source, or injectJavaScript after charging. Reference on Confirming & settlement.
javaScriptEnabled and domStorageEnabled are set.sharedCookiesEnabled, thirdPartyCookiesEnabled are set and incognito is off. Nothing clears cookies while the component is mounted.injectedJavaScriptBeforeContentLoaded, defines window.BlendChannelPay exactly, and ends with true;.onMessage checks event.nativeEvent.url before reading data.orderId leaves the app. The amount your backend charges comes from Blend.Linking.openURL.goBack() first.userAgent prop..env files shipped with it, or app config.onMessage fires but nothing happens. The origin check failed. Log event.nativeEvent.url and confirm the host ends in blendapp.ai.true; is not the last statement of the shim.onShouldStartLoadWithRequest is returning false for a Blend host. tickets.blendapp.ai is Blend; the isBlendHost suffix rule covers it.userAgent prop is set. Remove it.