SDK
You will install our iOS or Android library, identify the customer, show the paywall from the app and learn whether access is open.
Before you start
- Project ID. Cabinet: "Project settings" → "App connection" → "For developers". API address:
https://api.subster.ai. - iOS 14 or later, Android 7.0 (API 24) or later.
Step 1. Install the library
iOS. Xcode → File → Add Package Dependencies, address https://github.com/web2web-dev/web2app-ios-sdk.git, version 0.8.1, product Web2AppSDK.
Android. The library is on JitPack and declares the internet permission itself.
// 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")
}
Step 2. Call configure at launch
Once, before any other call. Without it, methods refuse and make no network requests.
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")
The cabinet example will not compile: import Web2App, a string address on iOS, no context on Android.
Step 3. Identify the customer
The library exchanges the handoff code for the buyer identifier (guid) and stores it on the device.
| What you have | iOS | Android |
|---|---|---|
| first launch, no code | identify(deepLinkValue: nil) | identify(onResult, onNeedEmail) |
| code from the return link, an email or a tracker | identify(deepLinkValue: code) | identifyWithDeepLinkValue(code) |
| identification failed | .failure(.needsEmailFallback) → requestEmailRecovery(email) | onNeedEmail → requestEmailRecovery(email) |
| customer already identified | currentGuid() | currentGuid() |
order of checks in identify | stored → code, and without a code device fingerprint → email | stored → Install Referrer → device fingerprint → email |
Getting the code from the link: Return to the app, step 3. What is behind each path: How we recognise the customer.
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) { }
)
Common mistakes:
- On iOS the stored identifier beats the code. If one exists,
identify(deepLinkValue:)returns it without exchanging the code. A customer who already got an identifier from the library (say, when shown the paywall) will not switch via a new return link. On AndroididentifyWithDeepLinkValuealways exchanges the code. - In AppsFlyer the code is in
deep_link_sub1, copied inaf_sub1.deep_link_valueholds the wordhandoff, which fails the exchange. - The device fingerprint is tried for two hours from the first failure, the snapshot's lifetime on the server. Then the library asks for the email at once; the log shows
identify.fingerprint_window_expired. - The library sends the first-open mark after identification: skip step 5 of the Handoff page.
Step 4. Ask for the entitlement
entitlement asks by the stored identifier, via the public address from Check the 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()
}
| Grant field | What it holds |
|---|---|
isActive | true when the status is active |
level | the level the project assigned to the price; without such a pair, the price identifier |
status | active, expired or revoked |
expiresAt | end of term in ISO 8601; empty for a grant without a term |
priceId | price identifier |
testMode | true for a test mode grant (section "While test mode is on" below); present since iOS 0.8.0 and Android 0.7.2, absent in earlier versions |
Where the short answer misleads:
- The library returns one grant, the latest, not a list. If the latest of several expired or was revoked,
isActiveisfalsethough an older one is valid. Selling more than one product? Read the whole list from your server or via the public address from the app. - An empty answer has three causes: no grants, no stored identifier yet, no network response. Do not revoke access on one empty answer.
Step 5. Show the paywall from the app
Start with the embedded window: no return scheme needed, the outcome arrives at once, and screen events reach the listener.
| How to open | iOS | Android | What comes back |
|---|---|---|---|
| embedded window, by ID | openWebPaywallEmbedded(paywallId:) | openWebPaywallEmbeddedById(context, paywallId) | PaywallResult |
| embedded window, by address | openWebPaywallEmbedded(paywallURL:) | openWebPaywallEmbedded(context, paywallUrl) | PaywallResult |
| browser window, by ID | openWebPaywall(paywallId:) | openWebPaywallById(context, paywallId) | grant or nil |
| browser window, by address | openWebPaywall(paywallURL:) | openWebPaywall(context, paywallUrl) | grant or nil |
The browser window is SFSafariViewController or Chrome Custom Tabs. Optional for all four: email prefills the email field; adaptyProfileId and revenuecatProfileId are for the subscription platform.
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()
}
}
| Result | What happened |
|---|---|
paid · Paid | payment confirmed, the grant is in hand |
pending · Pending | the page reported a payment, but the grant did not appear within ten seconds: ask for the entitlement again in a few seconds |
notPaid · NotPaid | the screen was closed, no grant |
unavailable · Unavailable | the screen was not shown: no configure, or no screen found by ID. On Android since 0.7.2, also a broken display of the paywall or quiz: the page crashed twice in a row. On iOS a crashed page reloads, so this outcome does not follow a display. For the paywall, before this outcome the library checks the payment for up to 10 seconds and returns Paid if it went through |
Copy the screen ID in the cabinet: "Funnels" → "Paywalls" tab → ⋮ in the screen row → "Copy the ID". An ID finds only a screen bound to a domain directly, not one inside a bound funnel.
The payment goes to the library's identifier, returned by currentGuid(); if none is stored, the library creates one. Pass it to your server.
| How the screen is shown | When the library starts asking for the entitlement | For how long |
|---|---|---|
| embedded window | after the window closes | once a second, up to 10 times |
| browser window on iOS | after the window closes | every two seconds, up to 30 times |
| browser window on Android | right when it opens | every two seconds, up to 30 times |
On Android the browser window does not report closing, so a minute after opening the callback gets nil, even if the customer is still paying. Ask again when the app returns to the foreground.
A new display closes the previous one: two embedded windows never coexist.
Display without waiting on iOS
A preloaded screen opens by ID instantly.
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 */ }
| Question | Answer |
|---|---|
| what is visible without preloading | a loading indicator while the page loads: on iOS since 0.8.0, on Android since 0.7.2 |
| where it works | iOS since 0.8.0; Android has no preloading |
| when to call | after identify, well before the display |
| when the ready page is used | display through openWebPaywallEmbedded(paywallId:), email and both profile ids the same as when preloading, page younger than an hour; otherwise the display goes the usual way, with loading |
| customer not identified yet | since 0.8.1 preloading works anyway; in 0.8.0 the call did nothing |
| after the display | with any outcome the library loads this screen again |
| screen no longer needed | invalidatePreloadedPaywalls(paywallIds:); all at once, for example on sign-out, clearPreloadedPaywalls() |
| memory | each preloaded page is a separate process of tens of megabytes; keep only those you will show |
| statistics | until the screen is shown, the view, pixels and speed measurement are not counted |
Embedded window events
The embedded page reports every visitor step. The library has one listener: a new one replaces the old, an empty value removes it, nothing removes it automatically. The browser window has no events.
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
| Event | When | Fields in data | Closes the window |
|---|---|---|---|
quiz_start | quiz start | — | no |
quiz_screen_view | a screen is shown | screenId, screenIndex, screenTotal | no |
quiz_answer | an answer; each answer gets its own event | the same plus blockId, blockType | no |
quiz_email_submit | email submitted | screenId | no |
quiz_complete | quiz completed | — | no |
paywall_result | outcome of the paywall | — | yes, if paid |
close | the page asks to close the window | — | yes |
screenIndexcounts from one: screens the visitor has passed;screenTotalis the length of their own path. This holds since 3 September 2026; for branching funnels, earlier events overstate progress.- The event cannot tell payment from closing:
paywall_resultarrives in both cases without an outcome. Use the display result. - Events carry neither email nor answers. Your server collects answers: Buyer properties.
Quiz in the embedded window
openQuizEmbedded(quizURL:) on iOS and openQuizEmbedded(context, quizUrl) on Android open the quiz in the same window, with the same events. The result is the close reason: page, the page; user, the user; paid, a payment inside the window; unavailable, no configure or a broken display. Unlike the paywall, the quiz does not check access: after payment, ask for the entitlement (step 4).
There is no opening by ID for quizzes. GET /public/quiz-url/<quizId> returns the address: { "success": true, "data": { "url": "…" } }, or 404 if the quiz is unpublished or the domain is not bound to the quiz itself.
While test mode is on
Test mode is turned on in the cabinet: how. While it is on, the library gets an active test-level grant for any identifier the project knows.
- Recognize it by the
testModefield (step 4) orlevel == "test". - The paywall can also return "paid" without payment: the project learns the library's identifier once the page opens. Test payments with Stripe test keys.
What the library does and what is left to you
| Task | Who |
|---|---|
| store the buyer identifier, exchange the handoff code, Install Referrer and device fingerprint for it | library |
| request the email with a code and send the first-open mark | library |
| add the identifier, email and subscription platform profiles to the paywall address | library |
| wait for the entitlement after payment and write "SDK logs" | library |
| receive Universal Links and App Links and extract the code from them | you: Return to the app, step 3 |
register the return scheme and pass its addresses to handleReturnURL / handleReturnUrl | you: same page, step 4 |
| show the email field when the library asks for it | you |
| check access from your server when money depends on the decision | you: Check the entitlement |
| learn about renewals, cancellations and refunds | you: Webhooks |
keep the sk_live_… key only on the server, not in the app | you |
Debugging
- Threads. On Android all callbacks arrive on the main thread. On iOS so do displays and the listener, but
identify,entitlementandrequestEmailRecoverycall back on a background thread: touch the UI viaDispatchQueue.main.async. - Log. The library logs each step to the Xcode console with the prefix
[Web2App]and to Logcat with the tagWeb2App. The cabinet shows the same feed: Troubleshooting. - Re-identify on the same device.
debugClear()erases the stored identifier,debugSetGuidsets your own. On Android both also ship in release builds: wrap calls inif (BuildConfig.DEBUG).
Next
- Return to the app: links that open the app from the browser, and the return scheme.
- How we recognise the customer: six identification paths and what to do when one fails.
- Check the entitlement: the full answer field by field, for customers with several grants.
- Troubleshooting: "SDK logs" and the complaint "I paid but have no access".