Native bridge (web ⇄ app)

Call the app from your page and receive events back, in both directions

The native bridge is a two-way channel between your website and the app. Your page asks the app something with WebToApp.call(method, params) and gets a promise back. The app tells your page when something happens with WebToApp.on(name, handler): it came to the foreground, the network changed, a notification was tapped, a rewarded ad finished, a purchase went through. It is always available in every app build, there is nothing to switch on in the dashboard, and it is also the transport behind the RevenueCat add-on.

Your existing code keeps working

The native bridge is built on the same window.FlutterWebView.postMessage as the JavaScript Bridge. Legacy messages such as userId, showRewardedAd and getLocation, and their DOM events, work unchanged. The new API adds to them; you do not have to migrate anything. Apps built before the native bridge existed do not have WebToApp.call: request a new build to get it.

Wait for the bridge

The app installs the client (window.WebToApp.call, .on, …) on every page and then fires a WebToAppBridgeReady event on window. In a regular browser none of this exists: window.WebToApp and window.FlutterWebView are undefined and the event never fires, so use a short timeout to decide that you are not in the app.

function whenBridgeReady(timeoutMs) {
  return new Promise(function (resolve) {
    if (window.WebToApp && typeof window.WebToApp.call === 'function') {
      resolve(true);                       // already installed
      return;
    }
    var timer = setTimeout(function () { resolve(false); }, timeoutMs || 1500);
    window.addEventListener('WebToAppBridgeReady', function () {
      clearTimeout(timer);
      resolve(true);
    }, { once: true });
  });
}

async function main() {
  if (!(await whenBridgeReady())) return;  // regular browser

  const info = await WebToApp.call('app.getInfo');
  // → { bridgeVersion: 2, platform: 'ios', capabilities: { purchases: true, paywalls: true, push: 'fcm', ads: false } }
}
main();

Android: do not assume it exists at parse time

On iOS the client is injected before your page runs. On Android it is injected when the page starts loading, which can be after your first inline script has already run. Always wait for WebToAppBridgeReady (or use the hosted helper, which does) instead of calling WebToApp.call straight away.

The WebToApp API

MemberDescription
WebToApp.bridgeVersion2
WebToApp.call(method, params?, { timeoutMs? })Returns a promise. Default timeout is 30 000 ms (10 minutes for paywall and purchase calls). On failure it rejects with an Error that has .code (and .details), see the errors below.
WebToApp.on(name, handler)Subscribe to an event from the app. handler(data, { name, ts }), where ts is epoch milliseconds. Returns a function that unsubscribes. The name '*' receives every event.
WebToApp.once(name, handler), WebToApp.off(name, handler)Subscribe for one event only, or remove a handler.
WebToApp.last(name)The last data received for that event since this page loaded, or undefined. Use it to read state you subscribed too late to see.

window.WebToApp already carries platform, isMobileApp(), getPlatform() and Compliance (see App Detection); the bridge adds the members above.

// Call with a custom timeout
const state = await WebToApp.call('app.getState', {}, { timeoutMs: 5000 });

// Subscribe, unsubscribe, one-shot, and read the last value
const off = WebToApp.on('app.background', function () { saveDraft(); });
off();                                                  // stop listening
WebToApp.once('app.foreground', function () { refreshSession(); });
const lastNetwork = WebToApp.last('network.changed');   // { online, connection } or undefined

// Debug: log everything the app sends
WebToApp.on('*', function (data, meta) { console.log(meta.name, data, meta.ts); });

How it travels

You normally never touch this: the injected client takes care of ids and timeouts. It is documented so you can debug what goes over the wire.

DirectionMechanism
Web → AppFlutterWebView.postMessage(JSON.stringify({ type: 'wta.call', id, method, params }))
App → Web (answer)WebToAppResponse event on window, with detail = { id, ok: true, result } or { id, ok: false, error: { code, message } }
App → Web (event)WebToAppEvent event on window, with detail = { name, data, ts }

Methods

app.* methods always exist. purchases.* methods need the RevenueCat add-on enabled for that platform; otherwise they reject with not_enabled.

MethodParamsResult
app.getInfonone{ bridgeVersion: 2, platform: 'ios' | 'android', capabilities: { purchases: boolean, paywalls: boolean, push: 'onesignal' | 'fcm' | null, ads: boolean } }
app.getStatenone{ lifecycle: 'foreground' | 'background', online: boolean, connection: 'wifi' | 'cellular' | 'ethernet' | 'other' | 'none' }
purchases.getOfferingsnoneOfferings
purchases.purchase{ packageId, offeringId? } (offeringId defaults to the current offering){ customerInfo, productIdentifier, transactionId }
purchases.restorenoneCustomerInfo
purchases.getCustomerInfononeCustomerInfo
purchases.logIn{ userId }{ customerInfo, created }
purchases.logOutnoneCustomerInfo (anonymous). A no-op result if already anonymous.
purchases.getAppUserIdnone{ appUserId, isAnonymous }
purchases.setAttributes{ attributes?: { [key]: string | null }, email?, displayName? }{}
purchases.presentPaywall{ offeringId?, requiredEntitlement?, displayCloseButton? }{ result: 'purchased' | 'restored' | 'cancelled' | 'error' | 'notPresented', customerInfo }. With requiredEntitlement the paywall only appears if the user lacks it (notPresented otherwise).
purchases.presentCustomerCenternone{}, resolved when the user closes it

