Making money
Sell In-App Products (IAP)
Google Play purchases from your own website or ZIP app
AppMintAppwrightBoth builders - the steps below are the same in each.
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.
In the app, this step's button saves these to your phone. Here they are direct downloads.
AppMint-InApp-Purchases-Guide.pdf iap-demo-index.html2What 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.
3What you need before you start#
Just four things:
- Your website or HTML/ZIP app (the thing you turn into an APK).
- Something to sell - decide what buyers get (e.g. 'Pro version': no limits, extra content).
- 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.
- 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.
4Step 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.
5Step 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>6The 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.
- REMOVE ADS, PAID ONCE (forever)
Create it under: In-app products
Also type the id into: Remove Ads (IAP) โ Product ID
- REMOVE ADS, MONTHLY
Create it under: Subscriptions
Also type the id into: Remove Ads (IAP) โ Product ID
- UNLOCK LEVELS / CONTENT, PAID ONCE
Create it under: In-app products
Do NOT type it into the Remove Ads box - your ads keep running.
- 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>7Which 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)8Full 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>9Full 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>;
}10Quick 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.
11Step 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:
- Open the stepper and go to STEPPER STEP 3 (the screen with Ads, Notifications and Permissions).
- 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.
- 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.
- 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.
- 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.
- 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)12Step 4 - Put your app on Google Play#
In your Play Console (the website where developers manage their apps):
- 'Create app' โ fill in the name and basic questions.
- In the left menu: 'Testing' โ 'Internal testing' โ 'Create new release' โ upload your AAB file โ 'Save' and roll it out.
- 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.
13Step 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)
- Play Console โ your app โ 'Monetize' โ 'Products' โ 'In-app products'.
- 'Create product'.
- Product ID: type EXACTLY the id from your page - level_pack_2. Same spelling, same case. Google never lets you change or reuse it.
- Name: what buyers see, e.g. 'Levels 11-20'. Description: one line.
- Set your price in your own currency - Google converts it for every country.
- Press 'Activate'.
EVERY MONTH (remove ads monthly, all-levels subscription)
- Play Console โ 'Monetize' โ 'Products' โ 'Subscriptions'.
- 'Create subscription'. Product ID: remove_ads_monthly.
- Save, then add a BASE PLAN: type 'Auto-renewing', billing period 'Monthly', your price.
- 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.
14Step 6 - Test it (you won't be charged)#
- Play Console โ left menu 'Setup' โ 'License testing' โ add your Gmail to the testers list.
- On your phone: open the internal-testing join link from Step 4, accept, and install your app FROM the Play Store.
- Open the app. Your buy button appears with the real price.
- Tap it - Google's payment sheet opens and says 'Test card, always approves'. Buy it. Your pro content appears. You pay nothing.
- 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.
15Step 7 - Go live and get paid#
- 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.)
- Users buy inside your app โ Google collects the money.
- 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.
16If 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.
17React, 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:
- 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.
- 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
1Create 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.
2Get 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...In Appwright
3Setup in the Appwright STEPPER#
In the generator app:
- Enable 'Remove Ads (IAP)' toggle in Step 3
- Enter the EXACT same 'Product ID' you created in Step 1
- Paste your 'Base64 License Key' from Step 2
- 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_lifetimeIn AppMint
3Setup in WebToApk Generator#
In the generator app:
- Enable 'Remove Ads (IAP)' toggle in Step 3
- Enter the EXACT same 'Product ID' you created in Step 1
- Paste your 'Base64 License Key' from Step 2
- 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_lifetime4Setup 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'
5Test in Internal or Closed Track#
In-App Purchases ONLY work when the app is installed via the Play Store (Testing tracks):
- Upload your generated AAB to the 'Internal Testing' or 'Closed Testing' track
- Share the tester join link with your test accounts
- Download the app from the Play Store
- 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.
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.
<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.
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)
};
}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>
);
}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#
<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.
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.
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.
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());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.
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.
| 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. |
| 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. |
| 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. |
| 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. |
| 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#
- Every product id in your code exists in the Play Console and is activated - for a subscription, the base plan too.
- The build carries the same ids, and the Base64 RSA licence key from Monetization setup.
- Your Gmail address is in Setup โ License testing, with the response set to RESPOND_NORMALLY.
- You uploaded the AAB to Internal or Closed testing and installed it from the Play Store link - a sideloaded APK can never purchase anything.
- You bought each product as a licence tester and saw "Test instrument, always approves".
- You uninstalled and reinstalled, and the purchase came back on its own.
- You turned off the network and the paid content is still unlocked.
- 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}orproducts[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 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
pendingas a failure. The buyer paid by a slow method; it unlocks by itself when Google confirms.