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
actionIdyourself. 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
messagelistener 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
action | payload | Effect | Replies with |
|---|---|---|---|
GET_USER_DETAILS | {} | Resolves the signed-in customer | GET_USER_DETAILS_SUCCESS |
ADD_TO_CART | LineItemToAdd[] | Adds items to the app cart | ADD_TO_CART_SUCCESS, but see below |
GOTO_LINK | Link | Navigates the app | none |
GO_BACK | {} | Pops the current screen | none |
SEND_ANALYTICS_EVENT | { event, data } | Forwards to every configured tracker | none |
SHOW_TOAST | { kind, message, duration? } | Shows an in-app toast. duration defaults to 1000 ms | none |
APPLY_COUPON | { code } | Applies a discount code to the cart, with a toast | none |
REMOVE_COUPON | { code } | Removes a discount code from the cart, with a toast | none |
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.
Navigation the app takes over
Some paths never load in your page. The app matches them and pushes a native screen instead:
| Path | What happens |
|---|---|
/products/:handle | Opens the native product screen for that handle |
/collections/:handle | Opens the native collection screen for that handle |
/account/reset | Replaces 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.
| Setting | Effect on your page |
|---|---|
source.link | The URL loaded. Supports {{customerAccessToken}}, substituted before navigation when someone is signed in |
source.hideElements | An 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.fallbackUrlPatterns | Extra 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.debuggable | Enables 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_DETAILSis 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 findwindow.appbrewmissing 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.

