Blocks API
Blocks API Examples
These examples show complete Blocks API integrations for websites and native apps, including handling of Block Client calls.
The full implementation might be an overkill in your use case, please ignore the parts irrelevant for your program.
Placeholders
Replace these throughout the examples:
SHOP_SLUGwith the Bubblehouse shop slug supplied for your integrationCUSTOMER_TOKENwith a token for the current customer, or an empty value for an anonymous customerMARKETwith the Bubblehouse market identity we have agreed upon (if your program is multi-market)LOGIN_URL,SIGNUP_URL, andLOYALTY_URLwith the destinations configured by BubblehouseSTOREFRONT_URLwith your online store’s base URLyourschemanameherewith the Block Client URL scheme configured for your app
Web Examples
Generate customer tokens on your server and render them into the block URL.
Plain JavaScript
This example loads bubblehouse.js, displays a server-rendered block URL in an IFRAME, and handles the Block Client calls required by a custom ecommerce integration:
<script
src="https://app.bubblehouse.com/s/SHOP_SLUG/bubblehouse.js"
async
></script>
<iframe
id="bubblehouse-rewards"
title="Bubblehouse rewards page"
src="https://app.bubblehouse.com/s/SHOP_SLUG/blocks/Rewards7?instance=bubblehouse-rewards&auth=CUSTOMER_TOKEN&market=MARKET"
loading="lazy"
height="1500"
sandbox="allow-top-navigation allow-scripts allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-same-origin"
allow="clipboard-write"
style="border: 0; width: 100%;"
></iframe>
<script>
window.addEventListener('message', async function(ev) {
const call = ev.data?.bubblehouseBlockClientCall
if (!call) {
return
}
switch (call) {
case 'ApplyDiscountCode1':
applyDiscountCode(ev.data.code)
break
case 'AddProductToCart1': {
const variantID = ev.data.variantId
if (typeof variantID !== 'string' || !variantID) {
break
}
if (!await addProductToCart(variantID)) {
break
}
const message = ev.data.successMessage
if (typeof message === 'string' && message) {
showMessage(message)
} else {
window.location.assign(ev.data.cartUrl || '/cart')
}
break
}
}
}, false)
</script>
Replace applyDiscountCode, addProductToCart, and showMessage with your ecommerce integration. addProductToCart should return true only after a successful add. If bubblehouse.js already handles these calls for your ecommerce platform, omit the custom message listener.
React (Shopify Hydrogen)
Load bubblehouse.js once from app/root.tsx, before the routed content:
import {Script} from '@shopify/hydrogen'
import type {ReactNode} from 'react'
const shop = 'SHOP_SLUG'
export function Layout({children}: {children?: ReactNode}) {
return (
<html lang="en">
<body>
<Script
src={`https://app.bubblehouse.com/s/$demo-shop/bubblehouse.js`}
async
/>
{children}
</body>
</html>
)
}
Keep the rest of your Hydrogen root layout around this structure. Do not set waitForHydration; the root should emit the asynchronous script during server rendering. Append https://app.bubblehouse.com to the existing frameSrc and scriptSrc directives of the app’s content security policy.
Generate the customer token in a Hydrogen loader, then pass it and the configured Bubblehouse market to the route component:
import {useEffect, useMemo} from 'react'
type BubblehouseRewardsProps = {
authToken: string
market: string
applyDiscountCode: (code: string) => void
addProductToCart: (variantID: string) => Promise<boolean>
showMessage: (message: string) => void
}
const shop = 'SHOP_SLUG'
const instance = 'bubblehouse-rewards'
export function BubblehouseRewards({
authToken,
market,
applyDiscountCode,
addProductToCart,
showMessage,
}: BubblehouseRewardsProps) {
const blockURL = useMemo(() => {
const url = new URL(
`https://app.bubblehouse.com/s/$demo-shop/blocks/Rewards7`
)
url.search = new URLSearchParams({
instance,
auth: authToken,
market,
}).toString()
return url.toString()
}, [authToken, market])
useEffect(() => {
async function handleBlockClientCall(ev: MessageEvent) {
const call = ev.data?.bubblehouseBlockClientCall
if (!call) {
return
}
switch (call) {
case 'ApplyDiscountCode1':
applyDiscountCode(ev.data.code)
break
case 'AddProductToCart1': {
const variantID = ev.data.variantId
if (typeof variantID !== 'string' || !variantID) {
break
}
if (!await addProductToCart(variantID)) {
break
}
const message = ev.data.successMessage
if (typeof message === 'string' && message) {
showMessage(message)
} else {
window.location.assign(ev.data.cartUrl || '/cart')
}
break
}
}
}
window.addEventListener('message', handleBlockClientCall, false)
return () => {
window.removeEventListener('message', handleBlockClientCall, false)
}
}, [addProductToCart, applyDiscountCode, showMessage])
return (
<iframe
id={instance}
title="Bubblehouse rewards page"
src={blockURL}
loading="lazy"
height={1500}
sandbox="allow-top-navigation allow-scripts allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-same-origin"
allow="clipboard-write"
style={{border: 0, width: '100%'}}
suppressHydrationWarning
/>
)
}
Keep instance equal to the IFRAME id. suppressHydrationWarning allows bubblehouse.js to resize the server-rendered IFRAME before React hydrates it. Generate authToken on the server; never put the token-signing secret in frontend code. Use the Bubblehouse market identity supplied for your integration rather than assuming it matches a Hydrogen country code.
Without Bubblehouse JS
If you do not load bubblehouse.js, handle the Block Client calls you need yourself. This example implements Resize1 so the IFRAME follows the height of its contents:
<script>
window.addEventListener('message', function(ev) {
const data = ev.data
const call = data?.bubblehouseBlockClientCall
if (!call) {
return
}
switch (call) {
case 'Resize1': {
const iframe = document.getElementById(data.instance)
if (!iframe) {
break
}
iframe.style.height = data.height + 'px'
if (!iframe.title && typeof data.title === 'string') {
const title = data.title.trim()
if (title) {
iframe.title = title
}
}
break
}
}
}, false)
</script>
<iframe
id="bubblehouse-rewards"
title="Bubblehouse rewards page"
src="https://app.bubblehouse.com/s/SHOP_SLUG/blocks/Rewards7?instance=bubblehouse-rewards&auth=CUSTOMER_TOKEN&market=MARKET"
loading="lazy"
height="1500"
sandbox="allow-top-navigation allow-scripts allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-same-origin"
allow="clipboard-write"
style="border: 0; width: 100%;"
></iframe>
Native Mobile Examples
These examples load a block with env=mobile and standalone=1, intercept the login, signup, and primary loyalty page destinations configured by Bubblehouse, and handle native Block Client callbacks. Replace the example destinations and app-specific cart and UI functions with your own integration.
Example (iOS WKWebView)
import UIKit
import WebKit
struct BlockClientCall {
let name: String
let values: [String: Any]
init?(url: URL) {
guard url.scheme?.lowercased() == "yourschemanamehere",
url.host?.lowercased() == "bubblehouse" else {
return nil
}
let path = url.path.split(separator: "/")
guard path.count == 2, path[0] == "blockclient",
let components = URLComponents(
url: url,
resolvingAgainstBaseURL: false
) else {
return nil
}
var values: [String: Any] = [:]
for item in components.queryItems ?? [] {
guard let json = item.value?.data(using: .utf8),
let value = try? JSONSerialization.jsonObject(
with: json,
options: .fragmentsAllowed
) else {
return nil
}
values[item.name] = value
}
self.name = String(path[1])
self.values = values
}
}
final class RewardsViewController: UIViewController, WKNavigationDelegate {
private let customerToken: String
private let market: String
private let loginURL = URL(string: "LOGIN_URL")!
private let signupURL = URL(string: "SIGNUP_URL")!
private let loyaltyURL = URL(string: "LOYALTY_URL")!
private let storefrontURL = URL(string: "STOREFRONT_URL")!
private let webView = WKWebView()
init(customerToken: String, market: String) {
self.customerToken = customerToken
self.market = market
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
webView.navigationDelegate = self
webView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(webView)
NSLayoutConstraint.activate([
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
webView.topAnchor.constraint(equalTo: view.topAnchor),
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
webView.load(URLRequest(url: rewardsURL()))
}
private func rewardsURL() -> URL {
var components = URLComponents(
string: "https://app.bubblehouse.com/s/SHOP_SLUG/blocks/Rewards7"
)!
components.queryItems = [
URLQueryItem(name: "instance", value: "bubblehouse-rewards"),
URLQueryItem(name: "auth", value: customerToken),
URLQueryItem(name: "market", value: market),
URLQueryItem(name: "env", value: "mobile"),
URLQueryItem(name: "standalone", value: "1"),
]
return components.url!
}
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping @MainActor @Sendable (
WKNavigationActionPolicy
) -> Void
) {
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
return
}
if matches(url, loginURL) {
decisionHandler(.cancel)
showNativeLogin()
return
}
if matches(url, signupURL) {
decisionHandler(.cancel)
showNativeSignup()
return
}
if matches(url, loyaltyURL) {
decisionHandler(.cancel)
showNativeLoyaltyPage()
return
}
guard let call = BlockClientCall(url: url) else {
decisionHandler(.allow)
return
}
decisionHandler(.cancel)
switch call.name {
case "ApplyDiscountCode1":
if let code = call.values["code"] as? String {
applyDiscountCode(code)
}
case "AddProductToCart1":
guard let variantID = call.values["variantId"] as? String,
!variantID.isEmpty else {
break
}
Task {
guard await addProductToCart(variantID: variantID) else {
return
}
if let message = call.values["successMessage"] as? String,
!message.isEmpty {
showMessage(message)
} else {
let cartURL = call.values["cartUrl"] as? String ?? ""
let url = URL(
string: cartURL.isEmpty ? "/cart" : cartURL,
relativeTo: storefrontURL
)!.absoluteURL
webView.load(URLRequest(url: url))
// or navigate to the native cart instead
}
}
default:
break
}
}
private func matches(_ url: URL, _ destination: URL) -> Bool {
url.scheme?.lowercased() == destination.scheme?.lowercased() &&
url.host?.lowercased() == destination.host?.lowercased() &&
url.port == destination.port &&
url.path == destination.path
}
}
The destination matcher intentionally ignores query parameters because Bubblehouse can add return parameters to login and signup URLs.
Example (iOS SFSafariViewController)
SFSafariViewController does not expose navigation policy callbacks. Register the Block Client scheme in the app’s URL Types and receive callbacks through the scene URL-open lifecycle. This example uses the BlockClientCall(url:) initializer from the WKWebView example above.
import SafariServices
@MainActor
func presentRewards(
from viewController: UIViewController,
customerToken: String,
market: String
) {
var components = URLComponents(
string: "https://app.bubblehouse.com/s/SHOP_SLUG/blocks/Rewards7"
)!
components.queryItems = [
URLQueryItem(name: "instance", value: "bubblehouse-rewards"),
URLQueryItem(name: "auth", value: customerToken),
URLQueryItem(name: "market", value: market),
URLQueryItem(name: "env", value: "mobile"),
URLQueryItem(name: "standalone", value: "1"),
]
let rewards = SFSafariViewController(url: components.url!)
viewController.present(rewards, animated: true)
}
extension SceneDelegate {
func scene(
_ scene: UIScene,
openURLContexts URLContexts: Set<UIOpenURLContext>
) {
for context in URLContexts {
guard let call = BlockClientCall(url: context.url) else {
continue
}
switch call.name {
case "ApplyDiscountCode1":
if let code = call.values["code"] as? String {
applyDiscountCode(code)
}
case "AddProductToCart1":
guard let variantID = call.values["variantId"] as? String,
!variantID.isEmpty else {
break
}
Task {
guard await addProductToCart(variantID: variantID) else {
return
}
if let message = call.values["successMessage"] as? String,
!message.isEmpty {
showMessage(message)
} else {
let cartURL =
call.values["cartUrl"] as? String ?? ""
let storefrontURL = URL(string: "STOREFRONT_URL")!
let url = URL(
string: cartURL.isEmpty ? "/cart" : cartURL,
relativeTo: storefrontURL
)!.absoluteURL
await UIApplication.shared.open(url)
// or navigate to the native cart instead
}
}
default:
break
}
}
}
}
Apps without scenes can decode the URL in application(_:open:options:) instead. Because SFSafariViewController cannot intercept normal HTTP or HTTPS navigation, hide login and signup controls or use app-opening destinations when those controls should launch native UI.
Example (Android WebView)
import android.net.Uri
import android.os.Bundle
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity
import org.json.JSONException
import org.json.JSONObject
import org.json.JSONTokener
import java.net.URL
data class BlockClientCall(
val name: String,
val values: JSONObject,
) {
companion object {
fun from(uri: Uri): BlockClientCall? {
if (!uri.scheme.equals("yourschemanamehere", ignoreCase = true) ||
!uri.host.equals("bubblehouse", ignoreCase = true)) {
return null
}
val path = uri.pathSegments
if (path.size != 2 || path[0] != "blockclient") {
return null
}
val values = JSONObject()
for (key in uri.queryParameterNames) {
val json = uri.getQueryParameter(key) ?: return null
val value = try {
JSONTokener(json).nextValue()
} catch (_: JSONException) {
return null
}
values.put(key, value)
}
return BlockClientCall(path[1], values)
}
}
}
class RewardsActivity : AppCompatActivity() {
private lateinit var webView: WebView
private val loginURL = Uri.parse("LOGIN_URL")
private val signupURL = Uri.parse("SIGNUP_URL")
private val loyaltyURL = Uri.parse("LOYALTY_URL")
private val storefrontURL = URL("STOREFRONT_URL")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
webView = WebView(this)
setContentView(webView)
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest,
): Boolean {
val url = request.url
if (matches(url, loginURL)) {
showNativeLogin()
return true
}
if (matches(url, signupURL)) {
showNativeSignup()
return true
}
if (matches(url, loyaltyURL)) {
showNativeLoyaltyPage()
return true
}
val call = BlockClientCall.from(url) ?: return false
when (call.name) {
"ApplyDiscountCode1" ->
applyDiscountCode(call.values.getString("code"))
"AddProductToCart1" -> {
val variantID =
call.values.opt("variantId") as? String ?: return true
if (variantID.isEmpty()) return true
addProductToCart(variantID = variantID) { added ->
if (!added) return@addProductToCart
val message =
call.values.opt("successMessage") as? String
val cartURL =
(call.values.opt("cartUrl") as? String)
.orEmpty()
.ifEmpty { "/cart" }
view.post {
if (!message.isNullOrEmpty()) {
showMessage(message)
} else {
view.loadUrl(
URL(
storefrontURL,
cartURL,
).toString()
)
// or navigate to the native cart instead
}
}
}
}
}
return true
}
}
val rewardsURL = Uri.parse(
"https://app.bubblehouse.com/s/SHOP_SLUG/blocks/Rewards7"
).buildUpon()
.appendQueryParameter("instance", "bubblehouse-rewards")
.appendQueryParameter("auth", "CUSTOMER_TOKEN")
.appendQueryParameter("market", "MARKET")
.appendQueryParameter("env", "mobile")
.appendQueryParameter("standalone", "1")
.build()
webView.loadUrl(rewardsURL.toString())
}
private fun matches(url: Uri, destination: Uri): Boolean =
url.scheme.equals(destination.scheme, ignoreCase = true) &&
url.host.equals(destination.host, ignoreCase = true) &&
url.port == destination.port &&
url.path == destination.path
}
The WebResourceRequest overload requires Android API 24 or later. Add the deprecated string overload if your app supports older Android versions.