Create Free APK

JavaScript bridge API

Purchases and entitlements

Selling from your own page, and asking what this buyer already owns. See the in-app purchases guide for the code around these.

AppMint.revenuecat helper#

AppMint.revenuecat — RevenueCat subscriptions

Example

Subscriptions and one-time purchases through your RevenueCat project: offerings, purchases, restores and entitlements. Purchases run through Google Play in the app; RevenueCat records each one before the app finishes it.

Returns: every method returns a Promise that always resolves {ok: true, …} or {ok: false, error, message?}. configure({appUserId?}), getCustomerInfo(), restore(), logIn(id), logOut() → {ok, customerInfo}; getOfferings() → {ok, current, all}; purchase(pkgOrProductId, {offeringId?, offerId?, consumable?}) → {ok, productId, transaction, customerInfo}; getAppUserId() → {ok, appUserId}. Needs: Integrate step → RevenueCat with your PUBLIC Google Play key (goog_…).

if (window.AppMint && AppMint.revenuecat) {
  await AppMint.revenuecat.configure({ appUserId: currentUser && currentUser.id });   // omit for an anonymous user
  var off = await AppMint.revenuecat.getOfferings();
  if (off.ok && off.current) {
    off.current.availablePackages.forEach((p) => addPlanButton(p.identifier, p.title, p.price, p.period));
  }
}

async function buy(pkg) {                        // a package object from getOfferings, or its identifier
  var r = await AppMint.revenuecat.purchase(pkg);
  if (r.ok) unlock(r.customerInfo.entitlements.active);
  else if (r.error === 'pending') alert('Payment pending — it will unlock when Google confirms it.');
  else if (!r.cancelled) alert('Purchase failed: ' + (r.message || r.error));
}

var info = await AppMint.revenuecat.getCustomerInfo();
var isPro = info.ok && !!info.customerInfo.entitlements.active.pro;

window.addEventListener('appmint:revenuecat', (e) => render(e.detail.customerInfo));   // entitlements changed
document.getElementById('restore').onclick = () => AppMint.revenuecat.restore();

Tie purchases to your own account:

if (window.AppMint && AppMint.revenuecat) {
  var li = await AppMint.revenuecat.logIn(user.id);       // after sign-in
  if (li.ok) refreshPlan(li.customerInfo.entitlements.active);
  document.getElementById('signout').onclick = async function () {
    await AppMint.revenuecat.logOut();
    showAnonymous();
  };
}

Show an honest message for each failure:

async function buyPackage(pkg) {
  var r = await AppMint.revenuecat.purchase(pkg);
  if (r.ok) return unlock(r.customerInfo.entitlements.active);
  if (r.cancelled || r.error === 'cancelled') return;
  var text = {
    not_configured: 'Purchases are not set up in this build',
    billing_unavailable: 'Google Play billing is not available on this phone',
    product_not_found: 'This plan is not available right now',
    network: 'No connection - try again online'
  }[r.error] || ('Purchase failed: ' + (r.message || r.error));
  alert(text);
}

Notes: customerInfo = {appUserId, originalAppUserId, requestDate, managementUrl, entitlements: {active, all}, activeSubscriptions, allPurchasedProductIds, nonSubscriptionTransactions}; each entitlement {identifier, isActive, productIdentifier, expirationDate, latestPurchaseDate, willRenew, periodType, store, isSandbox}. Pass {consumable: true} for coins/credits so they can be bought again; subscriptions and one-time unlocks are acknowledged. Offline, getCustomerInfo answers the last known info with stale: true; a purchase made while RevenueCat is unreachable is still finished and its receipt is sent on the next configure/restore (receiptQueued: true). Errors: not_configured, network, revenuecat_error (with status, code, message), billing_unavailable, billing_error, product_not_found, offer_not_found, cancelled, pending, already_anonymous, foreign_origin. Set up RevenueCat's Google Play service-account connection as RevenueCat asks; never put a secret sk_ key in an app. Existing AppMintIAP purchases keep working unchanged.

AppMintIAP.available helper#

window.AppMintIAP.available

Example

A property (not a function): true when this page can really buy through Google Play.

Returns: a boolean property. Needs: 💰 Sell In-App Products. Without it, window.AppMintIAP does not exist at all.

Check it before you show anything for sale, and say plainly when buying is not possible here:

var inApp = !!(window.AppMintIAP && window.AppMintIAP.available);
var buyBtn = document.getElementById('buy-pro');

if (!inApp) {
  buyBtn.disabled = true;
  buyBtn.textContent = 'Available in the Android app';
}

One upgrade button for your website and your app: Google Play inside the app, your own checkout on the web:

document.getElementById('upgrade').addEventListener('click', function () {
  var iap = window.AppMintIAP;
  if (iap && iap.available) {
    iap.buy('pro_unlock');                                   // inside the app
  } else if (window.WebToApk) {
    showMessage('Upgrades are not available in this version of the app.');
  } else {
    location.href = '/checkout/pro';                         // your website's own checkout
  }
});

In React or Next.js, read it inside an effect (the server render has no window), so nothing to buy is rendered outside the app:

import { useEffect, useState } from 'react';

export function useCanBuy() {
  const [canBuy, setCanBuy] = useState(false);     // false during the server render
  useEffect(() => {
    setCanBuy(!!(window.AppMintIAP && window.AppMintIAP.available));
  }, []);
  return canBuy;
}

// const canBuy = useCanBuy();
// return canBuy ? <UpgradeButton /> : null;

Notes: window.AppMintIAP is installed before your page's first script runs; only on WebViews older than about 2021 does it arrive once the page has finished loading, so code that must also run there should wait for it (see the whenIap pattern in AppMintIAP.sell). The same value is in AppMintIAP.state().available. Wrapping the offer in data-iap-container hides it outside the app with no code.

AppMintIAP.buy helper#

window.AppMintIAP.buy(id)

Example

Opens Google Play's purchase sheet for a product, from your own button. It grants nothing by itself.

Returns: nothing. The result arrives as appmint:purchase {productId, owned:true} (and through AppMintIAP.onChange) or appmint:purchase-failed {productId, reason}. Needs: 💰 Sell In-App Products and the product active in Play Console.

