01
Add the dependencies
onlang-kit renders flows over DivKit. Add onlang-billing-play for Google Play Billing (or onlang-billing-revenuecat), and onlang-compose if your paywall lives in Jetpack Compose.
dependencies {
implementation("io.onlang:onlang-kit:1.0.0")
implementation("io.onlang:onlang-billing-play:1.0.0")
implementation("io.onlang:onlang-compose:1.0.0")
}
The artifacts are distributed to early-access teams. They are commercial software under the ONLang SDK License: free for 14 days per app, then 0.49% of every order or $49 a month.
02
Describe your components
The registry is the contract between your app and the server: which components exist, which props they take, and how long a text may be. The compiler checks every hypothesis against it.
{
"registry": "acme_fitness",
"version": "1.0.0",
"products": ["annual", "monthly"],
"components": {
"PaywallIntro": { "props": {
"headline": { "type": "token", "max_chars": 40 },
"body": { "type": "token", "max_chars": 90 } } },
"PlanToggle": { "props": {
"products": { "type": "products", "min_items": 2, "max_items": 2 },
"default": { "type": "product" } },
"interactive": true, "emits": ["select"] },
"PurchaseButton": { "props": {
"title": { "type": "token", "max_chars": 28 } } }
}
}
03
Register them in the app
Each registered component is your own View, built from the props the server chose. Props arrive as a JSONObject, with store prices filled in and copy in the user’s locale.
import io.onlang.kit.OnlangRegistry
val registry = OnlangRegistry()
.register("PaywallIntro") { props, ctx ->
AcmeIntro(ctx.android).apply {
title = props.getString("headline"); body = props.getString("body")
}
}
.register("PlanToggle", interactive = true) { props, ctx ->
AcmePlanToggle(ctx.android, props.getJSONArray("products")).apply {
onSelect = { plan -> ctx.fire("onlang://select?product=$plan") }
}
}
.register("PurchaseButton") { props, ctx ->
AcmeButtonLabel(ctx.android).apply { text = props.getString("title") }
}
04
Configure once, in your Application
Billing and prices stay yours. PlayBilling buys through Google Play Billing and fills in {{price.annual}} and friends from Play; map registry product names to Play product ids if they differ.
import io.onlang.billing.PlayBilling
import io.onlang.kit.Onlang
import io.onlang.kit.OnlangConfig
class App : Application() {
override fun onCreate() {
super.onCreate()
val billing = PlayBilling(this, productIds = mapOf(
"annual" to "pro_annual",
"monthly" to "pro_monthly",
))
Onlang.configure(
this,
OnlangConfig(
app = "acme_fitness",
baseUrl = "https://delivery.example.com",
sdkKey = "onl_pk_…",
legalUrls = mapOf("terms" to "https://acme.example/terms",
"privacy" to "https://acme.example/privacy"),
),
registry, purchases = billing, store = billing,
)
}
}
Purchases carry the install id as their obfuscated account id, so Play renewals and refunds reach the experiment that showed the paywall. With RevenueCat, RevenueCatPurchases sets the subscriber attribute onlang_install_id. If you buy outside ONLang, pass Onlang.installIdOrNull the same way.
Users in Russia: during the mobile-internet whitelist restrictions, phones on mobile data can’t reach the main hosts. Set baseUrl to https://delivery.ru.onlang.io, ONLang’s node in Moscow: same key, releases and experiments, and paywalls already seen keep loading.
05
Show a placement
On FAILED or KILLED, show your own paywall: that is your fallback when nothing can load, and when the placement is switched off.
import io.onlang.kit.FlowResult
import io.onlang.kit.OnlangFlowListener
import io.onlang.kit.OnlangFlowView
val view = OnlangFlowView(this)
view.listener = object : OnlangFlowListener {
override fun onFlowFinished(view: OnlangFlowView, result: FlowResult) {
when (result.outcome) {
FlowResult.Outcome.FAILED, FlowResult.Outcome.KILLED -> showClassicPaywall()
else -> finish()
}
}
}
setContentView(view)
view.show("paywall")
OnlangFlow(placement = "paywall", onFinished = { result -> onPaywallDone(result) })
06
Bundle the first launch
So the very first launch works offline, put assets/onlang/<placement>.<locale>.json in the app: a JSON array of the flow’s bundles. onlang compose writes them, one file per screen. After the first launch, the SDK caches what delivery serves.
onlang compose --placement paywall --locale en --out build/onlang/paywall
07
Write a hypothesis, publish, experiment
Hypotheses are code in your repository. onlang lint compiles every combination and checks it (lengths, price disclosure, legal links) before anything ships.
onlang hypothesis create --id paywall.cta.copy --kind tokens --screen paywall \
--key cta.title --rationale "Name the outcome, not the step" \
--variant "outcome=Get my plan" --variant "trial=Try Pro free for {{trial.annual}}"
onlang lint
ONLANG_TOKEN=onl_sk_… onlang publish
ONLANG_TOKEN=onl_sk_… onlang experiment create --placement paywall --holdout 0.1 --start
ONLANG_TOKEN=onl_sk_… onlang experiment status <id>
08
Ask for consent
Until your app records a choice, events wait on the device (up to 30 days) and nothing is sent. Uploads then run as WorkManager work: online only, with backoff, surviving process death.
import io.onlang.core.Consent
Onlang.setConsent(Consent.GRANTED)
Onlang.setConsent(Consent.DENIED)
If your app disables WorkManager’s default initializer, initialize WorkManager yourself; otherwise the SDK uploads from its own thread.
09
Know where the off switch is
One command takes the placement off ONLang. Delivery answers within a second, and the flow finishes with KILLED, even on devices that go offline afterwards: neither the cache nor the bundled flow is shown, so users see your own paywall until you switch it back on.
onlang kill paywall --reason "INC-12: CTA does nothing on Android 15"
onlang kill --status
onlang kill paywall --lift
The kit suite renders the same 45 golden and fuzz bundles as the iOS and React Native SDKs on a device, with pixel snapshots, and runs the consent-gated WorkManager upload; the billing adapters run against fake Play and RevenueCat clients. onlang-kit adds 234 KB to a release app over DivKit.