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

React Native

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

What you are building

  1. A component that owns one WebView

    JavaScript and DOM storage on, cookies persistent, navigation kept inside the webview for Blend's hosts, and a fresh session minted on retry.

  2. An injected bridge named BlendChannelPay

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

  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.

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.

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

Requirements

RequirementTypeDescription
react-native-webviewrequiredInstall 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 higherrequiredThe bridge on Android is a JavaScript interface, which is safe from API 17.
javaScriptEnabled, domStorageEnabledrequiredSet them explicitly on the WebView even where they default to on.
Cookies persistrequiredsharedCookiesEnabled on iOS, thirdPartyCookiesEnabled on Android, and incognito off. Blend's session is an httpOnly cookie scoped to /channel.
Default user agentrequiredDo not pass userAgent. The seat map is touch-optimised.

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.ts, talks to YOUR backend, never to Blend
/**
 * 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.
}

The component

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.

BlendCheckout.tsx
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:

Using it
import { BlendCheckout } from './BlendCheckout';

export function CheckoutScreen({ navigation, session }) {
  return (
    <BlendCheckout
      appAuthToken={session.token}
      onClose={() => navigation.goBack()}
    />
  );
}

How the message arrives

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.

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

The shim must end with true;

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.

Powered byBlend

The trust model

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

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.

  1. Origin-check before acting

    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.

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

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

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 call reload(), change source, or injectJavaScript after charging. Reference on Confirming & settlement.

Checklist

  • javaScriptEnabled and domStorageEnabled are set.
  • sharedCookiesEnabled, thirdPartyCookiesEnabled are set and incognito is off. Nothing clears cookies while the component is mounted.
  • The shim is in injectedJavaScriptBeforeContentLoaded, defines window.BlendChannelPay exactly, and ends with true;.
  • onMessage checks event.nativeEvent.url before reading data.
  • Only orderId leaves the app. The amount your backend charges comes from Blend.
  • The webview loads Blend only; other hosts leave through Linking.openURL.
  • Android back goes through goBack() first.
  • Retry after a failed first load mints a new session; it never reloads the consumed token URL.
  • No userAgent prop.
  • No Blend key or secret in the bundle, .env files shipped with it, or app config.

Troubleshooting

  • The page loads but the buyer is sent back to the start on the next screen. Cookies are not persisting. Check the three cookie props, and make sure nothing calls a cookie-clearing API while the checkout is mounted.
  • The Pay button says it cannot take payment here. The shim did not run before the page. See above.
  • onMessage fires but nothing happens. The origin check failed. Log event.nativeEvent.url and confirm the host ends in blendapp.ai.
  • A yellow-box warning about the injected script. true; is not the last statement of the shim.
  • A ticket link opens the system browser. onShouldStartLoadWithRequest is returning false for a Blend host. tickets.blendapp.ai is Blend; the isBlendHost suffix rule covers it.
  • The seat map shows a desktop layout. A userAgent prop is set. Remove it.

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 in native Swift.Android (Kotlin)The same screen in native Kotlin.
Powered byBlend© 2026 Blend · Ticketing & event infrastructure