# Purchases and entitlements - JavaScript bridge API

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

- **Applies to:** Appwright
- **Source:** extracted from the app runtime and its per-method example files; this page is generated from them.
- **HTML:** https://freewebsitetoapk.com/docs/api/iap

### `AppMintIAP.available`

```js
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:

```js
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:

```js
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:

```js
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`

```js
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:

```js
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:

```js
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:

```js
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`

```js
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**.

```js
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:

```js
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:

```js
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`

```js
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:

```ts
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:

```js
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`):

```js
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`

```js
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**.

```js
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:

```js
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:

```js
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`

```js
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:

```js
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()`:

```js
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:

```js
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`

```js
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).

```js
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`:

```js
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`):

```js
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):

```js
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`

```js
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:

```html
<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):

```js
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:

```js
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`

```js
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):

```js
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):

```js
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":

```js
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`

```js
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**.

```js
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:

```js
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:

```js
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`

```js
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.

```js
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:

```js
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:

```js
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`

```js
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`.

```js
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:

```js
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:

```js
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`

```js
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. It also answers for the **Remove Ads (IAP)** product.

```js
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:

```js
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:

```js
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`

```js
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`.

```js
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:

```js
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:

```js
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`

```js
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:

```js
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:

```js
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:

```js
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.

### `startRemoveAdsPurchase`

```js
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). The app has no menu item for this, so your page must have the button.

```js
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:

```js
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:

```js
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.

