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_SLUG with the Bubblehouse shop slug supplied for your integration
  • CUSTOMER_TOKEN with a token for the current customer, or an empty value for an anonymous customer
  • MARKET with the Bubblehouse market identity we have agreed upon (if your program is multi-market)
  • LOGIN_URL, SIGNUP_URL, and LOYALTY_URL with the destinations configured by Bubblehouse
  • STOREFRONT_URL with your online store’s base URL
  • yourschemanamehere with 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&amp;auth=CUSTOMER_TOKEN&amp;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&amp;auth=CUSTOMER_TOKEN&amp;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.

Previous
Overview