AppbrewDevelopers
Build

The webview contract

What your web content can rely on inside the app webview.

A page opened inside an Appbrew app is a normal web page with two differences: the app injects a bridge into it before it runs, and the app intercepts some of its navigation. This page describes both, so you can predict what your page will do in-app without guessing.

If you only want to call the app, use the Web SDK. What follows is the layer underneath it, worth reading when something does not behave the way the SDK docs suggest.

The bridge

The app defines one global on your page:

window.appbrew = {
  postMessage: function (data) {
    /* hands `data` to the app */
  },
}

That is the entire surface. There is no callback, no promise, no version field, and no way to ask the app what it supports. The Web SDK builds everything else on top of this one method.

Its presence is the only reliable signal that your page is running in-app:

if (typeof window.appbrew !== 'undefined') {
  // in-app
}

Do not assume it exists at the top of the document

Most of the time the app installs the bridge before any of your scripts run. On Android WebViews too old to support document-start scripts, it is installed as the page starts loading instead, which can be after an inline script near the top of your <head>. Check for the bridge at the moment you call it rather than once at load, and treat its absence as "not in the app yet" rather than "not in the app".

The envelope

Every message you send takes the same shape:

{
  action: string                        // what you want the app to do
  payload: Record<string, any> | any[]  // arguments for that action
  actionId?: string                     // your correlation id, echoed back on replies
}

The app parses it, switches on action, and ignores anything it does not recognise. An unknown action produces no error and no reply.

How replies come back

Two actions reply. The app answers by broadcasting a message event on your page, not by calling you back:

window.addEventListener('message', (event) => {
  if (event.data.action === 'GET_USER_DETAILS_SUCCESS' && event.data.actionId === myId) {
    // event.data.payload
  }
})

Three consequences follow, and each of them explains a class of bug:

  • You must match on actionId yourself. Replies are broadcast, not routed. Two concurrent calls to the same action are told apart only by the id you sent.
  • Any script on the page can read the reply. A message listener installed by a third-party script sees the payload too. This matters most for the customer access token.
  • Failure is silence. The app never sends an error. When something goes wrong it simply does not reply, and your only signal is a timeout you implement yourself. The Web SDK waits five seconds and then resolves with { status: 'error' }.

Actions the app accepts

actionpayloadEffectReplies with
GET_USER_DETAILS{}Resolves the signed-in customerGET_USER_DETAILS_SUCCESS
ADD_TO_CARTLineItemToAdd[]Adds items to the app cartADD_TO_CART_SUCCESS, but see below
GOTO_LINKLinkNavigates the appnone
GO_BACK{}Pops the current screennone
SEND_ANALYTICS_EVENT{ event, data }Forwards to every configured trackernone
SHOW_TOAST{ kind, message, duration? }Shows an in-app toast. duration defaults to 1000 msnone
APPLY_COUPON{ code }Applies a discount code to the cart, with a toastnone
REMOVE_COUPON{ code }Removes a discount code from the cart, with a toastnone

The Web SDK wraps the first five. To use the last three, post to the bridge directly:

window.appbrew.postMessage({
  action: 'SHOW_TOAST',
  payload: { kind: 'success', message: 'Saved', duration: 2000 },
  actionId: 'toast-1',
})

ADD_TO_CART_SUCCESS is weaker than its name. The app replies once it has finished handling the request, so an error thrown on the app side still comes back as a success. A rejected or out-of-stock variant produces no reply at all. Neither outcome tells you what is in the cart, so read the cart back.

The user agent

The app sets a user agent containing Appbrew/<appname>/<ios|android>/<build>, where <appname> is lowercased with spaces removed and <build> is the build number, for example Appbrew/acmestore/android/412.

Substring match only

Most combinations append that token to the browser's own user agent. One does not: iOS running the Appbrew native webview replaces the user agent entirely, so the usual Mozilla/5.0 … tokens are absent. Do not parse the string, do not split on spaces, and do not assume a prefix. Test that the user agent contains Appbrew/.

