S Subster

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

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 haveiOSAndroid
first launch, no codeidentify(deepLinkValue: nil)identify(onResult, onNeedEmail)
code from the return link, an email or a trackeridentify(deepLinkValue: code)identifyWithDeepLinkValue(code)
identification failed.failure(.needsEmailFallback) → requestEmailRecovery(email)onNeedEmail → requestEmailRecovery(email)
customer already identifiedcurrentGuid()currentGuid()
order of checks in identifystored → code, and without a code device fingerprint → emailstored → 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:

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 fieldWhat it holds
isActivetrue when the status is active
levelthe level the project assigned to the price; without such a pair, the price identifier
statusactive, expired or revoked
expiresAtend of term in ISO 8601; empty for a grant without a term
priceIdprice identifier
testModetrue 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:

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 openiOSAndroidWhat comes back
embedded window, by IDopenWebPaywallEmbedded(paywallId:)openWebPaywallEmbeddedById(context, paywallId)PaywallResult
embedded window, by addressopenWebPaywallEmbedded(paywallURL:)openWebPaywallEmbedded(context, paywallUrl)PaywallResult
browser window, by IDopenWebPaywall(paywallId:)openWebPaywallById(context, paywallId)grant or nil
browser window, by addressopenWebPaywall(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()
    }
}
ResultWhat happened
paid · Paidpayment confirmed, the grant is in hand
pending · Pendingthe page reported a payment, but the grant did not appear within ten seconds: ask for the entitlement again in a few seconds
notPaid · NotPaidthe screen was closed, no grant
unavailable · Unavailablethe 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 shownWhen the library starts asking for the entitlementFor how long
embedded windowafter the window closesonce a second, up to 10 times
browser window on iOSafter the window closesevery two seconds, up to 30 times
browser window on Androidright when it opensevery 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 */ }
QuestionAnswer
what is visible without preloadinga loading indicator while the page loads: on iOS since 0.8.0, on Android since 0.7.2
where it worksiOS since 0.8.0; Android has no preloading
when to callafter identify, well before the display
when the ready page is useddisplay 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 yetsince 0.8.1 preloading works anyway; in 0.8.0 the call did nothing
after the displaywith any outcome the library loads this screen again
screen no longer neededinvalidatePreloadedPaywalls(paywallIds:); all at once, for example on sign-out, clearPreloadedPaywalls()
memoryeach preloaded page is a separate process of tens of megabytes; keep only those you will show
statisticsuntil 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
EventWhenFields in dataCloses the window
quiz_startquiz start—no
quiz_screen_viewa screen is shownscreenId, screenIndex, screenTotalno
quiz_answeran answer; each answer gets its own eventthe same plus blockId, blockTypeno
quiz_email_submitemail submittedscreenIdno
quiz_completequiz completed—no
paywall_resultoutcome of the paywall—yes, if paid
closethe page asks to close the window—yes

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.

What the library does and what is left to you

TaskWho
store the buyer identifier, exchange the handoff code, Install Referrer and device fingerprint for itlibrary
request the email with a code and send the first-open marklibrary
add the identifier, email and subscription platform profiles to the paywall addresslibrary
wait for the entitlement after payment and write "SDK logs"library
receive Universal Links and App Links and extract the code from themyou: Return to the app, step 3
register the return scheme and pass its addresses to handleReturnURL / handleReturnUrlyou: same page, step 4
show the email field when the library asks for ityou
check access from your server when money depends on the decisionyou: Check the entitlement
learn about renewals, cancellations and refundsyou: Webhooks
keep the sk_live_… key only on the server, not in the appyou

Debugging

Next