Use it when you draw the button yourself; the unlock comes from ownership, not from the tap:

var btn = document.getElementById('buy-pro');

btn.addEventListener('click', function () {
  if (!window.AppMintIAP || !window.AppMintIAP.available) { showMessage('Available in the Android app.'); return; }
  btn.disabled = true;
  window.AppMintIAP.buy('pro_unlock');
});

window.addEventListener('appmint:purchase', function (e) {
  if (e.detail.productId === 'pro_unlock') unlockPro();     // must be safe to run twice
  btn.disabled = false;
});
window.addEventListener('appmint:purchase-failed', function (e) {
  btn.disabled = false;
  showMessage(e.detail.reason === 'pending' ? 'Payment pending. It unlocks once Google confirms.' : 'Purchase failed: ' + e.detail.reason);
});
document.addEventListener('visibilitychange', function () { if (!document.hidden) btn.disabled = false; });   // cancel: no event

A plan picker with one "Continue" button that buys whichever plan is selected:

document.getElementById('continue').addEventListener('click', function () {
  var choice = document.querySelector('input[name="plan"]:checked');   // value = product ID
  var iap = window.AppMintIAP;
  if (!choice || !iap || !iap.available) return;
  if (iap.isOwned(choice.value)) { showMessage('You already have this plan.'); return; }
  iap.buy(choice.value);
});

window.addEventListener('appmint:purchase', function () {
  document.getElementById('plan-picker').hidden = true;
  showMessage('Thanks! Your plan is active.');
});

Tapping a Pro-only tool runs it when owned, and otherwise opens the purchase sheet:

document.querySelectorAll('[data-pro-tool]').forEach(function (btn) {
  btn.addEventListener('click', function () {
    var iap = window.AppMintIAP;
    if (iap && iap.isOwned('pro_unlock')) { runTool(btn.getAttribute('data-pro-tool')); return; }
    if (iap && iap.available) iap.buy('pro_unlock');
    else showMessage('Pro tools are available in the Android app.');
  });
});

window.addEventListener('appmint:purchase', function (e) {
  if (e.detail.productId === 'pro_unlock') document.body.classList.add('pro');
});

Notes: buy() is the bare WebToApk.purchase(id): no busy label, no group check. Buttons wired with sell(), start() or data-iap-buy do all of that for you, so prefer those.

AppMintIAP.isOwned helper#

window.AppMintIAP.isOwned(id)

Example

Checks, instantly and offline, whether the user owns a product (a one-time product, or a subscription that is still active).

Returns: true / false, synchronously; false outside the app. Needs: 💰 Sell In-App Products.

function paintPro() {
  var pro = !!(window.AppMintIAP && window.AppMintIAP.isOwned('pro_unlock'));
  document.getElementById('pro-tools').hidden = !pro;
}

// Re-check whenever ownership can change.
window.addEventListener('appmint:purchase', paintPro);
window.addEventListener('appmint:owned-changed', paintPro);

// AppMintIAP arrives after the page has loaded: paint once it is there.
var waitIap = setInterval(function () {
  if (window.AppMintIAP) { clearInterval(waitIap); paintPro(); }
}, 200);
setTimeout(function () { clearInterval(waitIap); }, 10000);   // not in the app, or selling is off

Pro through any of several plans: a monthly, a yearly and a lifetime product all count:

var PRO_PLANS = ['pro_monthly', 'pro_yearly', 'pro_lifetime'];

function isPro() {
  var iap = window.AppMintIAP;
  return !!iap && PRO_PLANS.some(function (id) { return iap.isOwned(id); });
}

function paintPlan() { document.body.classList.toggle('pro', isPro()); }
paintPlan();
window.addEventListener('appmint:purchase', paintPlan);
window.addEventListener('appmint:owned-changed', paintPlan);   // an expired subscription or a refund

A route guard: Pro-only screens send a user without Pro to the upgrade screen:

function route() {
  var path = location.hash.slice(1) || '/';
  var proOnly = path.indexOf('/reports') === 0;
  if (proOnly && !(window.AppMintIAP && window.AppMintIAP.isOwned('pro_unlock'))) {
    location.replace('#/upgrade');
    return;
  }
  render(path);
}

window.addEventListener('hashchange', route);
route();

Notes: Same answer as WebToApk.isOwned(id). Ownership is restored from the user's Google account at every launch and saved on the phone, so it survives reinstalls and works offline. Never keep the entitlement only in localStorage.

AppMintIAP.onChange helper#

window.AppMintIAP.onChange(fn)

Example

Calls your function with the purchase state right away, and again every time it changes (prices arrived, a purchase landed, the launch restore, a refund, an expired subscription).

Returns: an unsubscribe function. fn gets {available, adFree, owned, ownedIds, products}; products is {id: {price, currency, type, period, owned}} for the products the helper knows (from sell, start or data-iap-buy). Needs: 💰 Sell In-App Products.

A React hook. It returns the unsubscribe function straight from the effect:

import { useEffect, useState } from 'react';

type IapState = { available: boolean; adFree: boolean; owned: boolean; ownedIds: string[];
  products: Record<string, { price: string | null; currency: string | null; type: string | null; period: string | null; owned: boolean }> };

export function useIap(ids: string[]) {
  const [iap, setIap] = useState<IapState>({ available: false, adFree: false, owned: false, ownedIds: [], products: {} });
  useEffect(() => {
    let stop: (() => void) | undefined;
    const t = setInterval(() => {                   // AppMintIAP arrives after page load
      const api = (window as any).AppMintIAP;
      if (!api) return;
      clearInterval(t);
      ids.forEach((id) => api.sell(id, null));      // track them, so their prices are fetched
      stop = api.onChange(setIap);
    }, 200);
    return () => { clearInterval(t); if (stop) stop(); };
  }, []);
  return iap;
}

// const iap = useIap(['pro_unlock']);
// const isPro = iap.ownedIds.includes('pro_unlock');
// const price = iap.products.pro_unlock?.price;

A plain-JavaScript paywall: track two plans without buttons, then draw their prices and the current plan from every change:

var iap = window.AppMintIAP;
var PLANS = ['pro_monthly', 'pro_yearly'];