Offerings and CustomerInfo are described in the RevenueCat guide.

Events

EventdataWhen
purchases.customerInfoUpdatedCustomerInfoAfter setup, after every purchase, restore, log in and log out, and whenever RevenueCat reports a change (renewal, expiry, refund, a deferred purchase approved)
app.foreground, app.background{}The app comes to the foreground or goes to the background. Refresh sessions and entitlements on app.foreground.
network.changed{ online, connection }Connectivity changed
push.received{ provider: 'fcm' | 'onesignal', title, body, data }A notification arrives while the app is in the foreground
push.opened{ provider, title, body, data, openUrl }The user taps a notification. openUrl is the openUrl from the notification's data, or null.
ads.rewardEarned{ amount, type }The user finished a rewarded ad that you started with the showRewardedAd message

Events are fire-and-forget and are not replayed. A handler added after an event fired will not see it. WebToApp.last(name) only covers events since the current page loaded, so on a fresh page ask for the state instead: app.getState or purchases.getCustomerInfo.

Errors

Every rejection carries a code (a snake_case string) and a message.

CodeMeaning
timeoutNo answer within timeoutMs (raised by the client in your page).
unsupported_methodThis build does not know the method (the app was built before it shipped).
invalid_paramsA parameter is missing or has the wrong type.
not_enabledThe integration is off for this app, or this platform has no key.
paywalls_disabledpaywalls is off in the RevenueCat settings.
purchase_cancelledThe user dismissed the purchase sheet. Not a failure: do not show an error.
payment_pendingThe purchase is deferred (Ask to Buy, a slow payment method). The entitlement arrives later on purchases.customerInfoUpdated.
product_already_purchasedThe user already owns it: call purchases.restore.
network_errorOffline, or RevenueCat could not be reached.
store_problemAn App Store or Google Play error.
not_allowedPurchases are disabled on the device (parental controls and similar).
invalid_packagepackageId or offeringId was not found in the offerings.
unknownAnything else; read message for the details.
try {
  const info = await WebToApp.call('purchases.getCustomerInfo');
} catch (e) {
  if (e.code === 'not_enabled' || e.code === 'unsupported_method') {
    // RevenueCat is not set up for this build: hide the purchase UI
  } else if (e.code === 'timeout') {
    // the app did not answer in time
  } else {
    console.error(e.code, e.message);
  }
}

Worked example: a two-way flow

Your page asks the app for its state, then keeps itself in sync with what happens natively: refresh the session when the user comes back, react to a notification tap, reward a finished ad and show an offline banner. refreshSession, showOfflineBanner, trackEvent and grantCredits stand for your own code.

async function setupNativeBridge() {
  // 0. Inside the app? (a regular browser never gets the bridge)
  if (!(await whenBridgeReady(1500))) return;

  // 1. Web asks the app for its state
  const state = await WebToApp.call('app.getState');
  // → { lifecycle: 'foreground', online: true, connection: 'wifi' }
  showOfflineBanner(!state.online);

  // 2. App tells the web it is back in the foreground: refresh the session
  WebToApp.on('app.foreground', function () {
    refreshSession();
  });

  // 3. Connectivity changes while the app is open
  WebToApp.on('network.changed', function (data) {
    showOfflineBanner(!data.online);       // data = { online, connection }
  });

  // 4. The user tapped a notification. The app opens openUrl itself;
  //    use the event for analytics or to update the page you are on.
  WebToApp.on('push.opened', function (data) {
    // data = { provider, title, body, data, openUrl }
    trackEvent('push_opened', { provider: data.provider, title: data.title });
  });

  // 5. A rewarded ad finished: the web starts it, the app reports the reward
  WebToApp.on('ads.rewardEarned', function (reward) {
    grantCredits(reward.amount);           // reward = { amount, type }
  });
  document.getElementById('watch-ad').addEventListener('click', function () {
    window.FlutterWebView.postMessage(JSON.stringify({ type: 'showRewardedAd' }));
  });
}

setupNativeBridge();

This example uses whenBridgeReady from above. showRewardedAd needs the AdMob add-on with a rewarded ad unit, see the AdMob guide.

Troubleshooting

  • WebToApp.call is not a function / WebToApp is undefined: you are in a regular browser, the script ran before the Android client was injected (wait for WebToAppBridgeReady), or the app was built before the native bridge shipped (request a new build).
  • unsupported_method: the app does not know that method yet. Request a new build.
  • not_enabled on a purchases.* call: RevenueCat is off, or this platform has no key. Check capabilities.purchases from app.getInfo, then see the RevenueCat guide.
  • A handler never fires: events are not replayed, so subscribe as early as possible, and read WebToApp.last(name) or ask with app.getState for what you missed. Also check you are subscribing to the exact event name, or to '*' to see everything.
  • timeout: the app did not answer within timeoutMs. Raise it for slow calls, or check that the page is still loaded in the app.