# Sell In-App Products (IAP)

> Sell one-time unlocks and subscriptions from your own page, with live Play prices and offline entitlement checks.

- **Applies to:** AppMint and Appwright
- **Source:** the Integration Guide shipped inside the app; this page is generated from it.
- **HTML:** https://freewebtoapk.com/docs/in-app-purchases
- **Using AppMint:** wherever the text below says "Appwright", read "AppMint". The two builders share this feature and only the name changes. Steps where the instructions genuinely differ are given separately and labelled.

Google Play purchases from your own website or ZIP app

### 1. 📥 Get the guide + working demo app

Everything in this guide is also a PDF you can read offline, plus a complete demo app you can build and run today.

Tap below and both are saved to your phone under Downloads/Appwright/Guides:

- AppMint-InApp-Purchases-Guide.pdf - the full guide, including the Play Console steps and the React / Next.js examples
- iap-demo-index.html - a small game that sells three things: remove ads (one-time), a level pack (one-time) and a monthly season pass

To try the demo: zip iap-demo-index.html on its own, upload it in the stepper, and follow the guide. It is the fastest way to see a real purchase work end to end before you touch your own app.

**Download** (in the app these are saved to your phone by the button on this step):

- [AppMint-InApp-Purchases-Guide.pdf](https://freewebtoapk.com/docs/files/AppMint-InApp-Purchases-Guide.pdf) - 303 KB
- [iap-demo-index.html](https://freewebtoapk.com/docs/files/iap-demo-index.html) - 10 KB

### 2. What is this? (in plain words)

Your app can sell things - a Pro upgrade, extra levels, a subscription - and Google collects the money and pays it to you every month.

WE WILL BUILD ONE EXAMPLE TOGETHER, all the way through this guide:

```
A game that sells two things
  • 'Levels 11-20'      ₹99  paid once, kept forever
  • 'Remove ads'        ₹49  every month
```

You will end up adding four lines to your page. That is the whole job - there is no purchase code to write, and if you have never done this before you will still be fine.

You do NOT need to know programming beyond copy-paste, and you do NOT need a server, a database or a payment company account. Google Play does the hard parts:

- Shows the payment screen (card / UPI / carrier billing)
- Converts your price into every country's currency
- Remembers who bought what - even after reinstall
- Sends the money to your bank

Appwright takes 0% - the sale goes straight to YOUR Google Play account.

Works the same for Website → APK, ZIP → APK and AI-built apps.

### 3. What you need before you start

Just four things:

1. Your website or HTML/ZIP app (the thing you turn into an APK).
2. Something to sell - decide what buyers get (e.g. 'Pro version': no limits, extra content).
3. A Google Play developer account - one-time $25 fee at play.google.com/console. You need this to publish ANY app on the Play Store, not just paid ones.
4. About 1 hour, total, spread over the steps below.

Important to know up-front: in-app purchases only work when your app is installed FROM the Play Store. That's a Google rule for everyone, not an Appwright limit.

### 4. Step 1 - Invent your product ID

A product ID is just a short name you make up for the thing you sell. You'll type this same name in two places later (your page + Play Console), so keep it simple and write it down.

Rules: only lowercase letters, numbers, underscores. Must start with a letter or number.

Copy one of these:

- pro_unlock - 'Pro version' upgrade (good default - the code below already uses it)
- remove_ads - ad-free upgrade
- level_pack_2 - extra content pack
- coins_100 - game currency pack
- premium_monthly - monthly subscription

The price is NOT part of the ID - you'll set the price in Step 5, and you can change it any time without touching your code.

### 5. Step 2 - Mark your button (no code to write)

You do NOT write any purchase code. Your app already carries the purchase engine - you just label your own HTML so it knows what to sell.

Copy the block below into your page and change only:

(1) pro_unlock → your product ID, in data-iap-buy  
(2) the words on the button and inside the paid section

That's it. The button then shows Google's real price in the buyer's own currency, opens Google's payment screen when tapped, reveals the paid section after payment (and again at every launch for people who already bought), takes it back if a subscription lapses or a payment is refunded, and shows a clear message if anything goes wrong.

THE FOUR LABELS:

- data-iap-buy="id" - makes this the buy button (required)
- data-iap-label="…{price}…" - button text; {price} becomes Google's real price
- data-iap-owned="…" - button text after buying
- data-iap-show - a section that appears only after buying (start it 'hidden')

Also available: data-iap-hide (disappears after buying), data-iap-container (a wrapper that only appears inside the app), data-iap-text (put it on the <span> holding the text if your button also has icons).

SELLING SEVERAL DIFFERENT THINGS? Add a button for each. They are independent - an app can remove ads AND sell level packs AND run a subscription, and buying one never affects the others. Point a section at one product with data-iap-show="level_pack_2".

Only the IDs you type into 'Remove Ads (IAP) → Product ID' switch the ads off. Everything else you sell unlocks whatever your page does with it, and the ads keep running.

TWO PLANS FOR THE SAME THING (monthly vs lifetime)? Give both buttons the same data-iap-group="adfree" - then buying either hides the other, so nobody pays twice. Without a group nothing is hidden, which is what unrelated products need.

```
<!-- (1) CHANGE pro_unlock to your product ID -->
<button data-iap-buy="pro_unlock"
        data-iap-label="Unlock Pro — {price}"
        data-iap-owned="✓ Unlocked">
  Unlock Pro
</button>

<!-- (2) CHANGE the words. Appears only after buying. -->
<div data-iap-show hidden>
  🎉 Pro content here — visible after buying.
</div>

<!-- Optional: disappears after buying -->
<div data-iap-hide>
  Upgrade to remove the ads.
</div>

<!-- DIFFERENT THINGS: independent — buying one never affects the others -->
<button data-iap-buy="remove_ads"   data-iap-label="Remove ads — {price}">Remove ads</button>
<button data-iap-buy="level_pack_2" data-iap-label="Levels 11-20 — {price}">Get levels</button>

<div data-iap-show="level_pack_2" hidden>Levels 11-20 unlocked!</div>

<!-- SAME THING, TWO PLANS: one group, so buying either hides the other -->
<button data-iap-buy="remove_ads_monthly"  data-iap-group="adfree"
        data-iap-label="Remove ads — {price}/month"
        data-iap-owned="✓ Ad-free (monthly)">Remove ads monthly</button>

<button data-iap-buy="remove_ads_lifetime" data-iap-group="adfree"
        data-iap-label="Remove ads forever — {price}"
        data-iap-owned="✓ Ads removed forever">Remove ads forever</button>
```

### 6. The 4 things people usually sell

Pick whichever matches you. The only difference between them is where you create the product in Play Console - your page code is the same shape every time.

1. REMOVE ADS, PAID ONCE (forever)

Create it under: In-app products  
Also type the id into: Remove Ads (IAP) → Product ID

1. REMOVE ADS, MONTHLY

Create it under: Subscriptions  
Also type the id into: Remove Ads (IAP) → Product ID

1. UNLOCK LEVELS / CONTENT, PAID ONCE

Create it under: In-app products  
Do NOT type it into the Remove Ads box - your ads keep running.

1. UNLOCK LEVELS / CONTENT, MONTHLY

Create it under: Subscriptions  
Do NOT type it into the Remove Ads box - your ads keep running.

You can sell ALL FOUR in the same app, and as many products as you like. Buying one never affects the others.

```
<!-- 1. remove ads, paid once -->
<button data-iap-buy="remove_ads_lifetime"
        data-iap-label="Remove ads forever — {price}"
        data-iap-owned="✓ Ads removed">Remove ads forever</button>

<!-- 2. remove ads, monthly -->
<button data-iap-buy="remove_ads_monthly"
        data-iap-label="Remove ads — {price}/month"
        data-iap-owned="✓ Ad-free">Remove ads monthly</button>

<!-- 3. levels, paid once -->
<button data-iap-buy="level_pack_2"
        data-iap-label="Levels 11-20 — {price}"
        data-iap-owned="✓ Unlocked">Get levels 11-20</button>

<!-- 4. all levels, monthly -->
<button data-iap-buy="all_levels_monthly"
        data-iap-label="All levels — {price}/month"
        data-iap-owned="✓ Subscribed">Subscribe</button>
```

### 7. Which purchases remove the ads?

This is the one thing people get wrong, so it is worth 30 seconds.

Your ads are NOT part of your web page. They are real Android ads sitting on top of it, so nothing in your page can switch them off. Only ONE thing can:

```
wizard Step 3 → Remove Ads (IAP) → Product ID
```

Whatever id you type in that box removes the ads when someone buys it. Every other id you sell just unlocks whatever your page does with it, and the ads carry on.

Selling both an ad-free plan AND level packs? Type only the ad-free id in that box. In our example:

```
Product ID box:  remove_ads_monthly
NOT in the box:  level_pack_2
```

Offering two ad-free plans (monthly AND lifetime)? Put both in that one box, separated by a comma - owning either one removes the ads:

```
remove_ads_monthly,remove_ads_lifetime
```

Not selling ad removal at all? Leave that toggle off entirely and just use '💰 Sell In-App Products'.

```
Selling levels only:
  Remove Ads (IAP)          OFF
  Sell In-App Products      ON

Selling ad removal only:
  Remove Ads (IAP)          ON   → Product ID: remove_ads_monthly
  Sell In-App Products      ON

Selling both, with two ad-free plans:
  Remove Ads (IAP)          ON   → Product ID: remove_ads_monthly,remove_ads_lifetime
  Sell In-App Products      ON
  (level_pack_2 stays out of the box)
```

### 8. Full example - plain HTML page

Here is our whole example app: sells 'Levels 11-20' once, and 'Remove ads' monthly. Copy it, change the two ids and the words, and you are done.

Read what each label does:

- data-iap-buy    → what this button sells
- data-iap-label  → the text; {price} becomes Google's real price
- data-iap-owned  → the text after they have paid
- data-iap-show="level_pack_2" → this box appears only after THAT product is bought
- data-iap-hide   → this box disappears once anything is bought

Notice there is no <script> and no purchase code. Your app already contains it.

```
<h2>Get more levels</h2>

<button data-iap-buy="level_pack_2"
        data-iap-label="Levels 11-20 — {price}"
        data-iap-owned="✓ Levels 11-20 unlocked">
  Levels 11-20
</button>

<div data-iap-show="level_pack_2" hidden>
  🎉 Levels 11 to 20 are yours. Have fun!
</div>

<h2>Tired of ads?</h2>

<button data-iap-buy="remove_ads_monthly"
        data-iap-label="Remove ads — {price}/month"
        data-iap-owned="✓ No more ads">
  Remove ads
</button>

<div data-iap-hide>
  Ads help keep this game free.
</div>
```

### 9. Full example - React or Next.js

THE EASY WAY: the exact same labels work in React and Next.js, because they are only markup. No useState, no useEffect, no 'use client'. Buttons React adds later are picked up automatically.

That is all most apps need - use the first block below and stop there.

THE CONTROLLED WAY (second block): only if you want React itself to draw the price and switch the screen. Then:

- In Next.js put 'use client' on line 1 of the file, or the build fails.
- Only touch window.AppMintIAP inside useEffect - in the component body it runs on the server, where window does not exist.
- onChange(fn) hands you the state right away and again on every change, and returns the unsubscribe function React wants.
- In Next.js, build as a static export: output: 'export' in next.config.js.

```
// ── EASY WAY — just JSX, nothing else ──────────────────────────
export default function Shop() {
  return (
    <>
      <button data-iap-buy="level_pack_2"
              data-iap-label="Levels 11-20 — {price}"
              data-iap-owned="✓ Unlocked">Levels 11-20</button>

      <div data-iap-show="level_pack_2" hidden>🎉 Levels 11-20 are yours!</div>
    </>
  );
}

// ── CONTROLLED WAY — React draws everything ───────────────────
'use client';                       // Next.js: must be line 1
import { useEffect, useState } from 'react';

export default function Shop() {
  const [iap, setIap] = useState({ available: false, products: {} });

  useEffect(() => {
    if (typeof window === 'undefined' || !window.AppMintIAP) return;
    window.AppMintIAP.start({
      plans: [{ id: 'level_pack_2' }, { id: 'remove_ads_monthly' }]
    });
    return window.AppMintIAP.onChange(setIap);
  }, []);

  if (!iap.available) return null;   // outside the app there is nothing to buy

  const levels = iap.products?.level_pack_2;   // always optional-chain
  return levels?.owned
    ? <p>🎉 Levels 11-20 are yours!</p>
    : <button onClick={() => window.AppMintIAP.buy('level_pack_2')}>
        Levels 11-20 {levels?.price}
      </button>;
}
```

### 10. Quick check in your browser

Open your page in a normal browser (Chrome on your PC or phone).

You should see the buy button sitting there doing nothing, and your paid section still hidden. Both are correct: outside the app there is nothing to buy, so nothing can be bought by accident.

Inside the app it is a different page entirely: the button gets the real price, opens Google's payment screen, and hides itself once the buyer owns it. Your website keeps working normally for ordinary visitors either way.

Want the offer to vanish completely on the web? Wrap it in a <div data-iap-container hidden> - that wrapper is revealed only inside the app.

### 11. Step 3 - Turn purchases ON in the STEPPER

⚠️ DO NOT SKIP THIS. If you skip it your buttons still appear, but every tap answers 'In-app purchases are not enabled in this app.' and you take no money.

The STEPPER is the Step 1 → Step 2 → Step 3 screens you use to build your app. Purchases live in STEPPER STEP 3.

DO THIS IN ORDER:

1. Open the stepper and go to STEPPER STEP 3 (the screen with Ads, Notifications and Permissions).

1. Scroll down to the card headed 'Remove Ads (IAP)'.

CANNOT SEE THAT CARD? Turn the 'Ads' switch ON - the purchase card only appears while Ads is on. If your app has no ads at all: turn Ads ON, do point 3, then turn Ads back OFF. Your purchase setting stays on.

1. Turn ON the golden '💰 Sell In-App Products' switch.

THIS IS THE SWITCH THAT LETS YOU SELL ANYTHING. Nothing works without it.

- Uploading a ZIP or an AI-built app: Appwright scans your files, finds your buy buttons, turns this on for you and lists your product ids. Check it is on anyway.
- Using a website URL: turn it on yourself - a live website cannot be scanned during the build.

1. ONLY IF you are also selling ad removal: turn ON 'Remove Ads (IAP)' as well, and type your ad-free product id into the 'Play Store Product ID' box. Two ad-free plans? Type both ids separated by a comma, e.g. remove_ads_monthly,remove_ads_lifetime

Never put a level pack or Pro-feature id in that box - it would switch your ads off for free.

1. If the 'License Key' box is showing, paste your key there (Play Console → Monetize → Monetization setup → Licensing). It lets your app check that a payment is genuine.

1. Finish the stepper and export the AAB file - NOT the APK. Google Play only accepts AAB, and purchases only ever work through the Play Store.

BEFORE YOU BUILD, look at STEPPER STEP 3 one more time and confirm '💰 Sell In-App Products' is ON. That single switch is the most common reason a creator's buttons do nothing.

```
STEPPER STEP 3 — what to set

  Ads ......................... ON  (needed to see the card below)
  💰 Sell In-App Products ..... ON  ← REQUIRED for selling anything

  Selling ad removal too?
  Remove Ads (IAP) ............ ON
  Play Store Product ID ....... remove_ads_monthly
         (two plans) .......... remove_ads_monthly,remove_ads_lifetime
  License Key ................. paste from Play Console

  Export ...................... AAB  (not APK)
```

### 12. Step 4 - Put your app on Google Play

In your Play Console (the website where developers manage their apps):

1. 'Create app' → fill in the name and basic questions.
2. In the left menu: 'Testing' → 'Internal testing' → 'Create new release' → upload your AAB file → 'Save' and roll it out.
3. Still in Internal testing → 'Testers' tab → add your own Gmail → copy the 'join' link for later.

Internal testing is a private area - nobody can see your app yet. It exists so YOU can try purchases before going public.

[Open Play Console](https://play.google.com/console)

### 13. Step 5 - Create your product and set the price

Now tell Google what you are selling. Which menu you use depends on whether they pay once or every month.

PAID ONCE (levels, Pro, coins, remove-ads-forever)

1. Play Console → your app → 'Monetize' → 'Products' → 'In-app products'.
2. 'Create product'.
3. Product ID: type EXACTLY the id from your page - level_pack_2. Same spelling, same case. Google never lets you change or reuse it.
4. Name: what buyers see, e.g. 'Levels 11-20'. Description: one line.
5. Set your price in your own currency - Google converts it for every country.
6. Press 'Activate'.

EVERY MONTH (remove ads monthly, all-levels subscription)

1. Play Console → 'Monetize' → 'Products' → 'Subscriptions'.
2. 'Create subscription'. Product ID: remove_ads_monthly.
3. Save, then add a BASE PLAN: type 'Auto-renewing', billing period 'Monthly', your price.
4. Press 'Activate' on the base plan AND on the subscription itself. Miss either one and your app cannot see it.

Repeat for every id you used. Your page code does not change - the app asks Google which kind each id is.

WANT MONTHLY *AND* YEARLY? Create TWO separate subscriptions (all_levels_monthly and all_levels_yearly) rather than two base plans inside one subscription. One subscription with several base plans always offers the first one, with no way to pick.

Change any price later in Play Console - your app shows the new price by itself, no rebuild needed.

### 14. Step 6 - Test it (you won't be charged)

1. Play Console → left menu 'Setup' → 'License testing' → add your Gmail to the testers list.
2. On your phone: open the internal-testing join link from Step 4, accept, and install your app FROM the Play Store.
3. Open the app. Your buy button appears with the real price.
4. Tap it - Google's payment sheet opens and says 'Test card, always approves'. Buy it. Your pro content appears. You pay nothing.
5. Bonus check: uninstall and reinstall the app - the pro content unlocks by itself at launch. That's Google restoring the purchase.

⚠️ If you install the APK directly (sideload) instead of from the Play Store, the buy button will NOT appear. That's normal - always test through the Play Store link.

### 15. Step 7 - Go live and get paid

1. When testing looks good: Play Console → 'Production' → create a release with the same AAB → roll out. (First-time apps go through Google's review - usually a few days.)
2. Users buy inside your app → Google collects the money.
3. Google pays your bank account monthly (Play Console → 'Payments profile'). Google keeps its standard service fee (15% for most developers). Appwright keeps nothing.

To add more products later: add the new ID in your page code, rebuild once, and create the ID in Play Console. Price changes never need a rebuild.

### 16. If something doesn't work

Your app tells you what went wrong instead of failing silently. Find the message you see:

'In-app purchases are not enabled in this app.'  
→ '💰 Sell In-App Products' was OFF in STEPPER STEP 3 when you built. Turn it on and build again.

'This item is not available on Google Play yet.'  
→ Google does not recognise that id. Check it letter by letter against Play Console, confirm the product is ACTIVE (for a subscription, the base plan AND the subscription must both be activated), and make sure you installed the app from the Play tester link.

'Google Play could not start the payment.'  
→ The app was not installed from the Play Store, or was signed with a different key than the one you uploaded.

'Payment pending - this unlocks automatically once Google confirms.'  
→ Nothing to do. Slow payment methods (cash vouchers, some UPI) take a few minutes; the unlock arrives by itself, even after restarting the app.

NO MESSAGE AT ALL, nothing happens on tap  
→ Almost always a typo in data-iap-buy, or the app is not installed from Play. Every other cause shows one of the messages above.

PAYMENT WORKED BUT THE ADS ARE STILL THERE  
→ That product id is not in STEPPER STEP 3 → 'Remove Ads (IAP)' → 'Play Store Product ID'. Add it (comma-separate several) and build again.

BUY BUTTON NEVER APPEARS IN THE APP  
→ '💰 Sell In-App Products' was off at build time. In a normal web browser the buttons are hidden ON PURPOSE - there is nothing to buy outside the app.

NOTHING HAPPENED AFTER I CLOSED GOOGLE'S PAYMENT SHEET  
→ That is a normal cancel. Tap again and complete the payment.

PRICE LOOKS OLD  
→ Play caches prices on a device for a few hours. A fresh install always shows the new price.

### 17. React, Next.js, or plain JavaScript

REACT AND NEXT.JS NEED NOTHING SPECIAL. The labels above are only markup, so the same one line works there too - no useState, no useEffect, no 'use client':

<button data-iap-buy="pro_unlock"

```
      data-iap-label="Unlock Pro — {price}">Unlock Pro</button>
```

Buttons React mounts later are found automatically, and remounting is safe. In Next.js, build as a static export (output: 'export').

Prefer code to labels? One line does the same job:

AppMintIAP.sell('pro_unlock', '#buy-btn');

Optional third argument: {label:'Unlock Pro - {price}', owned:'✓ Unlocked', show:'#thanks', hide:'.upsell'}. Call it once per product.

ONLY IF YOU WANT REACT TO DRAW THE PRICE ITSELF - otherwise skip this:

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

useEffect(() => {

```
if (typeof window === 'undefined' || !window.AppMintIAP) return;
window.AppMintIAP.start({ plans: [{ id: 'pro_unlock' }] });
return window.AppMintIAP.onChange(setIap);
```

}, []);

const pro = iap.products?.pro_unlock;   // always optional-chain

if (!iap.available) return null;        // nothing to buy outside the app  
if (pro?.owned) return <p>Thanks!</p>;  
return <button onClick={() => window.AppMintIAP.buy('pro_unlock')}>

```
       Unlock Pro {pro?.price}
     </button>;
```

TWO HABITS KEEP THIS FROM BREAKING:

1. Read ownership PER PRODUCT - iap.products?.<id>?.owned. The top-level iap.owned means 'anything is owned' and changes meaning the day you sell a second product.
2. Optional-chain the whole path, so a field you did not expect can never throw while React is rendering.

onChange hands you {available, adFree, owned, ownedIds, products} immediately and on every change, and returns an unsubscribe function - so a returning buyer is already unlocked on the first render. In Next.js add 'use client' as line 1 and build with output: 'export'.

Also: AppMintIAP.available, .isOwned(id), .owned(), .buy(id), .state(), .refresh(). data-iap-buy labels work in JSX too, and buttons React mounts later are picked up automatically.

Prefer to drive the raw bridge yourself? Everything above is built on these:

- iap.isOwned(id) - instant true/false, works offline
- iap.getOwnedProducts() - JSON array of all owned IDs
- iap.getProducts(reqId, idsJson) - live prices; result arrives as the 'appmint:products' event {requestId, products:[{productId, title, price, currency, type, owned}]}
- iap.purchase(id) - one-time products AND subscriptions (auto-detected)
- Events: 'appmint:purchase' {productId} (grant ONLY here - also fires on restore), 'appmint:purchase-failed' {reason: disabled | not_found | billing_error | pending}

Selling only a single 'remove ads' upgrade? Use the simpler built-in pair instead - enable 'Remove Ads (IAP)' in wizard Step 3:

```
var iap = window.WebToApk;

if (iap && !iap.isPremium()) {
  showRemoveAdsButton(function onTap() {
    iap.startRemoveAdsPurchase();
  });
}

window.addEventListener('appmint:premium', function (e) {
  if (e.detail.premium) hideRemoveAdsButton();
});
```

## Remove Ads (IAP)

Google Play Billing Setup

### 1. Create Managed Product

In your Google Play Console dashboard:

- Go to 'Monetization' > 'In-app products'
- Click 'Create product'
- Enter a Product ID (e.g., 'remove_ads_premium')
- Set a Name (e.g., 'Remove All Ads') and Description
- Set the Price and click 'Save'
- Click 'Activate' to make it available for testing

Selling ad removal as a SUBSCRIPTION instead? Create it under 'Monetization' > 'Subscriptions' with an auto-renewing base plan, and activate BOTH the base plan and the subscription. Ad-free then lasts as long as the subscription is active, and the ads come back automatically when it lapses.

[Open Play Console](https://play.google.com/console)

### 2. Get your License Key

Google Play requires an RSA public key to verify purchases securely.

In Play Console:

- Go to 'Monetization' > 'Monetization setup'
- Under 'Licensing', copy the 'Base64-encoded RSA public key'
- ⚠️ IMPORTANT: You will need to paste this into the Generator later.

```
Example Key Format:
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
```

### 3. Setup in WebToApk Generator _(AppMint only)_

In the generator app:

1. Enable 'Remove Ads (IAP)' toggle in Step 3
2. Enter the EXACT same 'Product ID' you created in Step 1
3. Paste your 'Base64 License Key' from Step 2
4. Generate your APK/AAB

Offering more than one ad-free plan (e.g. a monthly subscription AND a lifetime one-time unlock)? Put every Product ID in this one field, separated by commas - owning ANY of them removes the ads. The first ID listed is the one the built-in 'Remove Ads' menu item buys, so put your default plan first.

```
Product ID: remove_ads_premium

Two plans:
remove_ads_monthly,remove_ads_lifetime
```

### 3. Setup in the Appwright STEPPER _(Appwright only)_

In the generator app:

1. Enable 'Remove Ads (IAP)' toggle in Step 3
2. Enter the EXACT same 'Product ID' you created in Step 1
3. Paste your 'Base64 License Key' from Step 2
4. Generate your APK/AAB

Offering more than one ad-free plan (e.g. a monthly subscription AND a lifetime one-time unlock)? Put every Product ID in this one field, separated by commas - owning ANY of them removes the ads. The first ID listed is the one window.WebToApk.startRemoveAdsPurchase() buys, so put your default plan first. Generated apps have no built-in 'Remove Ads' menu item - your own page must supply the button that calls it.

```
Product ID: remove_ads_premium

Two plans:
remove_ads_monthly,remove_ads_lifetime
```

### 4. Setup License Testers

To test purchases without spending real money:

- In Play Console, go to 'Setup' > 'License testing'
- Add your Gmail address to the 'License testers' list
- Ensure 'License response' is set to 'RESPOND_NORMALLY'

[Setup License Testers](https://play.google.com/console)

### 5. Test in Internal or Closed Track

In-App Purchases ONLY work when the app is installed via the Play Store (Testing tracks):

1. Upload your generated AAB to the 'Internal Testing' or 'Closed Testing' track
2. Share the tester join link with your test accounts
3. Download the app from the Play Store
4. Tap the 'Remove Ads' button in your app to test the flow!

```
Note: Purchases will show a 'Test instrument, always approves' message for license testers.
```

## Code recipes

The steps above cover the basics - the four labels, a full plain page, the React one-liner. These recipes cover what they do not: several plans that replace each other, subscriptions that lapse, framework hooks, the raw bridge in full, and the reference tables.

### The one rule

A purchase is granted **only** when Google confirms it. Not when the button is clicked, not when the sheet opens, not when the user comes back to your page. Everything below is a different way of expressing that one rule.

The runtime already enforces it: `AppMintIAP` re-reads ownership from the bridge on launch, on every purchase, on every refund and every time the app is reopened. Ownership survives reinstalls, a new phone and being offline, because it lives in the buyer's Google account, not in your page.

> **Careful.** Never unlock in the click handler, and never keep the entitlement only in `localStorage`. A cleared browser store must not cost a paying customer what they bought - and a copied `localStorage` value must not give it away for free.

### Recipe 1 - a pricing table with live prices

Three plans where buying any one of them replaces the others. `group` is what makes them alternatives: without it, they are unrelated products and buying the lifetime plan would leave the monthly button sitting there.

*Three alternatives, one group*

```html
<div class="plans" data-iap-container>
  <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_yearly" data-iap-group="pass"
          data-iap-label="Yearly — {price}" data-iap-owned="Active">Yearly</button>

  <button data-iap-buy="pass_lifetime" data-iap-group="pass"
          data-iap-label="Lifetime — {price}" data-iap-owned="Owned">Lifetime</button>
</div>

<p data-iap-show="pass">You are a member. Thank you.</p>
<p data-iap-hide="pass">Members get every level and no ads.</p>
```

A product id that Google does not know about - not created yet, not activated, or the app was not installed from the Play Store - is simply left without a price. Its button stays tappable on purpose: tapping it then reports the real reason instead of doing nothing.

### Recipe 2 - React

`onChange` fires immediately with the current state and again on every change, and it returns its own unsubscribe function - so it is exactly the shape a `useEffect` wants.

*hooks/useIap.js*

```jsx
import { useEffect, useState } from 'react';

/** Purchase state for the whole app. Safe in a browser too: available is false there. */
export function useIap() {
  const [iap, setIap] = useState({ available: false, ownedIds: [], products: {}, adFree: false });

  useEffect(() => {
    if (!window.AppMintIAP) return;             // running in a browser, or IAP not enabled
    return window.AppMintIAP.onChange(setIap);  // returns the unsubscribe fn
  }, []);

  return {
    ...iap,
    owns: (id) => iap.ownedIds.includes(id),
    priceOf: (id) => iap.products[id]?.price ?? null,
    buy: (id) => window.AppMintIAP?.buy(id)
  };
}
```

*A gate around anything you sell*

```jsx
import { useIap } from './hooks/useIap';

export function ProGate({ children }) {
  const { available, owns, priceOf, buy } = useIap();

  if (owns('pro_unlock')) return children;

  return (
    <div className="upsell">
      <h2>Pro</h2>
      <p>Unlimited projects, no watermark.</p>
      <button onClick={() => buy('pro_unlock')} disabled={!available}>
        {available ? `Unlock Pro${priceOf('pro_unlock') ? ` — ${priceOf('pro_unlock')}` : ''}` : 'Not available here'}
      </button>
    </div>
  );
}
```

> **Note.** The button calls `buy()`; it does not unlock anything. The unlock happens because Google confirms, `onChange` fires, `owns('pro_unlock')` turns true and the component re-renders. That is the same rule, expressed in React.

### Recipe 3 - Vue

*The same gate in Vue 3*

```html
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue';

const iap = ref({ available: false, ownedIds: [], products: {} });
let stop;

onMounted(() => { stop = window.AppMintIAP?.onChange(s => (iap.value = s)); });
onUnmounted(() => stop && stop());

const isPro = computed(() => iap.value.ownedIds.includes('pro_unlock'));
const price = computed(() => iap.value.products.pro_unlock?.price ?? '');
</script>

<template>
  <slot v-if="isPro" />
  <button v-else :disabled="!iap.available" @click="AppMintIAP.buy('pro_unlock')">
    Unlock Pro {{ price }}
  </button>
</template>
```

### Recipe 4 - subscriptions

A subscription is bought exactly like a one-time product; the difference is that it can go away. `type` tells you which kind a product is and `period` gives the billing interval as an ISO-8601 duration - `P1M` monthly, `P1Y` yearly, `P1W` weekly.

*A monthly pass that expires on its own*

```js
AppMintIAP.onChange(state => {
  const pass = state.products.season_pass;      // undefined until Google answers
  const active = state.ownedIds.includes('season_pass');

  document.body.classList.toggle('has-pass', active);

  if (pass && !active) {
    const every = { P1W: 'week', P1M: 'month', P1Y: 'year' }[pass.period] || 'period';
    label.textContent = `Season pass — ${pass.price} / ${every}`;
  }
});

// The pass lapsed (not renewed, or refunded): the same handler runs with it gone.
// Nothing to write — the class comes off and the paid content hides itself.
```

You do not poll and you do not need an expiry date. Play restores the owned set on every launch, so a lapsed subscription simply stops appearing in `ownedIds`.

> **Careful.** Activate **both** the subscription and its base plan in the Play Console. A subscription with an inactive base plan returns no details at all, and the button will report "not available on Google Play yet".

### Recipe 5 - the raw bridge, no helper

Everything above is built on six methods and five events. If you want to own the UI completely, use them directly.

*The whole surface, in one file*

```js
const bridge = window.WebToApk;
const enabled = !!(bridge && bridge.purchase);          // false in a browser

// --- what does Google charge for these, in this buyer's currency? ---
const reqId = 'prices-' + Date.now();
window.addEventListener('appmint:products', function onProducts(e) {
  if (e.detail.requestId !== reqId) return;             // not our answer
  window.removeEventListener('appmint:products', onProducts);
  for (const p of e.detail.products) {
    // {productId, type:'inapp'|'subs', title, description, price, currency, period?, owned}
    render(p);
  }
});
bridge.getProducts(reqId, JSON.stringify(['pro_unlock', 'season_pass']));

// --- what does this buyer already own? instant, and works offline ---
const owned = JSON.parse(bridge.getOwnedProducts() || '[]');
const isPro = bridge.isOwned('pro_unlock');

// --- buy ---
buyButton.onclick = () => bridge.purchase('pro_unlock');

// --- the ONLY place a purchase is ever granted ---
window.addEventListener('appmint:purchase', e => {
  if (e.detail.productId === 'pro_unlock' && e.detail.owned) unlockPro();
});

// --- and the only place failures show up ---
window.addEventListener('appmint:purchase-failed', e => {
  toast({
    not_found:     'This item is not available on Google Play yet.',
    billing_error: 'Google Play could not start the payment. Please try again.',
    pending:       'Payment pending — this unlocks automatically once Google confirms.',
    disabled:      'In-app purchases are not enabled in this app.'
  }[e.detail.reason] || 'Purchase could not be completed. Please try again.');
});

// --- the launch-time restore finished, or a refund took something away ---
window.addEventListener('appmint:owned-changed', () => refreshEverything());
```

> **Note.** `appmint:purchase` fires again for something already owned, on purpose. Write `unlockPro()` so that running it twice is harmless and you never have to track whether you have already run it.

### Recipe 6 - degrade honestly outside the app

The same page usually has to open in a normal browser as well - during development, or because you also publish it on the web. There, `window.WebToApk` does not exist and no purchase can happen. Say so, rather than showing a button that does nothing.

*One check, at the top*

```js
const inApp = !!(window.AppMintIAP && window.AppMintIAP.available);

if (!inApp) {
  buyButton.disabled = true;
  buyButton.textContent = 'Available in the Android app';
  // and let people get it:
  storeLink.hidden = false;
}
```

### Reference

Everything the page can call, listen for, or mark up.

**window.AppMintIAP - the helper the runtime injects**

| Call | Does |
| --- | --- |
| `AppMintIAP.available` | False in a browser, or when in-app purchases were not switched on for this build. Check it before showing anything for sale. |
| `AppMintIAP.sell(id, button, opts)` | Wire one product to one button. `opts`: `label`, `owned`, `group`, `show`, `hide`, `labelEl`. Call it once per product. |
| `AppMintIAP.start(config)` | Wire several at once: `{plans, show, hide, container, toast, hideOtherPlans}`. Use it when you want your own toast function, or to keep the other plans of a group visible after one is bought (`hideOtherPlans: false`). |
| `AppMintIAP.onChange(fn)` | Fires immediately with the current state, then on every change. Returns an unsubscribe function. |
| `AppMintIAP.state()` | The same object, once: `{available, adFree, owned, ownedIds, products}`. |
| `AppMintIAP.isOwned(id)` | True/false, instantly, offline. |
| `AppMintIAP.owned()` | Array of every owned product id. |
| `AppMintIAP.buy(id)` | Opens Google's purchase sheet. Grants nothing by itself. |
| `AppMintIAP.refresh()` | Re-scan for `data-iap-buy` buttons added since the last pass. Rarely needed - the runtime watches the DOM. |

**window.WebToApk - the bridge underneath**

| Call | Returns |
| --- | --- |
| `purchase(productId)` | Nothing. Opens the sheet; the outcome arrives as an event. |
| `getProducts(requestId, idsJson)` | Nothing. The answer arrives as `appmint:products` carrying the same `requestId`. |
| `getOwnedProducts()` | JSON array of owned product ids, as a string. |
| `isOwned(productId)` | Boolean - a one-time product owned, or a subscription currently active. |
| `isPremium()` | Boolean - the "remove ads" entitlement specifically. |
| `startRemoveAdsPurchase()` | Nothing. Opens the sheet for the FIRST id in the wizard's "Remove Ads" field; owning any id listed there removes the ads. Outcome arrives as `appmint:premium`. |

**Events**

| Event | detail | When |
| --- | --- | --- |
| `appmint:purchase` | `{productId, owned}` | Google confirmed. The only place to unlock. Re-fires for something already owned. |
| `appmint:purchase-failed` | `{productId, reason}` | The sheet never opened, the flow errored, or the payment is pending. Never fired for a plain cancel. |
| `appmint:products` | `{requestId, products[]}` | Reply to a `getProducts()` call. Ids Play does not know are simply absent. |
| `appmint:premium` | `{premium}` | The ad-free entitlement changed - purchase, launch restore, refund or expiry. |
| `appmint:owned-changed` | `{products[]}` | The launch-time restore finished, or a refund took something away. Carries every owned id. |

**HTML attributes**

| Attribute | On | Does |
| --- | --- | --- |
| `data-iap-buy="id"` | button | Makes it buy that product. |
| `data-iap-label="… {price}"` | button | Label template. `{price}` becomes Google's live localised price. |
| `data-iap-owned="…"` | button | Label once the product is owned. |
| `data-iap-group="name"` | button | Marks plans as alternatives to each other. |
| `data-iap-show="id|group"` | anything | Shown only when that is owned. |
| `data-iap-hide="id|group"` | anything | Hidden once that is owned. |
| `data-iap-text` | inside a button | The element whose text gets the label, when the button has more inside it than text. |
| `data-iap-container` | wrapper | Shown only inside the app; hidden in a browser, where there is nothing to buy. Wrap the whole offer in it. |
| `data-iap-state="owned"` | set by the runtime | On an owned product's button, so you can style it. |

**Failure reasons on appmint:purchase-failed**

| reason | What actually happened |
| --- | --- |
| `not_found` | Google does not know this product id - not created, not activated, or the app was not installed from the Play Store. |
| `billing_error` | Play could not start the payment. Usually transient; the buyer should try again. |
| `pending` | A slow payment method (cash, bank transfer). It unlocks by itself when Google confirms - do not treat this as a failure. |
| `disabled` | In-app purchases were not switched on for this build. |
| `unknown` | Anything else. |

### Before you ship

1. Every product id in your code exists in the Play Console **and is activated** - for a subscription, the base plan too.
2. The build carries the same ids, and the Base64 RSA licence key from Monetization setup.
3. Your Gmail address is in **Setup → License testing**, with the response set to RESPOND_NORMALLY.
4. You uploaded the AAB to Internal or Closed testing and installed it **from the Play Store link** - a sideloaded APK can never purchase anything.
5. You bought each product as a licence tester and saw "Test instrument, always approves".
6. You uninstalled and reinstalled, and the purchase came back on its own.
7. You turned off the network and the paid content is still unlocked.
8. You refunded a test purchase in the Play Console and watched the app lock again.

### Mistakes that cost money

- **Unlocking in the click handler.** Anyone who opens the sheet and backs out gets the product free.
- **Storing the entitlement only in `localStorage`.** Clearing site data takes away something a customer paid for; copying a value gives it away.
- **Hard-coding the price in your HTML.** It will be wrong in every other country and out of date the day you change it. Use `{price}` or `products[id].price`.
- **Selling physical goods or a real-world service through Play billing.** Google requires the opposite: those must use a normal payment processor. That is what the [Stripe guide](/docs/stripe-payments) is for.
- **Testing on a sideloaded APK.** In-app purchases only work when the app was installed from the Play Store. Nothing you write can change that.
- **Treating `pending` as a failure.** The buyer paid by a slow method; it unlocks by itself when Google confirms.