if (iap) {
  iap.start({ plans: PLANS.map(function (id) { return { id: id }; }) });   // tracked, not drawn
  iap.onChange(function (s) {
    document.getElementById('paywall').hidden = !s.available;
    PLANS.forEach(function (id) {
      var el = document.querySelector('[data-plan="' + id + '"]');
      var p = s.products[id];
      el.hidden = !p;                                // Google has not answered for it (yet)
      if (p) el.querySelector('.price').textContent = p.owned ? 'Your plan' : p.price;
    });
  });
}

Subscribe while an account screen is open and unsubscribe when it closes. adFree and ownedIds are filled in once the helper is running (the page uses sell(), start() or data-iap-buy):

var stopAccount = null;

function openAccountScreen() {
  if (window.AppMintIAP) {
    stopAccount = window.AppMintIAP.onChange(function (s) {
      document.getElementById('ad-status').textContent = s.adFree ? 'Ad-free' : 'With ads';
      document.getElementById('owned-count').textContent = s.ownedIds.length + ' purchase(s)';
    });
  }
  showScreen('account');
}

function closeAccountScreen() {
  if (stopAccount) { stopAccount(); stopAccount = null; }   // no updates for a hidden screen
  showScreen('home');
}

Notes: Read ownership per product (ownedIds or products[id].owned). The top-level owned only means "anything is owned". adFree is the Remove Ads entitlement (WebToApk.isPremium()). A product is in products only after Google answered for it.

AppMintIAP.owned helper#

window.AppMintIAP.owned()

Example

Lists every product ID the user owns.

Returns: an array of product IDs, synchronously, for example ['pro_unlock', 'level_pack_2']; [] outside the app or when nothing is owned. Needs: 💰 Sell In-App Products.

function unlockOwnedLevels() {
  var ids = window.AppMintIAP ? window.AppMintIAP.owned() : [];
  document.querySelectorAll('[data-level-pack]').forEach(function (el) {
    el.classList.toggle('locked', ids.indexOf(el.getAttribute('data-level-pack')) === -1);
  });
}

window.addEventListener('appmint:owned-changed', unlockOwnedLevels);   // restore or refund changed the list
window.addEventListener('appmint:purchase', unlockOwnedLevels);

Count how many levels are open when every level pack is its own product:

function unlockedLevelCount() {
  var ids = window.AppMintIAP ? window.AppMintIAP.owned() : [];
  var packs = ids.filter(function (id) { return /^level_pack_\d+$/.test(id); }).length;
  return 10 + packs * 10;                         // 10 free levels, 10 more per pack
}

function paintLevelCount() {
  document.getElementById('levels-label').textContent = unlockedLevelCount() + ' levels unlocked';
}
paintLevelCount();
window.addEventListener('appmint:owned-changed', paintLevelCount);

A "My purchases" list on your settings screen, with your own name for each product:

var LABELS = { pro_unlock: 'Pro features', remove_ads: 'No ads', level_pack_2: 'Levels 11-20' };

function renderPurchases() {
  var ids = window.AppMintIAP ? window.AppMintIAP.owned() : [];
  var list = document.getElementById('purchases');
  list.textContent = '';
  ids.forEach(function (id) {
    var li = document.createElement('li');
    li.textContent = LABELS[id] || id;
    list.appendChild(li);
  });
  document.getElementById('no-purchases').hidden = ids.length > 0;
}
renderPurchases();
window.addEventListener('appmint:owned-changed', renderPurchases);

Notes: Parsed from WebToApk.getOwnedProducts() for you. Includes active subscriptions and the Remove Ads product. Works offline.

AppMintIAP.refresh helper#

window.AppMintIAP.refresh()

Example

Looks again for data-iap-buy buttons that were added since the last scan, and wires them (or starts the helper if it had found none yet).

Returns: the AppMintIAP object. Needs: 💰 Sell In-App Products.

The helper already watches the page for new data-iap-buy buttons, so you rarely need this. Call it after you insert buttons in a way the watcher could miss, for example by changing attributes on an existing element:

function showShop() {
  var btn = document.getElementById('buy-coins');
  btn.setAttribute('data-iap-buy', 'coins_500');          // attribute added to an EXISTING node
  btn.setAttribute('data-iap-label', '500 coins — {price}');
  if (window.AppMintIAP) window.AppMintIAP.refresh();
}

Shop cards whose products come from your own server: the buttons are already on the page, only their attributes change, so wire them with refresh():

fetch('/shop.json').then(function (r) { return r.json(); }).then(function (shop) {
  shop.items.forEach(function (item) {           // [{slot, productId, name}]
    var btn = document.querySelector('.shop-card[data-slot="' + item.slot + '"] button');
    if (!btn) return;
    btn.setAttribute('data-iap-buy', item.productId);
    btn.setAttribute('data-iap-label', item.name + ' - {price}');
  });
  if (window.AppMintIAP) window.AppMintIAP.refresh();
});

A holiday bundle that goes on sale at a set time, on a button that was on the page from the start:

var saleStart = new Date('2026-12-01T00:00:00').getTime();

function openSale() {
  var btn = document.getElementById('sale-btn');
  btn.setAttribute('data-iap-buy', 'holiday_bundle');
  btn.setAttribute('data-iap-owned', 'Bundle unlocked');
  document.getElementById('sale-card').hidden = false;
  if (window.AppMintIAP) window.AppMintIAP.refresh();
}

var wait = saleStart - Date.now();
if (wait <= 0) openSale();
else if (wait < 86400000) setTimeout(openSale, wait);   // later than a day: the next launch opens it

Notes: Each new button gets its price from Google and its owned state. A button that is already wired is not wired twice. Buttons whose element was removed from the page are dropped.

AppMintIAP.sell helper#

window.AppMintIAP.sell(id, button, opts)

Example

Wires one product to one button in a single line. Call it once per product.

Returns: the AppMintIAP object (for chaining). Needs: 💰 Sell In-App Products in the build wizard and the product created and active in Play Console. window.AppMintIAP is installed before your page's first script runs (on WebViews older than about 2021, once the page has finished loading - the whenIap pattern below covers both).

