Заработок
Продажа покупок в приложении (IAP)
Покупки в Google Play с вашего сайта или приложения из ZIP
1📥 Получите руководство и рабочее демо-приложение#
Всё из этого руководства есть и в PDF, который можно читать офлайн, а также полное демо-приложение, которое можно собрать и запустить сегодня.
Нажмите ниже, и оба файла сохранятся на телефон в Downloads/AppMint/Guides:
- AppMint-InApp-Purchases-Guide.pdf — полное руководство, включая шаги в Play Console и примеры для React / Next.js
- iap-demo-index.html — небольшая игра, продающая три вещи: удаление рекламы (разово), набор уровней (разово) и ежемесячный сезонный пропуск
Чтобы попробовать демо: заархивируйте один файл iap-demo-index.html, загрузите его в мастер и следуйте руководству. Это самый быстрый способ увидеть, как настоящая покупка работает от начала до конца, прежде чем браться за своё приложение.
В приложении кнопка этого шага сохраняет их на телефон. Здесь они доступны для прямого скачивания.
AppMint-InApp-Purchases-Guide.pdf iap-demo-index.html2Что это? (простыми словами)#
Ваше приложение может продавать, например, Pro-версию, дополнительные уровни или подписку, а Google собирает деньги и переводит их вам каждый месяц.
МЫ ВМЕСТЕ СОБЕРЁМ ОДИН ПРИМЕР на протяжении всего этого руководства:
A game that sells two things
• 'Levels 11-20' ₹99 paid once, kept forever
• 'Remove ads' ₹49 every month
В итоге вы добавите на страницу четыре строки. Это вся работа: писать код покупки не нужно, и даже если вы никогда этого не делали, всё получится.
Вам НЕ нужно уметь программировать сверх копирования и вставки, и НЕ нужны ни сервер, ни база данных, ни аккаунт платёжной компании. Самое сложное делает Google Play:
- Показывает платёжный экран (карта / UPI / оплата через оператора)
- Переводит вашу цену в валюту каждой страны
- Помнит, кто что купил, даже после переустановки
- Переводит деньги на ваш банковский счёт
AppMint берёт 0%: продажа идёт прямо на ВАШ аккаунт Google Play.
Работает одинаково для «Сайт в APK», «ZIP в APK» и приложений, созданных ИИ.
3Что нужно перед началом#
Всего четыре вещи:
- Ваш сайт или приложение HTML/ZIP (то, что вы превращаете в APK).
- Что продавать: решите, что получат покупатели (например, «Pro-версия»: без ограничений, дополнительный контент).
- Аккаунт разработчика Google Play: разовый взнос $25 на play.google.com/console. Он нужен, чтобы опубликовать ЛЮБОЕ приложение в Play Store, а не только платное.
- Всего около 1 часа, распределённого по шагам ниже.
Важно знать заранее: покупки в приложении работают только когда приложение установлено ИЗ Play Store. Это правило Google для всех, а не ограничение AppMint.
4Шаг 1 — Придумайте идентификатор продукта#
Идентификатор продукта — это короткое имя, которое вы придумываете для того, что продаёте. Позже вы введёте это же имя в двух местах (на странице и в Play Console), поэтому сделайте его простым и запишите.
Правила: только строчные буквы, цифры и подчёркивания. Должен начинаться с буквы или цифры.
Скопируйте один из вариантов:
- pro_unlock — улучшение до «Pro-версии» (хороший вариант по умолчанию, код ниже уже его использует)
- remove_ads — улучшение без рекламы
- level_pack_2 — набор дополнительного контента
- coins_100 — набор игровой валюты
- premium_monthly — ежемесячная подписка
Цена НЕ входит в идентификатор: вы зададите её на шаге 5 и сможете менять в любой момент, не трогая код.
5Шаг 2 — Разметьте кнопку (писать код не нужно)#
Вам НЕ нужно писать код покупки. Движок покупок уже есть в вашем приложении: вы просто размечаете свой HTML, чтобы он знал, что продавать.
Скопируйте блок ниже на свою страницу и измените только:
(1) pro_unlock → идентификатор вашего продукта в data-iap-buy
(2) слова на кнопке и внутри платного раздела
Вот и всё. Кнопка показывает настоящую цену Google в валюте покупателя, при нажатии открывает платёжный экран Google, после оплаты показывает платный раздел (и при каждом запуске для тех, кто уже купил), забирает его обратно, если подписка закончилась или платёж возвращён, и показывает понятное сообщение, если что-то пошло не так.
ЧЕТЫРЕ ПОДПИСИ:
- data-iap-buy="id" — делает этот элемент кнопкой покупки (обязательно)
- data-iap-label="…{price}…" — текст кнопки; {price} заменяется настоящей ценой от Google
- data-iap-owned="…" — текст кнопки после покупки
- data-iap-show — раздел, который появляется только после покупки (изначально задайте ему 'hidden')
Также доступно: data-iap-hide (исчезает после покупки), data-iap-container (контейнер, который появляется только внутри приложения), data-iap-text (добавьте его к <span> с текстом, если на кнопке есть ещё и значки).
ПРОДАЁТЕ НЕСКОЛЬКО РАЗНЫХ ВЕЩЕЙ? Добавьте кнопку для каждой. Они независимы: приложение может убирать рекламу И продавать наборы уровней И вести подписку, а покупка одного никогда не влияет на остальные. Привязать раздел к одному продукту можно через data-iap-show="level_pack_2".
Рекламу отключают только идентификаторы, которые вы введёте в «Удалить рекламу (IAP) → Product ID». Всё остальное, что вы продаёте, открывает то, что с этим делает ваша страница, а реклама продолжает показываться.
ДВА ТАРИФА НА ОДНО И ТО ЖЕ (месяц и навсегда)? Дайте обеим кнопкам одинаковый data-iap-group="adfree": тогда покупка любого скроет другой, и никто не заплатит дважды. Без группы ничего не скрывается, а это нужно для несвязанных продуктов.
<!-- (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>64 вещи, которые обычно продают#
Выберите подходящий вариант. Разница только в том, где вы создаёте продукт в Play Console, а код вашей страницы каждый раз одинаковый.
- УДАЛИТЬ РЕКЛАМУ, ОПЛАТА ОДИН РАЗ (навсегда)
Создайте его в разделе: «Продукты в приложении»
Также введите идентификатор в: Удалить рекламу (IAP) → Product ID
- УДАЛИТЬ РЕКЛАМУ, ЕЖЕМЕСЯЧНО
Создайте его в разделе: «Подписки»
Также введите идентификатор в: Удалить рекламу (IAP) → Product ID
- ОТКРЫТЬ УРОВНИ / КОНТЕНТ, ОПЛАТА ОДИН РАЗ
Создайте его в разделе: «Продукты в приложении»
НЕ вводите его в поле «Удалить рекламу», иначе реклама продолжит работать.
- ОТКРЫТЬ УРОВНИ / КОНТЕНТ, ЕЖЕМЕСЯЧНО
Создайте его в разделе: «Подписки»
НЕ вводите его в поле «Удалить рекламу», иначе реклама продолжит работать.
В одном приложении можно продавать ВСЕ ЧЕТЫРЕ вида продуктов и сколько угодно товаров. Покупка одного никак не влияет на остальные.
<!-- 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Какие покупки убирают рекламу?#
Именно здесь люди чаще всего ошибаются, поэтому стоит потратить на это 30 секунд.
Ваша реклама НЕ является частью веб-страницы. Это настоящая реклама Android поверх неё, поэтому ничто на вашей странице не может её отключить. Это может сделать только ОДНО:
wizard Step 3 → Remove Ads (IAP) → Product ID
Любой идентификатор, введённый в это поле, убирает рекламу при покупке. Все остальные продаваемые идентификаторы просто открывают то, что с ними делает ваша страница, а реклама продолжает показываться.
Продаёте и тариф без рекламы, И наборы уровней? В это поле введите только идентификатор тарифа без рекламы. В нашем примере:
Product ID box: remove_ads_monthly
NOT in the box: level_pack_2
Предлагаете два тарифа без рекламы (ежемесячный И пожизненный)? Поместите оба в это одно поле через запятую: владение любым из них убирает рекламу:
remove_ads_monthly,remove_ads_lifetime
Совсем не продаёте отключение рекламы? Оставьте этот переключатель полностью выключенным и просто используйте «💰 Продавать товары в приложении».
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Полный пример — обычная HTML-страница#
Вот всё наше приложение-пример: разово продаёт «Уровни 11–20» и ежемесячно «Убрать рекламу». Скопируйте его, измените два идентификатора и тексты, и готово.
Посмотрите, что делает каждая подпись:
- data-iap-buy → что продаёт эта кнопка
- data-iap-label → текст; {price} заменяется настоящей ценой от Google
- data-iap-owned → текст после оплаты
- data-iap-show="level_pack_2" → этот блок появляется только после покупки ИМЕННО этого продукта
- data-iap-hide → этот блок исчезает, как только что-либо куплено
Обратите внимание: здесь нет <script> и нет кода покупки. Он уже есть в вашем приложении.
<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Полный пример — React или Next.js#
ПРОСТОЙ СПОСОБ: те же самые подписи работают в React и Next.js, потому что это просто разметка. Без useState, без useEffect, без 'use client'. Кнопки, которые React добавляет позже, подхватываются автоматически.
Большинству приложений этого достаточно: используйте первый блок ниже и остановитесь на нём.
УПРАВЛЯЕМЫЙ СПОСОБ (второй блок): только если вы хотите, чтобы сам React рисовал цену и переключал экран. Тогда:
- В Next.js добавьте 'use client' первой строкой файла, иначе сборка завершится ошибкой.
- Обращайтесь к window.AppMintIAP только внутри useEffect: в теле компонента код выполняется на сервере, где window не существует.
- onChange сразу отдаёт состояние и снова при каждом изменении, а также возвращает функцию отписки, которую ждёт React.
- В Next.js собирайте как статический экспорт: output: 'export' в 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Быстрая проверка в браузере#
Откройте свою страницу в обычном браузере (Chrome на компьютере или телефоне).
Вы должны увидеть кнопку покупки, которая ничего не делает, и платный раздел по-прежнему скрыт. Оба результата верны: вне приложения нечего покупать, поэтому случайно ничего купить нельзя.
Внутри приложения это совсем другая страница: кнопка получает настоящую цену, открывает платёжный экран Google и скрывается, как только покупатель владеет продуктом. Ваш сайт при этом продолжает нормально работать для обычных посетителей.
Хотите, чтобы предложение полностью исчезало в вебе? Оберните его в <div data-iap-container hidden>: этот контейнер показывается только внутри приложения.
11Шаг 3 — Включите покупки в МАСТЕРЕ#
⚠️ НЕ ПРОПУСКАЙТЕ ЭТО. Если пропустить, кнопки всё равно появятся, но каждое нажатие будет отвечать «Покупки в приложении в этом приложении не включены», и вы не получите денег.
МАСТЕР — это экраны «Шаг 1 → Шаг 2 → Шаг 3», на которых вы собираете приложение. Покупки находятся в ШАГЕ 3 МАСТЕРА.
ДЕЛАЙТЕ ПО ПОРЯДКУ:
- Откройте мастер и перейдите на ШАГ 3 МАСТЕРА (экран с рекламой, уведомлениями и разрешениями).
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Шаг 4 — Разместите приложение в Google Play#
В вашем Play Console (сайт, где разработчики управляют своими приложениями):
- «Создать приложение» → заполните название и основные вопросы.
- В левом меню: «Тестирование» → «Внутреннее тестирование» → «Создать новый релиз» → загрузите файл AAB → «Сохранить» и запустите выпуск.
- По-прежнему во внутреннем тестировании → вкладка «Тестеры» → добавьте свой Gmail → скопируйте ссылку-приглашение «join» на потом.
Внутреннее тестирование — это закрытая область: никто пока не видит ваше приложение. Она нужна, чтобы ВЫ могли проверить покупки до публикации.
13Шаг 5 — Создайте продукт и задайте цену#
Теперь расскажите Google, что вы продаёте. Какое меню использовать, зависит от того, платят один раз или каждый месяц.
ОПЛАТА ОДИН РАЗ (уровни, Pro, монеты, удаление рекламы навсегда)
- Play Console → ваше приложение → «Монетизация» → «Продукты» → «Продукты в приложении».
- «Создать продукт».
- Product ID: введите ТОЧНО тот же идентификатор, что на вашей странице: level_pack_2. То же написание, тот же регистр. Google никогда не позволяет изменить или использовать его повторно.
- Название: то, что видят покупатели, например «Уровни 11–20». Описание: одна строка.
- Задайте цену в своей валюте: Google пересчитает её для каждой страны.
- Нажмите «Активировать».
КАЖДЫЙ МЕСЯЦ (удаление рекламы ежемесячно, подписка на все уровни)
- Play Console → «Монетизация» → «Продукты» → «Подписки».
- «Создать подписку». Product ID: remove_ads_monthly.
- Сохраните, затем добавьте БАЗОВЫЙ ПЛАН: тип «Auto-renewing», период оплаты «Monthly», ваша цена.
- Нажмите «Активировать» и на базовом плане, И на самой подписке. Если пропустить хоть одно, приложение её не увидит.
Повторите для каждого использованного идентификатора. Код вашей страницы не меняется: приложение само спрашивает у Google, какого вида каждый идентификатор.
ХОТИТЕ ЕЖЕМЕСЯЧНО *И* ЕЖЕГОДНО? Создайте ДВЕ отдельные подписки (all_levels_monthly и all_levels_yearly), а не два базовых плана в одной подписке. Одна подписка с несколькими базовыми планами всегда предлагает первый, и выбрать другой нельзя.
Цену можно изменить позже в Play Console: приложение само покажет новую цену, пересборка не нужна.
14Шаг 6 — Проверьте (деньги не спишутся)#
- Play Console → левое меню «Настройка» → «Лицензионное тестирование» → добавьте свой Gmail в список тестеров.
- На телефоне откройте ссылку-приглашение внутреннего тестирования из шага 4, примите её и установите приложение ИЗ Play Store.
- Откройте приложение. Ваша кнопка покупки появится с настоящей ценой.
- Нажмите на неё: откроется платёжный экран Google с надписью «Test card, always approves». Купите продукт. Появится ваш платный контент. Вы ничего не платите.
- Дополнительная проверка: удалите и заново установите приложение, и платный контент откроется сам при запуске. Это Google восстанавливает покупку.
⚠️ Если вы устанавливаете APK напрямую (вручную), а не из Play Store, кнопка покупки НЕ появится. Это нормально: всегда проверяйте через ссылку Play Store.
15Шаг 7 — Опубликуйте и получайте деньги#
- Когда тестирование прошло хорошо: Play Console → «Производство» → создайте релиз с тем же AAB → запустите выпуск. (Новые приложения проходят проверку Google, обычно несколько дней.)
- Пользователи покупают внутри вашего приложения → деньги собирает Google.
- Google переводит деньги на ваш банковский счёт ежемесячно (Play Console → «Платёжный профиль»). Google удерживает свою стандартную комиссию (15% для большинства разработчиков). AppMint не берёт ничего.
Чтобы позже добавить новые продукты: добавьте новый идентификатор в код страницы, один раз пересоберите приложение и создайте идентификатор в Play Console. Для изменения цен пересборка никогда не нужна.
16Если что-то не работает#
Приложение сообщает, что пошло не так, а не молча ломается. Найдите сообщение, которое вы видите:
«Покупки в приложении в этом приложении не включены».
→ При сборке на ШАГЕ 3 МАСТЕРА был ВЫКЛЮЧЕН переключатель «💰 Продавать товары в приложении». Включите его и соберите заново.
«Этот товар пока недоступен в Google Play».
→ Google не узнаёт этот идентификатор. Сверьте его по буквам с Play Console, убедитесь, что продукт АКТИВЕН (для подписки должны быть активированы И базовый план, И сама подписка), и что вы установили приложение по ссылке тестера Play.
«Google Play не смог начать платёж».
→ Приложение установлено не из Play Store или подписано другим ключом, чем тот, что вы загрузили.
«Платёж в ожидании — продукт откроется автоматически, когда Google подтвердит».
→ Делать ничего не нужно. Медленные способы оплаты (кассовые ваучеры, некоторые UPI) занимают несколько минут; доступ откроется сам, даже после перезапуска приложения.
НЕТ НИКАКОГО СООБЩЕНИЯ, при нажатии ничего не происходит
→ Почти всегда это опечатка в data-iap-buy или приложение установлено не из Play. Любая другая причина показывает одно из сообщений выше.
ОПЛАТА ПРОШЛА, НО РЕКЛАМА ОСТАЛАСЬ
→ Этого идентификатора продукта нет в ШАГЕ 3 МАСТЕРА → «Удалить рекламу (IAP)» → «Play Store Product ID». Добавьте его (несколько через запятую) и соберите заново.
КНОПКА ПОКУПКИ НИКОГДА НЕ ПОЯВЛЯЕТСЯ В ПРИЛОЖЕНИИ
→ При сборке переключатель «💰 Продавать товары в приложении» был выключен. В обычном браузере кнопки скрыты НАМЕРЕННО: вне приложения нечего покупать.
НИЧЕГО НЕ ПРОИЗОШЛО ПОСЛЕ ЗАКРЫТИЯ ПЛАТЁЖНОГО ЭКРАНА GOOGLE
→ Это обычная отмена. Нажмите снова и завершите платёж.
ЦЕНА УСТАРЕЛА
→ Play кеширует цены на устройстве на несколько часов. После новой установки всегда показывается новая цена.
17React, Next.js или обычный JavaScript#
REACT И NEXT.JS НЕ ТРЕБУЮТ НИЧЕГО ОСОБОГО. Подписи выше — это просто разметка, поэтому та же одна строка работает и там: без useState, без useEffect, без 'use client':
<button data-iap-buy="pro_unlock"
data-iap-label="Unlock Pro — {price}">Unlock Pro</button>
Кнопки, которые React монтирует позже, находятся автоматически, а повторное монтирование безопасно. В Next.js собирайте как статический экспорт (output: 'export').
Предпочитаете код подписям? Одна строка делает то же самое:
AppMintIAP.sell('pro_unlock', '#buy-btn');
Необязательный третий аргумент: {label:'Unlock Pro - {price}', owned:'✓ Unlocked', show:'#thanks', hide:'.upsell'}. Вызывайте один раз на каждый продукт.
ТОЛЬКО ЕСЛИ ВЫ ХОТИТЕ, ЧТОБЫ REACT САМ РИСОВАЛ ЦЕНУ, иначе пропустите это:
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; // всегда используйте необязательную цепочку
if (!iap.available) return null; // вне приложения нечего покупать
if (pro?.owned) return <p>Спасибо!</p>;
return <button onClick={() => window.AppMintIAP.buy('pro_unlock')}>
Unlock Pro {pro?.price}
</button>;
ДВЕ ПРИВЫЧКИ, ЧТОБЫ ЭТО НЕ СЛОМАЛОСЬ:
- Читайте владение ПО КАЖДОМУ ПРОДУКТУ: iap.products?.<id>?.owned. Верхнеуровневое iap.owned означает «куплено хоть что-то» и меняет смысл в тот день, когда вы начнёте продавать второй продукт.
- Используйте необязательную цепочку (?.) по всему пути, чтобы неожиданное поле никогда не вызвало ошибку во время отрисовки React.
onChange сразу отдаёт вам {available, adFree, owned, ownedIds, products} и при каждом изменении, а также возвращает функцию отписки, поэтому вернувшийся покупатель разблокирован уже при первой отрисовке. В Next.js добавьте 'use client' первой строкой и собирайте с output: 'export'.
Также: AppMintIAP.available, .isOwned(id), .owned(), .buy(id), .state(), .refresh(). Подписи data-iap-buy работают и в JSX, а кнопки, которые React монтирует позже, подхватываются автоматически.
Хотите работать с самим мостом напрямую? Всё выше построено на этом:
- iap.isOwned(id) — мгновенный true/false, работает без интернета
- iap.getOwnedProducts() — JSON-массив всех купленных идентификаторов
- iap.getProducts(reqId, idsJson) — актуальные цены; результат приходит как событие
'appmint:products'{requestId, products:[{productId, title, price, currency, type, owned}]} - iap.purchase(id) — разовые продукты И подписки (определяется автоматически)
- События:
'appmint:purchase'{productId} (открывайте доступ ТОЛЬКО здесь: нажатие на уже купленное вызывает его снова),'appmint:owned-changed'{products} (восстановление при запуске завершено или возврат денег что-то отнял — перечитайте права),'appmint:purchase-failed'{reason: disabled | not_found | billing_error | pending}
Продаёте только одно улучшение «убрать рекламу»? Используйте более простую встроенную пару: включите «Удалить рекламу (IAP)» на шаге 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();
});Удалить рекламу (IAP)
Настройка Google Play Billing
1Создание управляемого продукта#
В панели Google Play Console:
- Перейдите в «Монетизация» > «Продукты в приложении»
- Нажмите «Создать продукт»
- Введите Product ID (например, «remove_ads_premium»)
- Задайте название (например, «Remove All Ads») и описание
- Задайте цену и нажмите «Сохранить»
- Нажмите «Активировать», чтобы сделать его доступным для тестирования
Продаёте отключение рекламы как ПОДПИСКУ? Создайте её в «Монетизация» > «Подписки» с автопродлеваемым базовым планом и активируйте И базовый план, И подписку. Тогда отсутствие рекламы длится, пока подписка активна, а когда она закончится, реклама вернётся автоматически.
2Получите лицензионный ключ#
Google Play требует открытый ключ RSA, чтобы надёжно проверять покупки.
В Play Console:
- Перейдите в «Монетизация» > «Настройка монетизации»
- В разделе «Licensing» скопируйте «Base64-encoded RSA public key»
- ⚠️ ВАЖНО: позже вам нужно будет вставить это в генератор.
Example Key Format:
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...3Настройка в генераторе WebToApk#
В приложении-генераторе:
- Включите переключатель «Удалить рекламу (IAP)» на шаге 3
- Введите ТОЧНО тот же «Product ID», который вы создали на шаге 1
- Вставьте «Base64 License Key» из шага 2
- Создайте APK/AAB
Предлагаете больше одного тарифа без рекламы (например, ежемесячную подписку И пожизненную разовую разблокировку)? Поместите все Product ID в это одно поле через запятую: владение ЛЮБЫМ из них убирает рекламу. Первый указанный идентификатор покупается встроенным пунктом меню «Удалить рекламу», поэтому поставьте первым свой тариф по умолчанию.
Product ID: remove_ads_premium
Two plans:
remove_ads_monthly,remove_ads_lifetime4Настройка лицензионных тестеров#
Чтобы проверить покупки, не тратя настоящие деньги:
- В Play Console перейдите в «Настройка» > «Лицензионное тестирование»
- Добавьте свой адрес Gmail в список «License testers»
- Убедитесь, что «License response» установлен на «RESPOND_NORMALLY»
5Тестирование на внутреннем или закрытом треке#
Покупки в приложении работают ТОЛЬКО когда приложение установлено через Play Store (тестовые треки):
- Загрузите созданный AAB на трек «Внутреннее тестирование» или «Закрытое тестирование»
- Отправьте ссылку-приглашение тестерам вашим тестовым аккаунтам
- Скачайте приложение из Play Store
- Нажмите кнопку «Удалить рекламу» в приложении, чтобы проверить процесс!
Note: Purchases will show a 'Test instrument, always approves' message for license testers.Рецепты кода
Шаги выше охватывают основы: четыре подписи, полную простую страницу, однострочник для React. Эти рецепты охватывают то, что туда не вошло: несколько тарифов, заменяющих друг друга, подписки, которые заканчиваются, хуки для фреймворков, полный мост и справочные таблицы.
Главное правило#
Покупка засчитывается только когда Google её подтвердил. Не когда нажата кнопка, не когда открылся платёжный экран и не когда пользователь вернулся на вашу страницу. Всё ниже — просто разные способы выразить это одно правило.
Среда выполнения уже это обеспечивает: AppMintIAP заново читает права с моста при запуске, при каждой покупке, при каждом возврате и каждый раз при повторном открытии приложения. Право сохраняется при переустановках, на новом телефоне и без интернета, потому что оно хранится в аккаунте Google покупателя, а не на вашей странице.
Никогда не открывайте доступ в обработчике клика и не храните право на покупку только в localStorage. Очистка хранилища браузера не должна лишать платящего клиента того, что он купил, а скопированное значение localStorage не должно отдавать это бесплатно.
Рецепт 1 — таблица цен с актуальными ценами#
Три тарифа, где покупка любого из них заменяет остальные. Именно group делает их альтернативами: без него это несвязанные продукты, и после покупки пожизненного тарифа кнопка месячного так и осталась бы на месте.
<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>Идентификатор продукта, о котором Google не знает (ещё не создан, не активирован или приложение установлено не из Play Store), просто остаётся без цены. Его кнопка остаётся нажимаемой намеренно: нажатие покажет настоящую причину, а не ничего.
Рецепт 2 — React#
onChange срабатывает сразу с текущим состоянием и снова при каждом изменении, а также возвращает собственную функцию отписки, то есть именно то, что нужно для useEffect.
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>
);
}Кнопка вызывает buy(), но ничего не открывает. Доступ открывается потому, что Google подтверждает покупку, срабатывает onChange, owns('pro_unlock') становится true и компонент перерисовывается. Это то же самое правило, выраженное в React.
Рецепт 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>Рецепт 4 — подписки#
Подписка покупается точно так же, как разовый продукт; разница в том, что она может прекратиться. type показывает, какого вида продукт, а period задаёт интервал оплаты как длительность ISO-8601: P1M — месяц, P1Y — год, P1W — неделя.
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.Опрашивать ничего не нужно, и дата окончания не требуется. Play восстанавливает набор купленного при каждом запуске, поэтому закончившаяся подписка просто перестаёт появляться в ownedIds.
Активируйте в Play Console и подписку, и её базовый план. Подписка с неактивным базовым планом вообще не возвращает данных, и кнопка сообщит «пока недоступно в Google Play».
Рецепт 5 — сам мост, без помощника#
Всё выше построено на шести методах и пяти событиях. Если вы хотите полностью управлять интерфейсом, используйте их напрямую.
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 намеренно срабатывает снова для уже купленного. Напишите unlockPro() так, чтобы повторный запуск был безвреден и вам не приходилось отслеживать, запускали ли вы его раньше.
Рецепт 6 — корректная деградация вне приложения#
Одну и ту же страницу обычно нужно открывать и в обычном браузере: во время разработки или потому что вы публикуете её ещё и в вебе. Там window.WebToApk не существует, и покупка невозможна. Скажите об этом, а не показывайте кнопку, которая ничего не делает.
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;
}Справочник#
Всё, что страница может вызвать, слушать или разметить.
| Вызов | Действие |
|---|---|
AppMintIAP.available | False в браузере или когда покупки в приложении не были включены для этой сборки. Проверяйте это, прежде чем показывать что-либо на продажу. |
AppMintIAP.sell(id, button, opts) | Связывает один продукт с одной кнопкой. opts: label, owned, group, show, hide, labelEl. Вызывайте один раз на каждый продукт. |
AppMintIAP.start(config) | Связывает сразу несколько: {plans, show, hide, container, toast, hideOtherPlans}. Используйте, если нужна своя функция toast или чтобы другие тарифы группы оставались видимыми после покупки одного (hideOtherPlans: false). |
AppMintIAP.onChange(fn) | Срабатывает сразу с текущим состоянием, затем при каждом изменении. Возвращает функцию отписки. |
AppMintIAP.state() | Тот же объект, один раз: {available, adFree, owned, ownedIds, products}. |
AppMintIAP.isOwned(id) | True/false, мгновенно, без интернета. |
AppMintIAP.owned() | Массив всех купленных идентификаторов продуктов. |
AppMintIAP.buy(id) | Открывает платёжный экран Google. Сам по себе ничего не выдаёт. |
AppMintIAP.refresh() | Заново проверяет кнопки data-iap-buy, добавленные после последнего прохода. Нужно редко: среда выполнения сама следит за DOM. |
| Вызов | Возвращает |
|---|---|
purchase(productId) | Ничего. Открывает платёжный экран; результат приходит как событие. |
getProducts(requestId, idsJson) | Ничего. Ответ приходит как appmint:products с тем же requestId. |
getOwnedProducts() | JSON-массив купленных идентификаторов продуктов в виде строки. |
isOwned(productId) | Логическое значение: разовый продукт куплен или подписка сейчас активна. |
isPremium() | Логическое значение: именно право «убрать рекламу». |
startRemoveAdsPurchase() | Ничего. Открывает платёжный экран для ПЕРВОГО идентификатора из поля мастера «Удалить рекламу»; владение любым из перечисленных там идентификаторов убирает рекламу. Результат приходит как appmint:premium. |
| Событие | подробности | Когда |
|---|---|---|
appmint:purchase | {productId, owned} | Google подтвердил. Единственное место, где открывать доступ. Срабатывает снова для уже купленного. |
appmint:purchase-failed | {productId, reason} | Экран не открылся, процесс завершился ошибкой или платёж в ожидании. Не срабатывает при обычной отмене. |
appmint:products | {requestId, products[]} | Ответ на вызов getProducts(). Идентификаторы, которых Play не знает, просто отсутствуют. |
appmint:premium | {premium} | Право на отсутствие рекламы изменилось: покупка, восстановление при запуске, возврат или окончание срока. |
appmint:owned-changed | {products[]} | Восстановление при запуске завершено или возврат денег что-то отнял. Содержит все купленные идентификаторы. |
| Атрибут | Вкл. | Действие |
|---|---|---|
data-iap-buy="id" | кнопка | Заставляет покупать этот продукт. |
data-iap-label="… {price}" | кнопка | Шаблон подписи. {price} заменяется актуальной локализованной ценой от Google. |
data-iap-owned="…" | кнопка | Подпись после того, как продукт куплен. |
data-iap-group="name" | кнопка | Помечает тарифы как альтернативы друг другу. |
data-iap-show="id|group" | что угодно | Показывается только когда это куплено. |
data-iap-hide="id|group" | что угодно | Скрывается, когда это куплено. |
data-iap-text | внутри кнопки | Элемент, текст которого получает подпись, если внутри кнопки есть не только текст. |
data-iap-container | контейнер | Показывается только внутри приложения; в браузере скрыт, ведь там нечего покупать. Оберните в него всё предложение. |
data-iap-state="owned" | задаётся средой выполнения | На кнопке купленного продукта, чтобы вы могли её оформить. |
| причина | Что на самом деле произошло |
|---|---|
not_found | Google не знает этот идентификатор продукта: он не создан, не активирован, либо приложение установлено не из Play Store. |
billing_error | Play не смог начать платёж. Обычно это временно; покупателю стоит повторить попытку. |
pending | Медленный способ оплаты (наличные, банковский перевод). Продукт откроется сам, когда Google подтвердит платёж; не считайте это неудачей. |
disabled | Покупки в приложении не были включены для этой сборки. |
unknown | Всё остальное. |
Перед выпуском#
- Каждый идентификатор продукта из вашего кода существует в Play Console и активирован, а для подписки ещё и базовый план.
- В сборке те же идентификаторы и лицензионный ключ RSA в Base64 из настройки монетизации.
- Ваш адрес Gmail указан в Настройка → Лицензионное тестирование, а ответ установлен на RESPOND_NORMALLY.
- Вы загрузили AAB во внутреннее или закрытое тестирование и установили приложение по ссылке Play Store: установленный вручную APK никогда ничего купить не сможет.
- Вы купили каждый продукт как лицензионный тестер и увидели «Test instrument, always approves».
- Вы удалили и заново установили приложение, и покупка вернулась сама.
- Вы отключили сеть, а платный контент остался открытым.
- Вы вернули деньги за тестовую покупку в Play Console и увидели, что приложение снова заблокировалось.
Ошибки, которые стоят денег#
- Открытие доступа в обработчике клика. Любой, кто откроет платёжный экран и выйдет, получит продукт бесплатно.
- Хранение права на покупку только в
localStorage. Очистка данных сайта отнимает то, за что клиент заплатил; копирование значения отдаёт это даром. - Жёстко прописанная цена в HTML. Она будет неверной в любой другой стране и устареет в день её изменения. Используйте
{price}илиproducts[id].price. - Продажа физических товаров или реальных услуг через платежи Play. Google требует обратного: для них нужен обычный платёжный процессор. Для этого и нужно руководство по Stripe.
- Тестирование на APK, установленном вручную. Покупки в приложении работают только когда приложение установлено из Play Store. Ничто из написанного вами этого не изменит.
- Считать
pendingнеудачей. Покупатель заплатил медленным способом; продукт откроется сам, когда Google подтвердит платёж.