SDK
Вы поставите нашу библиотеку для iOS или Android, опознаете покупателя, покажете экран с ценами прямо из приложения и узнаете, открыт ли доступ.
Перед началом
- ID проекта. Кабинет: «Настройки проекта» → «Подключение приложения» → «Для разработчика». Адрес API —
https://api.subster.ai. - iOS 14 или новее, Android 7.0 (API 24) или новее.
Шаг 1. Поставьте библиотеку
iOS. Xcode → File → Add Package Dependencies, адрес https://github.com/web2web-dev/web2app-ios-sdk.git, версия 0.8.1, продукт Web2AppSDK.
Android. Библиотека лежит в JitPack; разрешение на интернет она объявляет сама.
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}
// build.gradle.kts of the app module
dependencies {
implementation("com.github.web2web-dev:web2app-android-sdk:0.7.2")
}
Шаг 2. Вызовите configure при запуске
Один раз, до любого другого вызова. Без него методы отвечают отказом и в сеть не ходят.
import Web2AppSDK
// once at launch; baseUrl is a URL, not a String
Web2App.configure(projectId: "<PROJECT_ID>", baseUrl: URL(string: "https://api.subster.ai")!)
import app.web2app.sdk.Web2AppSdk
// once at launch, e.g. in Application.onCreate(); Android needs a context
// since 0.7.2 configure warms up WebView (hundreds of ms on the main thread);
// opt out with warmUpWebView = false; call WebView.setDataDirectorySuffix before configure
Web2AppSdk.configure(applicationContext, "<PROJECT_ID>", "https://api.subster.ai")
Пример в кабинете не соберётся: там import Web2App, адрес строкой на iOS и нет контекста на Android.
Шаг 3. Опознайте покупателя
Библиотека меняет код перехода на идентификатор покупателя (guid) и сохраняет его на устройстве.
| Что у вас | iOS | Android |
|---|---|---|
| первый запуск, кода нет | identify(deepLinkValue: nil) | identify(onResult, onNeedEmail) |
| код из ссылки возврата, из письма или от трекера | identify(deepLinkValue: code) | identifyWithDeepLinkValue(code) |
| опознать не удалось | .failure(.needsEmailFallback) → requestEmailRecovery(email) | onNeedEmail → requestEmailRecovery(email) |
| покупатель уже опознан | currentGuid() | currentGuid() |
порядок проверок в identify | сохранённый → код, а без кода отпечаток устройства → почта | сохранённый → Install Referrer → отпечаток устройства → почта |
Как достать код из ссылки — «Возврат в приложение», шаг 3. Что стоит за каждым путём — «Как мы узнаём покупателя».
Web2App.identify(deepLinkValue: code) { result in
switch result {
case .success(let guid): print("identified:", guid)
case .failure(.needsEmailFallback): showEmailField() // then Web2App.requestEmailRecovery(email) { _ in }
case .failure(let error): print("identify failed:", error)
}
}
Web2AppSdk.identify(
onResult = { result -> result.onSuccess { guid -> /* identified */ } },
onNeedEmail = { showEmailField() }, // then Web2AppSdk.requestEmailRecovery(email) { }
)
Где ошибаются чаще всего:
- На iOS сохранённый идентификатор главнее кода. Если он уже есть,
identify(deepLinkValue:)возвращает его и код не обменивает. Покупатель, которому библиотека успела завести идентификатор — например, при показе экрана с ценами, — по новой ссылке возврата на другой не перейдёт. На AndroididentifyWithDeepLinkValueобменивает код всегда. - У AppsFlyer код лежит в
deep_link_sub1, дубль вaf_sub1. Вdeep_link_valueстоит словоhandoff, и обмен с ним проваливается. - Отпечаток устройства пробуется два часа с первой неудачи — столько живёт слепок на сервере. Потом библиотека сразу просит почту, в логах — шаг
identify.fingerprint_window_expired. - Отметку первого открытия шлёт библиотека после удачного опознания: шаг 5 страницы «Возврат в приложение» не нужен.
Шаг 4. Спросите право доступа
entitlement спрашивает право по сохранённому идентификатору — тем же открытым адресом, что в «Проверить право доступа».
Web2App.entitlement { grant in
// iOS calls this closure on a background thread
DispatchQueue.main.async {
if grant?.isActive == true { unlockPremium() }
}
}
Web2AppSdk.entitlement { grant ->
if (grant?.isActive == true) unlockPremium()
}
| Поле записи | Что в нём |
|---|---|
isActive | true, когда статус active |
level | уровень, который проект назначил цене; без такой пары — идентификатор цены |
status | active, expired или revoked |
expiresAt | конец срока в ISO 8601; пусто у бессрочной записи |
priceId | идентификатор цены |
testMode | true у записи режима проверки (раздел «Пока включён режим проверки» ниже); поле есть с iOS 0.8.0 и Android 0.7.2, в ранних версиях его нет |
Где короткий ответ подводит:
- Библиотека отдаёт одну запись, самую свежую, а не список. Если записей несколько и свежая истекла или отозвана,
isActiveбудетfalse, хотя старая действует. Продаёте больше одного продукта — разбирайте весь список со своего сервера или открытым адресом из приложения. - Пустой ответ приходит в трёх случаях: записей нет, идентификатор ещё не сохранён, сеть не ответила. Не отнимайте доступ по одному пустому ответу.
Шаг 5. Покажите экран с ценами из приложения
Начните со встроенного окна: схема возврата ему не нужна, исход приходит сразу, а события экрана доезжают до слушателя.
| Как открыть | iOS | Android | Что вернётся |
|---|---|---|---|
| встроенное окно, по ID | openWebPaywallEmbedded(paywallId:) | openWebPaywallEmbeddedById(context, paywallId) | PaywallResult |
| встроенное окно, по адресу | openWebPaywallEmbedded(paywallURL:) | openWebPaywallEmbedded(context, paywallUrl) | PaywallResult |
| окно браузера, по ID | openWebPaywall(paywallId:) | openWebPaywallById(context, paywallId) | запись о доступе или nil |
| окно браузера, по адресу | openWebPaywall(paywallURL:) | openWebPaywall(context, paywallUrl) | запись о доступе или nil |
Окно браузера — это SFSafariViewController и Chrome Custom Tabs. Необязательные параметры у всех четырёх: email подставится в поле почты, adaptyProfileId и revenuecatProfileId — для подписочной платформы.
Web2App.openWebPaywallEmbedded(paywallId: "<PAYWALL_ID>", email: userEmail) { result in
switch result {
case .paid(let grant): unlockPremium(grant)
case .pending: recheckEntitlementLater() // paid, the record has not arrived yet
case .notPaid: break
case .unavailable: showError() // the screen was not shown
}
}
Web2AppSdk.openWebPaywallEmbeddedById(context, "<PAYWALL_ID>", email = userEmail) { result ->
when (result) {
is PaywallResult.Paid -> unlockPremium(result.grant)
PaywallResult.Pending -> recheckEntitlementLater()
PaywallResult.NotPaid -> Unit
PaywallResult.Unavailable -> showError()
}
}
| Результат | Что случилось |
|---|---|
paid · Paid | оплата подтверждена, запись о доступе в руках |
pending · Pending | страница сообщила об оплате, но запись за десять секунд не появилась — спросите право ещё раз через несколько секунд |
notPaid · NotPaid | экран закрыли, записи нет |
unavailable · Unavailable | экран не показан: нет configure или экран не нашёлся по ID. На Android с 0.7.2 — ещё и сорванный показ экрана с ценами или квиза: страница упала два раза подряд. На iOS упавшая страница загружается заново, такого исхода после показа нет. У экрана с ценами перед этим исходом библиотека до 10 секунд проверяет оплату и, если она прошла, отдаёт Paid |
ID экрана копируется в кабинете: «Воронки» → вкладка «Пейволлы» → ⋮ в строке экрана → «Скопировать ID». По ID находится только экран, к которому домен привязан напрямую; экран внутри привязанной воронки не найдётся.
Оплата ложится на идентификатор библиотеки, тот, что отдаёт currentGuid(); если сохранённого нет, библиотека заводит новый. Его и передавайте своему серверу.
| Как показан экран | Когда библиотека начинает спрашивать право | Как долго |
|---|---|---|
| встроенное окно | после закрытия окна | раз в секунду, до 10 раз |
| окно браузера на iOS | после закрытия окна | раз в две секунды, до 30 раз |
| окно браузера на Android | сразу при открытии | раз в две секунды, до 30 раз |
На Android окно браузера не сообщает о закрытии, поэтому через минуту после открытия колбэк получит nil, даже если покупатель ещё платит. Спросите право заново, когда приложение вернулось на передний план.
Новый показ закрывает прежний: два встроенных окна одновременно не живут.
Показ без ожидания на iOS
Экран, загруженный заранее, открывается по ID сразу.
Web2App.preloadPaywalls(paywallIds: ["<PAYWALL_ID>"], email: userEmail)
// later, with the same email and profile ids
Web2App.openWebPaywallEmbedded(paywallId: "<PAYWALL_ID>", email: userEmail) { result in /* as above */ }
| Вопрос | Ответ |
|---|---|
| что видно без предзагрузки | индикатор загрузки, пока страница грузится: на iOS с 0.8.0, на Android с 0.7.2 |
| где работает | iOS с 0.8.0; на Android предзагрузки нет |
| когда звать | после identify, задолго до показа |
| когда готовая страница подойдёт | показ через openWebPaywallEmbedded(paywallId:), email и оба profile-id те же, что при предзагрузке, странице меньше часа; иначе показ идёт обычным путём, с загрузкой |
| покупатель ещё не опознан | с 0.8.1 предзагрузка работает и так; в 0.8.0 вызов ничего не делал |
| после показа | с любым исходом библиотека загружает этот экран заново |
| экран больше не понадобится | invalidatePreloadedPaywalls(paywallIds:); все сразу, например при выходе из аккаунта, — clearPreloadedPaywalls() |
| память | каждая заранее загруженная страница — отдельный процесс на десятки мегабайт; держите только те, что покажете |
| статистика | пока экран не показан, просмотр, пиксели и замер скорости не засчитываются |
События встроенного окна
Страница во встроенном окне сообщает о каждом шаге посетителя. Слушатель у библиотеки один: новый заменяет прежний, пустое значение снимает, сам он не снимается. В окне браузера событий нет.
Web2App.setFunnelEventListener { name, data in
analytics.log(name, ["screen": data.screenIndex as Any])
}
Web2App.setFunnelEventListener(nil) // unsubscribe when the screen is gone
Web2AppSdk.setFunnelEventListener { name, data ->
analytics.log(name, mapOf("screen" to data.screenIndex))
}
Web2AppSdk.setFunnelEventListener(null) // unsubscribe when the screen is gone
| Событие | Когда | Поля в data | Закрывает окно |
|---|---|---|---|
quiz_start | начало квиза | — | нет |
quiz_screen_view | показан экран | screenId, screenIndex, screenTotal | нет |
quiz_answer | ответ; на каждый ответ своё событие | те же плюс blockId, blockType | нет |
quiz_email_submit | почта отправлена | screenId | нет |
quiz_complete | квиз пройден | — | нет |
paywall_result | исход экрана с ценами | — | да, если оплачено |
close | страница просит закрыть окно | — | да |
screenIndexсчитается с единицы — сколько экранов посетитель уже прошёл,screenTotal— длина его собственного пути. Так считается с 3 сентября 2026 года; у ветвящихся воронок в событиях до этой даты доля пройденного завышена.- Оплатил покупатель или закрыл экран, по событию не узнать:
paywall_resultприходит в обоих случаях без исхода. Смотрите результат показа. - Почты и самих ответов в событиях нет. Ответы забирает ваш сервер — «Свойства покупателя».
Квиз во встроенном окне
openQuizEmbedded(quizURL:) на iOS и openQuizEmbedded(context, quizUrl) на Android открывают квиз в том же окне, с теми же событиями. Результат — причина закрытия: page — страница, user — пользователь, paid — внутри окна прошла оплата; unavailable — нет configure или сорванный показ. Квиз, в отличие от экрана с ценами, доступ не проверяет: после оплаты спросите право (шаг 4).
Открытия по ID у квиза нет. Адрес по ID отдаёт GET /public/quiz-url/<quizId> — ответ { "success": true, "data": { "url": "…" } }, либо 404, если квиз не опубликован или домен привязан не к нему самому.
Пока включён режим проверки
Режим проверки включают в кабинете — как. Пока он включён, библиотека получает действующую запись с уровнем test на любой идентификатор, который знает проект.
- Отличайте такую запись полем
testMode(шаг 4) или проверкойlevel == "test". - Показ экрана с ценами тоже может вернуть «оплачено» без оплаты: идентификатор библиотеки проект узнаёт, как только страница открылась. Путь оплаты проверяйте тестовыми ключами Stripe.
Что библиотека делает сама, а что остаётся вам
| Задача | Кто |
|---|---|
| хранить идентификатор покупателя, обменивать на него код перехода, Install Referrer и отпечаток устройства | библиотека |
| просить письмо с кодом и слать отметку первого открытия | библиотека |
| дописывать к адресу экрана с ценами идентификатор, почту и профили подписочных платформ | библиотека |
| дожидаться права доступа после оплаты и писать «Логи SDK» | библиотека |
| принимать Universal Link и App Link и доставать из них код | вы — «Возврат в приложение», шаг 3 |
регистрировать схему возврата и передавать её адреса в handleReturnURL / handleReturnUrl | вы — там же, шаг 4 |
| показывать поле почты, когда библиотека о нём просит | вы |
| проверять доступ со своего сервера, когда за решением стоят деньги | вы — «Проверить право доступа» |
| узнавать о продлениях, отменах и возвратах | вы — «Вебхуки» |
держать ключ sk_live_… только на сервере, не в приложении | вы |
Отладка
- Потоки. На Android все колбэки приходят на главном потоке. На iOS так приходят показы и слушатель, а
identify,entitlementиrequestEmailRecoveryзовут замыкание с фонового потока — интерфейс трогайте черезDispatchQueue.main.async. - Журнал. Каждый шаг библиотека пишет в консоль Xcode с префиксом
[Web2App]и в Logcat с тегомWeb2App. Ту же ленту видно в кабинете — «Диагностика». - Опознать заново на том же устройстве.
debugClear()стирает сохранённый идентификатор,debugSetGuidподставляет свой. На Android оба метода попадают и в релизную сборку: оборачивайте вызов вif (BuildConfig.DEBUG).
Дальше
- Возврат в приложение — ссылки, которыми браузер открывает приложение, и схема возврата.
- Как мы узнаём покупателя — шесть путей опознания и что делать, когда путь не сработал.
- Проверить право доступа — весь ответ по полям, если записей у покупателя больше одной.
- Диагностика — «Логи SDK» и разбор жалобы «оплатил, а доступа нет».