function whenIap(fn) {
  if (window.AppMintIAP) { fn(window.AppMintIAP); return; }
  var tries = 0;
  var t = setInterval(function () {
    if (window.AppMintIAP) { clearInterval(t); fn(window.AppMintIAP); }
    else if (++tries > 50) clearInterval(t);          // not in the app, or selling is off
  }, 200);
}

whenIap(function (iap) {
  iap.sell('pro_unlock', '#buy-pro', {
    label: 'Unlock Pro — {price}',     // {price} = Google's live, localized price
    owned: '✓ Unlocked',               // the button text once owned
    show: '#thanks',                   // shown once owned
    hide: '.upsell'                    // hidden once owned
  });
  iap.sell('coins_500', '#buy-coins', { label: '500 coins — {price}' });
});

Several plans where buying one replaces the others share a group:

whenIap(function (iap) {
  iap.sell('pass_monthly', '#monthly', { label: 'Monthly — {price}', group: 'pass' })
     .sell('pass_yearly', '#yearly', { label: 'Yearly — {price}', group: 'pass' });
});

Track a product with no button and draw the offer yourself (null wires nothing; your button calls buy):

whenIap(function (iap) {
  iap.sell('remove_ads', null);                      // tracked, never drawn by the helper
  iap.onChange(function (s) {
    var p = s.products.remove_ads;                   // undefined until Google answers
    var btn = document.getElementById('remove-ads');
    if (!s.available || (p && p.owned)) { btn.hidden = true; return; }
    btn.hidden = false;
    btn.textContent = p && p.price ? 'Remove ads for ' + p.price : 'Remove ads';
  });
  document.getElementById('remove-ads').addEventListener('click', function () {
    iap.buy('remove_ads');
  });
});

Wire a button that only exists once your shop dialog opens (a late call joins the running helper):

document.getElementById('open-shop').addEventListener('click', function () {
  openShopDialog();                                  // your code: adds <button id="buy-levels">
  if (!window.AppMintIAP) return;                    // not in the app
  window.AppMintIAP.sell('level_pack_2', '#buy-levels', {
    label: 'Levels 11-20 — {price}',
    owned: '✓ Levels 11-20 unlocked',
    show: '#levels-2-link'                           // your menu entry, shown once owned
  });
});

Notes: button is an element or a CSS selector; pass null to track a product without a button (your own UI reads AppMintIAP.state()). Other opts: labelEl (the element inside the button whose text changes). It works before or after the helper started, including from a React effect. The same as the data-iap-buy attributes (see AppMintIAP.start). Unlock paid content only from ownership (isOwned, onChange), never in your own click handler.

AppMintIAP.start helper#

window.AppMintIAP.start(config) and the data-iap-* attributes

Example

Wires your buy buttons to Google Play in one step: live prices on the buttons, the purchase sheet on tap, "owned" labels, thank-you blocks, restore at launch, and a visible message for every failure.

Returns: the AppMintIAP object (for chaining). Needs: 💰 Sell In-App Products in the build wizard and the product IDs created and active in Play Console. The app installs window.AppMintIAP before your page's first script runs (on WebViews older than about 2021, only once the page has finished loading).

No code at all: mark the HTML. The helper reads these attributes by itself as soon as it is injected, and watches for buttons that React or Vue render later:

<div data-iap-container>                              <!-- shown only inside the app -->
  <button data-iap-buy="pass_monthly" data-iap-group="pass"
          data-iap-label="Monthly — {price}" data-iap-owned="Active">Monthly</button>
  <button data-iap-buy="pass_lifetime" data-iap-group="pass"
          data-iap-label="Lifetime — {price}" data-iap-owned="Owned">
    <img src="star.svg" alt=""> <span data-iap-text>Lifetime</span>   <!-- only this text changes -->
  </button>
  <button data-iap-buy="coins_500" data-iap-label="500 coins — {price}">500 coins</button>
</div>

<p data-iap-show="pass" hidden>You are a member. Thank you!</p>   <!-- shown when any "pass" plan is owned -->
<p data-iap-hide="pass">Members get every level and no ads.</p>    <!-- hidden once it is owned -->

With code: call start() yourself when you want your own message function or unlock hooks. Use it on pages WITHOUT data-iap-buy buttons in the HTML (with them, the helper has already started and a second start does nothing):

function whenIap(fn) {                               // AppMintIAP arrives after page load
  if (window.AppMintIAP) { fn(window.AppMintIAP); return; }
  var tries = 0;
  var t = setInterval(function () {
    if (window.AppMintIAP) { clearInterval(t); fn(window.AppMintIAP); }
    else if (++tries > 50) clearInterval(t);         // not in the app, or selling is off
  }, 200);
}

whenIap(function (iap) {
  iap.start({
    plans: [
      { id: 'pro_unlock', button: '#buy-pro', label: 'Unlock Pro — {price}', owned: '✓ Pro' },
      { id: 'coins_500', button: '#buy-coins', label: '500 coins — {price}' }
    ],
    show: '#pro-area',                 // shown once anything is owned
    hide: '.upsell',                   // hidden once anything is owned
    busyLabel: 'Opening Google Play…',
    onUnlock: function (id) { console.log('now owned:', id); },
    onLock: function (id) { console.log('no longer owned (refund or expiry):', id); },
    toast: function (msg) { showMessage(msg); }     // failure messages; default: window.showToast, else alert
  });
});

In React: start with plans that have no button (nothing is drawn) and let React render from onChange, which returns the unsubscribe function for the effect:

function ProButton() {
  const [iap, setIap] = React.useState({ available: false, products: {} });

  React.useEffect(() => {
    if (typeof window === 'undefined' || !window.AppMintIAP) return;   // server render / browser
    window.AppMintIAP.start({ plans: [{ id: 'pro_unlock' }] });       // safe to run twice
    return window.AppMintIAP.onChange(setIap);
  }, []);

  const pro = iap.products?.pro_unlock;                                // read ownership per product
  if (!iap.available) return null;                                     // nothing to buy outside the app
  return pro?.owned
    ? <p>Thanks! Pro is unlocked.</p>
    : <button onClick={() => window.AppMintIAP.buy('pro_unlock')}>Unlock Pro {pro?.price}</button>;
}