The user agent reaches your server on the first request, which makes it the only in-app signal available before any JavaScript runs.

Some paths never load in your page. The app matches them and pushes a native screen instead:

PathWhat happens
/products/:handleOpens the native product screen for that handle
/collections/:handleOpens the native collection screen for that handle
/account/resetReplaces the page with the app's password-reset screen

Matching is not identical across apps

The two webview implementations match differently. One matches the URL's path exactly, so a nested URL such as /collections/all/products/blue-tee stays in your page. The other tests the whole URL as a substring, so that same URL opens the native product screen — and so does anything carrying the fragment in a query string, such as /search?return_url=/products/blue-tee. Keeping /products/ and /collections/ out of the path is not enough; keep them out of the query too, or take over the click and call gotoLink yourself.

So a product link in your page usually opens the native product detail screen, not your PDP. That is usually what you want. When it is not, route around it: link somewhere that does not match these patterns, or take over the click and call gotoLink yourself.

Links to other hosts open according to how the app is configured, which may be the in-app browser or the device browser. A page opened in the device browser has no bridge.

Settings that change your page

A web-view block carries a few settings that affect what your page sees. Merchants set them in Studio or through app config, not from the page.

SettingEffect on your page
source.linkThe URL loaded. Supports {{customerAccessToken}}, substituted before navigation when someone is signed in
source.hideElementsAn array of CSS selectors the app hides from document start, for trimming site chrome. The Appbrew native webview keeps watching the DOM. The other one re-runs on animation frames and stops once the total number of matched nodes equals the number of selectors you configured — a count comparison, not one match per selector. So two selectors where the first matches two nodes and the second matches none will stop with the second never applied, while a single selector matching two nodes never stops and keeps hiding new ones. Do not rely on either outcome
options.fallbackUrlPatternsExtra regexes for recovering a web URL from an app-scheme link that failed to open. The app already reads fallback_url, redirect_url, target_url, and web_url parameters without any configuration
options.debuggableEnables Safari and Chrome remote inspection of the page. Read only by the Appbrew native webview, where it defaults to on in debug builds

Version gates

The bridge grows by adding actions, and older apps drop actions they do not know. A page calling a newer action against an older app gets a five-second hang and then a timeout, with nothing in the console.

  • GET_USER_DETAILS is available on 0.26.0 and later. On 0.25 and earlier the action is dropped.
  • On 0.26 and earlier, an iOS page in an app running the Appbrew native webview, inside a block that sets hideElements, may find window.appbrew missing altogether: applying the selectors tore the bridge down. Fixed on 0.27.0. Apps on the other webview were never affected. If you support older builds and your block hides elements, check for the bridge on every call rather than once at load.

There is no capability handshake, so a page cannot ask the app what it supports. Write for the oldest app version you still serve, and treat a timeout as "this app is too old" rather than as an error worth showing the shopper.

Calling the bridge without the SDK

The Web SDK is a convenience, not a requirement. This is a complete getUserDetails in about twenty lines, if you would rather not add the script tag:

function getUserDetails(timeoutMs = 5000) {
  if (!window.appbrew) return Promise.resolve(null)

  return new Promise((resolve) => {
    const actionId = String(Date.now()) + Math.random()

    const onMessage = (event) => {
      if (
        event.data?.action === 'GET_USER_DETAILS_SUCCESS' &&
        event.data.actionId === actionId
      ) {
        clearTimeout(timer)
        window.removeEventListener('message', onMessage)
        resolve(event.data.payload)
      }
    }

    const timer = setTimeout(() => {
      window.removeEventListener('message', onMessage)
      resolve({ status: 'error', message: 'Timed out' })
    }, timeoutMs)

    window.addEventListener('message', onMessage)
    window.appbrew.postMessage({ action: 'GET_USER_DETAILS', payload: {}, actionId })
  })
}

Remove the listener on both paths. A listener left behind after a timeout fires on the next reply and resolves nothing.

On this page