Notes: Attributes: data-iap-buy="id", data-iap-label ({price} becomes Play's live price), data-iap-owned (label once owned; default "✓ Purchased"), data-iap-group (plans that replace each other; after one is owned the others hide unless hideOtherPlans: false), data-iap-show / data-iap-hide (value = product id or group; empty = anything owned), data-iap-text (the part of a button whose text changes), data-iap-container. An owned button gets data-iap-state="owned" and the class iap-unlocked. A cancel on Google's sheet sends no event; the button is released when the page is visible again (or after 30 s). Calling start(options) after the helper already started on its own from data-iap-buy attributes MERGES the options (plans, show/hide, container, onUnlock/onLock, toast, busyLabel, hideOtherPlans) into the running set; calling it twice is harmless. A page that ships its own appmint-iap.js keeps it - the built-in copy stands down.

AppMintIAP.started helper#

window.AppMintIAP.started

Example

A property (not a function): true once the helper is running, i.e. it has wired at least one product from start(), sell() or data-iap-buy buttons.

Returns: a boolean property. Needs: 💰 Sell In-App Products.

Use it to decide between start() (with your own options) and sell() (which also works on a running helper):

function setUpShop(iap) {
  if (!iap.started) {
    iap.start({
      plans: [{ id: 'pro_unlock', button: '#buy-pro', label: 'Unlock Pro — {price}' }],
      toast: function (msg) { showMessage(msg); }
    });
  } else {
    // Already running (the page had data-iap-buy buttons): start() would do nothing now.
    iap.sell('pro_unlock', '#buy-pro', { label: 'Unlock Pro — {price}' });
  }
}
if (window.AppMintIAP) setUpShop(window.AppMintIAP);

Warn yourself during development when nothing on the page was wired (a typo in data-iap-buy, or the buttons never rendered):

window.addEventListener('load', function () {
  setTimeout(function () {
    var iap = window.AppMintIAP;
    if (iap && !iap.started) {
      console.warn('AppMintIAP is still waiting: no data-iap-buy button, sell() or start() on this page');
    }
  }, 3000);
});

Tell a store screen apart from "still loading" and "purchases unavailable":

function storeStatus() {
  var iap = window.AppMintIAP;
  if (!iap) return 'web';                            // opened in a normal browser
  if (!iap.started) return 'waiting';                // no product wired yet
  return iap.state().available ? 'ready' : 'unavailable';
}
document.getElementById('store').setAttribute('data-status', storeStatus());

Notes: A second start() never starts it twice (React StrictMode runs effects twice); its options are merged into the running set. It stays false while no product is wired yet; the helper then keeps watching the page for data-iap-buy buttons.

AppMintIAP.state helper#

window.AppMintIAP.state()

Example

Returns the current purchase state once, without subscribing.

Returns: {available, adFree, owned, ownedIds, products}, synchronously. ownedIds is an array of product IDs; products is {id: {price, currency, type: 'inapp'|'subs', period, owned}} for the products the helper has asked Google about. Needs: 💰 Sell In-App Products.

function describePass() {
  if (!window.AppMintIAP) return 'Available in the Android app';
  var s = window.AppMintIAP.state();
  if (s.ownedIds.indexOf('season_pass') !== -1) return 'Your season pass is active';
  var p = s.products.season_pass;                       // undefined until Google answers
  if (!p || !p.price) return 'Season pass';
  var every = { P1W: 'week', P1M: 'month', P1Y: 'year' }[p.period] || 'period';
  return 'Season pass — ' + p.price + ' / ' + every;
}
document.getElementById('pass-label').textContent = describePass();

Check ownership at the moment the player taps a locked level, and send them to your shop otherwise:

function openLevel(n) {
  var s = window.AppMintIAP ? window.AppMintIAP.state() : null;
  var needsPack = n > 10;
  if (needsPack && !(s && s.ownedIds.indexOf('level_pack_2') !== -1)) {
    showShop('level_pack_2');                        // your own screen
    return;
  }
  startLevel(n);
}

Hide your own "go ad-free" prompts once the build's ad-free entitlement is active, and list the prices Google has sent so far:

if (window.AppMintIAP) {
  var s = window.AppMintIAP.state();
  document.querySelectorAll('.go-ad-free').forEach(function (el) { el.hidden = s.adFree; });

  var list = document.getElementById('price-list');
  Object.keys(s.products).forEach(function (id) {
    var p = s.products[id];
    var li = document.createElement('li');
    li.textContent = id + ': ' + (p.owned ? 'owned' : (p.price || '…')) +
                     (p.type === 'subs' ? ' (subscription)' : '');
    list.appendChild(li);
  });
}

Notes: It is a snapshot. To follow changes use AppMintIAP.onChange(fn), which gives the same object every time something changes. owned is true when anything at all is owned; check a product with ownedIds or AppMintIAP.isOwned(id).

getOwnedProducts bridge#

window.WebToApk.getOwnedProducts(): String

JSON array of every owned product ID - for gating whole screens at startup.

Example

Lists every Play product the user owns, to unlock whole screens at start.

Returns: a JSON string (array of product IDs), synchronously, for example '["pro_unlock","level_pack_2"]'. Parse it with JSON.parse. '[]' when nothing is owned. Needs: 💰 Sell In-App Products in the build wizard.

function ownedIds() {
  var b = window.WebToApk;
  if (!b || typeof b.getOwnedProducts !== 'function') return [];
  try { return JSON.parse(b.getOwnedProducts()); } catch (e) { return []; }
}

function unlockAll(ids) {
  ids.forEach(function (id) {
    var el = document.querySelector('[data-product="' + id + '"]');
    if (el) el.classList.add('unlocked');
  });
}
unlockAll(ownedIds());

// The owned list changed (launch restore, a new purchase, a refund): the full new list.
window.addEventListener('appmint:owned-changed', function (e) {
  document.querySelectorAll('.unlocked').forEach(function (el) { el.classList.remove('unlocked'); });
  unlockAll(e.detail.products);              // e.detail = {products: ['pro_unlock', ...]}
});

The same change also calls window.onAppMintOwnedChanged(detail) if you define it.

A "My purchases" list on your settings screen, with your own name for each product:

var NAMES = { pro_unlock: 'Pro features', level_pack_2: 'Levels 11-20', remove_ads: 'No ads' };

function renderMyPurchases() {
  var b = window.WebToApk, ids = [];
  if (b && typeof b.getOwnedProducts === 'function') {
    try { ids = JSON.parse(b.getOwnedProducts()); } catch (e) {}
  }
  var list = document.getElementById('my-purchases');
  list.textContent = '';
  ids.forEach(function (id) {
    var li = document.createElement('li');
    li.textContent = NAMES[id] || id;
    list.appendChild(li);
  });
  document.getElementById('nothing-bought').hidden = ids.length > 0;
}
renderMyPurchases();
window.addEventListener('appmint:owned-changed', renderMyPurchases);

Work out the best tier the user owns when each tier is its own product:

var TIERS = ['premium_plus', 'premium', 'basic'];     // best first

function currentTier() {
  var b = window.WebToApk, owned = [];
  if (b && typeof b.getOwnedProducts === 'function') {
    try { owned = JSON.parse(b.getOwnedProducts()); } catch (e) {}
  }
  for (var i = 0; i < TIERS.length; i++) if (owned.indexOf(TIERS[i]) !== -1) return TIERS[i];
  return 'free';
}

applyTier(currentTier());
window.addEventListener('appmint:purchase', function () { applyTier(currentTier()); });
window.addEventListener('appmint:owned-changed', function () { applyTier(currentTier()); });

Notes: Includes one-time products and active subscriptions, and also the Remove Ads product if the user bought it. Works offline; the list is saved on the phone at every launch restore and purchase. appmint:owned-changed fires only when the list really changed, so it may not fire at all on a normal launch.

getProducts bridge#

window.WebToApk.getProducts(requestId: String, productIdsJson: String)

Asks Play for live details of the given product IDs (JSON array of strings). Result arrives as 'appmint:products' {requestId, products:[{productId, type, title, description, price, currency, period?, owned}]}. IDs Play doesn't know are absent from the result - the page hides those items.

Example

Asks Google Play for the live details of your products: title, description and the localized price.

Returns: nothing now. The answer arrives as the appmint:products event: {requestId, products: [{productId, type, title, description, price, currency, period?, owned}]}. type is 'inapp' or 'subs'; period is an ISO-8601 period such as 'P1M' (subscriptions only). Needs: 💰 Sell In-App Products in the build wizard. The second argument is a JSON string: use JSON.stringify.

function loadPrices(ids) {
  return new Promise(function (resolve) {
    var b = window.WebToApk;
    if (!b || typeof b.getProducts !== 'function') { resolve([]); return; }   // browser
    var requestId = 'prices-' + Date.now();
    window.addEventListener('appmint:products', function handler(e) {
      if (String(e.detail.requestId) !== requestId) return;   // another call's answer
      window.removeEventListener('appmint:products', handler);
      resolve(e.detail.products || []);
    });
    b.getProducts(requestId, JSON.stringify(ids));
  });
}

loadPrices(['pro_unlock', 'coins_500', 'pro_monthly']).then(function (products) {
  products.forEach(function (p) {
    var btn = document.querySelector('[data-product="' + p.productId + '"]');
    if (!btn) return;
    btn.hidden = false;
    btn.textContent = p.owned ? 'Owned' : p.title + ' · ' + p.price + (p.period === 'P1M' ? ' / month' : '');
  });
});

The answer is also passed to window.onAppMintProducts(detail) if you define it.

A subscription plan picker, each card labelled with Play's price and its billing period:

var PERIODS = { P1W: 'week', P1M: 'month', P3M: '3 months', P6M: '6 months', P1Y: 'year' };

window.addEventListener('appmint:products', function (e) {
  if (e.detail.requestId !== 'plans') return;
  e.detail.products.forEach(function (p) {
    var card = document.querySelector('.plan[data-product="' + p.productId + '"]');
    if (!card || p.type !== 'subs') return;
    card.hidden = false;
    card.querySelector('.price').textContent = p.price + ' / ' + (PERIODS[p.period] || p.period);
    card.classList.toggle('current', p.owned);
  });
});

var b = window.WebToApk;
if (b && typeof b.getProducts === 'function') {
  b.getProducts('plans', JSON.stringify(['pro_weekly', 'pro_monthly', 'pro_yearly']));
}

While testing, log the IDs Google Play did not return, through the page hook:

var WANTED = ['pro_unlock', 'coins_500', 'pro_monthly'];

window.onAppMintProducts = function (detail) {       // same payload as appmint:products
  if (detail.requestId !== 'store-check') return;
  var found = detail.products.map(function (p) { return p.productId; });
  var missing = WANTED.filter(function (id) { return found.indexOf(id) === -1; });
  if (missing.length) console.warn('Not returned by Google Play: ' + missing.join(', '));
  document.getElementById('store').hidden = found.length === 0;
};

if (window.WebToApk && typeof window.WebToApk.getProducts === 'function') {
  window.WebToApk.getProducts('store-check', JSON.stringify(WANTED));
}

Notes: Show price exactly as Play gives it and never store it. An ID that Play does not know (not created, not active, or the app was not installed from Play) is simply missing from products; keep that item hidden. A numeric-looking requestId comes back as a number, so use a text id or compare with String().

isOwned bridge#

window.WebToApk.isOwned(productId: String): Boolean

Instant on-device ownership check (one-time product owned OR sub active).

Example

Checks, instantly and offline, whether the user owns one of your Play products (a one-time product, or a subscription that is still active).

Returns: true / false, synchronously. Needs: 💰 Sell In-App Products in the build wizard. With that switch on it also answers for the Remove Ads (IAP) product; in an app that only sells ad removal (the switch off) it answers false for every product, so check ad removal with isPremium() there.

function hasPro() {
  var b = window.WebToApk;
  return !!(b && typeof b.isOwned === 'function' && b.isOwned('pro_unlock'));
}

function paint() {
  document.getElementById('pro-features').hidden = !hasPro();
  document.getElementById('buy-pro').hidden = hasPro();
}
paint();

// Ownership changes: a purchase landed, or the launch restore / a refund changed the list.
window.addEventListener('appmint:purchase', paint);
window.addEventListener('appmint:owned-changed', paint);

Check at the moment of use: a Pro-only export runs when owned, and otherwise opens the purchase sheet and finishes once the purchase lands:

var exportWaiting = false;

document.getElementById('export-hd').addEventListener('click', function () {
  var b = window.WebToApk;
  if (!b || typeof b.isOwned !== 'function') { exportStandard(); return; }   // browser
  if (b.isOwned('pro_unlock')) { exportHd(); return; }
  exportWaiting = true;
  b.purchase('pro_unlock');
});

window.addEventListener('appmint:purchase', function (e) {
  if (e.detail.productId === 'pro_unlock' && exportWaiting) {
    exportWaiting = false;
    exportHd();                                  // finish what the user asked for
  }
});

Pro through any of several plans: a monthly, a yearly and a lifetime product all count:

var PRO_PLANS = ['pro_monthly', 'pro_yearly', 'pro_lifetime'];

function isPro() {
  var b = window.WebToApk;
  if (!b || typeof b.isOwned !== 'function') return false;
  return PRO_PLANS.some(function (id) { return b.isOwned(id); });
}

function paintPlan() { document.body.classList.toggle('pro', isPro()); }
paintPlan();
window.addEventListener('appmint:purchase', paintPlan);
window.addEventListener('appmint:owned-changed', paintPlan);   // an expired subscription or a refund

Notes: The owned list is restored from the user's Google account at every launch and saved on the phone, so it works offline and after a reinstall, with no server. Always false in a browser. A subscription stops counting once Play no longer reports it as active.

isPremium bridge#

window.WebToApk.isPremium(): Boolean

True when the user owns "Remove Ads" (Play Billing entitlement, auto-restored on every launch). Matches the reason 'ad_free' in 'appmint:ad-unavailable'.

Example

Tells you whether the user owns your "Remove Ads" product, so the page can hide its "Remove ads" button and ad-related UI.

Returns: true / false, synchronously. Needs: Ads on and Remove Ads (IAP) with your Play Store Product ID. Without that it is always false.

var removeAdsBtn = document.getElementById('remove-ads');

function paintPremium(premium) {
  removeAdsBtn.hidden = premium;
  document.body.classList.toggle('premium', premium);
}

var b = window.WebToApk;
paintPremium(!!(b && typeof b.isPremium === 'function' && b.isPremium()));

// Changes later: purchase finished, restore at launch, refund or expired subscription.
window.addEventListener('appmint:premium', function (e) {
  paintPremium(e.detail.premium);            // e.detail = {premium: true | false}
});

The same change also calls a page hook, if you define it:

window.onAppMintPremiumChanged = function (info) { paintPremium(info.premium); };   // {premium}

Show your own "Remove ads" offer on the game-over screen only to players who still see ads:

function showGameOver(score) {
  document.getElementById('final-score').textContent = score;
  var b = window.WebToApk;
  var premium = !!(b && typeof b.isPremium === 'function' && b.isPremium());
  // Ad-free already, or a plain browser (no ads there): no offer.
  document.getElementById('remove-ads-offer').hidden = premium || !b;
  document.getElementById('game-over').hidden = false;
}

Notes: Ownership is restored from the user's Google account at every launch, so read it once at start and then follow appmint:premium. The Product ID field accepts several IDs separated by commas (for example a monthly plan and a lifetime one); owning any of them counts. While premium, ads are removed and ad calls answer ad_free.

purchase bridge#

window.WebToApk.purchase(productId: String)

Opens Google's purchase sheet for [productId] (one-time product or subscription - resolved automatically). Grant ONLY on 'appmint:purchase' {productId, owned:true}; failures fire 'appmint:purchase-failed' {reason}. Already-owned re-fires the purchase event so the page can idempotently unlock.

Example

Opens Google Play's purchase sheet for one of your products (a one-time product or a subscription; the app finds out which).

Returns: nothing now. Success fires appmint:purchase {productId, owned: true}. A failure fires appmint:purchase-failed {productId, reason}. Needs: 💰 Sell In-App Products in the build wizard, and the product created and active in Play Console.

Unlock ONLY in the appmint:purchase handler, never on the tap:

var b = window.WebToApk;
var buyBtn = document.getElementById('buy-pro');

if (!b || typeof b.purchase !== 'function') {
  buyBtn.hidden = true;                              // browser: nothing to buy here
} else {
  buyBtn.addEventListener('click', function () {
    buyBtn.disabled = true;
    b.purchase('pro_unlock');
  });
}

window.addEventListener('appmint:purchase', function (e) {
  if (e.detail.productId === 'pro_unlock') unlockPro();   // safe to run twice
  buyBtn.disabled = false;
});

window.addEventListener('appmint:purchase-failed', function (e) {
  buyBtn.disabled = false;
  if (e.detail.reason === 'pending') showMessage('Payment pending. It unlocks by itself once Google confirms.');
  else showMessage('Purchase is not available right now (' + e.detail.reason + ').');
});

// A cancel on Google's sheet sends no event: release the button when the page is visible again.
document.addEventListener('visibilitychange', function () { if (!document.hidden) buyBtn.disabled = false; });

The same results also call window.onAppMintPurchase(detail) and window.onAppMintPurchaseFailed(detail) if you define them.

Buy the plan the user picked in a subscription picker, and hide a plan whose ID Play does not know:

var picked = 'pro_yearly';
document.querySelectorAll('.plan').forEach(function (card) {
  card.addEventListener('click', function () { picked = card.getAttribute('data-product'); });
});

document.getElementById('subscribe').addEventListener('click', function () {
  var b = window.WebToApk;
  if (b && typeof b.purchase === 'function') b.purchase(picked);
});

window.addEventListener('appmint:purchase', function (e) {
  if (/^pro_/.test(e.detail.productId)) unlockPro();        // any plan unlocks Pro
});
window.addEventListener('appmint:purchase-failed', function (e) {
  if (e.detail.reason !== 'not_found') return;
  var card = document.querySelector('.plan[data-product="' + e.detail.productId + '"]');
  if (card) card.hidden = true;
});

Unlock a level pack from the locked level the player tapped, then open that level:

var wantedLevel = null;

function openLevel(n) {
  var b = window.WebToApk;
  var owned = !!(b && typeof b.isOwned === 'function' && b.isOwned('level_pack_2'));
  if (n <= 10 || owned) { startLevel(n); return; }
  if (!b || typeof b.purchase !== 'function') { showMessage('Levels 11-20 are in the Android app.'); return; }
  wantedLevel = n;
  b.purchase('level_pack_2');
}

window.onAppMintPurchase = function (detail) {       // {productId, owned: true}
  if (detail.productId !== 'level_pack_2' || !wantedLevel) return;
  var n = wantedLevel;
  wantedLevel = null;
  startLevel(n);
};

Notes: Reasons: disabled (Sell In-App Products is off), not_found (the ID is not in Play Console), billing_error, pending (the user paid with a slow method; the unlock arrives later through the launch restore and appmint:owned-changed). If the product is already owned, appmint:purchase fires again at once, so your unlock must be safe to repeat. The app also shows its own short message for errors.

revenuecat bridge#

window.WebToApk.revenuecat(action: String, argsJson: String, callbackId: String)

One RevenueCat [action] with [argsJson]; resolves [callbackId] with {ok, …} or {ok:false, error}.

Example

The raw method behind AppMint.revenuecat - use the wrapper. One entry point: action is configure, getOfferings, purchase, restore, getCustomerInfo, logIn, logOut or getAppUserId.

Returns: nothing directly; resolves window.__appmintReplies[callbackId] with the same answer the wrapper gives. Needs: Integrate step → RevenueCat with your public goog_ key.

window.__appmintReplies = window.__appmintReplies || {};
window.__appmintReplies['rc1'] = (r) => console.log(r.ok ? r.customerInfo : r.error);
WebToApk.revenuecat('getCustomerInfo', '{}', 'rc1');

Configure with a user id and read the offerings:

window.__appmintReplies = window.__appmintReplies || {};
window.__appmintReplies['rc-cfg'] = (r) => {
  if (!r.ok) return console.warn('RevenueCat:', r.error);
  window.__appmintReplies['rc-off'] = (o) => console.log(o.ok ? o.current : o.error);
  WebToApk.revenuecat('getOfferings', '{}', 'rc-off');
};
if (window.WebToApk && WebToApk.revenuecat) {
  WebToApk.revenuecat('configure', JSON.stringify({ appUserId: 'u_42' }), 'rc-cfg');
}

Purchase and handle the refusals:

window.__appmintReplies = window.__appmintReplies || {};
window.__appmintReplies['rc-buy'] = (r) => {
  if (r.ok) return unlock(r.customerInfo.entitlements.active);
  if (r.error === 'foreign_origin') return showMessage('Purchases only work inside the app.');
  if (r.cancelled) return;
  showMessage('Purchase failed: ' + (r.message || r.error));
};
WebToApk.revenuecat('purchase', JSON.stringify({ package: '$rc_monthly' }), 'rc-buy');

Notes: See AppMint.revenuecat for every action's arguments and answers.

startRemoveAdsPurchase bridge#

window.WebToApk.startRemoveAdsPurchase()

Launch the Google Play "Remove Ads" purchase flow from the page (user-initiated, e.g. the app's own "Remove ads" button). Without this the ONLY entry point was the native side-menu item - unreachable in apps built without the side menu. Outcome reaches the page via 'appmint:premium' {premium:true}; already-owned re-notifies immediately; flow errors surface as a native toast.

Example

Opens Google Play's purchase sheet for your "Remove Ads" product, from your page's own "Remove ads" button.

Returns: nothing. The result arrives as appmint:premium {premium: true} (and window.onAppMintPremiumChanged). Needs: Ads on and Remove Ads (IAP) with your Play Store Product ID (the first ID listed is the one sold). When the Side Menu is on, the app also has its own Remove Ads item there (hidden once the user owns it); with the side menu off, this button on your page is the only way to buy.

var b = window.WebToApk;
var btn = document.getElementById('remove-ads');

if (!b || typeof b.startRemoveAdsPurchase !== 'function' || b.isPremium()) {
  btn.hidden = true;                          // browser, or already ad-free
} else {
  btn.addEventListener('click', function () {
    btn.disabled = true;
    b.startRemoveAdsPurchase();
  });
}

window.addEventListener('appmint:premium', function (e) {
  if (e.detail.premium) { btn.hidden = true; showMessage('Thank you! Ads are gone.'); }
});

// A cancel on Google's sheet sends no event: release the button when the page is visible again.
document.addEventListener('visibilitychange', function () {
  if (!document.hidden) btn.disabled = false;
});

A settings row that shows whether ads are removed, with the button that buys it:

var row = document.getElementById('ads-setting');
var btn = row.querySelector('button');
var b = window.WebToApk;

function paintAdsRow(premium) {
  row.querySelector('.status').textContent = premium ? 'Ads removed. Thank you!' : 'Ads are on';
  btn.hidden = premium;
}

if (b && typeof b.startRemoveAdsPurchase === 'function') {
  paintAdsRow(b.isPremium());
  btn.addEventListener('click', function () { b.startRemoveAdsPurchase(); });
  window.onAppMintPremiumChanged = function (info) { paintAdsRow(info.premium); };   // {premium}
} else {
  row.hidden = true;                                  // browser: nothing to sell
}

Offer it after every third finished round, in a dialog the player answers with a tap:

var rounds = 0;
var offer = document.getElementById('remove-ads-offer');   // a <dialog>

function onRoundFinished() {
  var b = window.WebToApk;
  if (!b || typeof b.startRemoveAdsPurchase !== 'function' || b.isPremium()) return;
  rounds += 1;
  if (rounds % 3 === 0) offer.showModal();
}

offer.querySelector('.buy').addEventListener('click', function () {
  offer.close();
  window.WebToApk.startRemoveAdsPurchase();   // opened from the player's own tap
});
offer.querySelector('.later').addEventListener('click', function () { offer.close(); });

Notes: If the user already owns it, appmint:premium {premium:true} fires at once. Errors (Play not reachable, product not found in Play Console) are shown by the app as a short native message; the page gets no event for them. Test with an app installed from a Play testing track.

Generated from the app runtime and its example files on every docs build. Read it as Markdown · All families.

Checked against the shipped bridge on 2026-10-03.