Skip to content
Back to developers
SAMPLE GUIDE · IOS

Your iOS paywall, server-driven.

OnlangKit renders ONLang flows with your own UIKit views, over DivKit: no web views, no downloaded code. This sample takes the paywall you ship today and serves it through ONLang, with today’s screen as the fallback.

YOU NEED
  • An ONLang project: onlang init in your app’s repository
  • Your app’s publishable key (onl_pk_…) and the delivery URL
  • iOS 15+, Xcode 26 (Swift 6.2+), Swift Package Manager
  • The paywall you ship today: it stays your screen when ONLang is switched off
  1. 01

    Add the Swift package

    Xcode → File → Add Package Dependencies. Depend on OnlangKit (rendering over DivKit) and OnlangStoreKit (StoreKit 2), or on OnlangRevenueCat if RevenueCat handles your purchases.

    Package.swift
    .package(url: "https://github.com/onlang-ai/onlang-ios.git", from: "1.0.0"),
    // only with RevenueCat:
    .package(url: "https://github.com/onlang-ai/onlang-ios-revenuecat.git", from: "1.0.0"),

    The packages 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.

  2. 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.

    onlang/registry.json
    {
      "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 } } }
      }
    }
  3. 03

    Register them in the app

    Each registered component is your own UIView, built from the props the server chose: store prices filled in, copy in the user’s locale. DivKit sizes it from the registry’s fixed height, or from its Auto Layout fitting size.

    Onlang+Registry.swift
    import OnlangKit
    
    let registry = OnlangRegistry()
      .register("PaywallIntro") { props, _ in
        AcmeIntro(title: props["headline"] as? String ?? "", body: props["body"] as? String ?? "")
      }
      // Interactive: handles its own touches, and reports them to ONLang
      .register("PlanToggle", interactive: true) { props, ctx in
        AcmePlanToggle(plans: props["products"] as? [String] ?? []) { plan in
          ctx.fire(URL(string: "onlang://select?product=\(plan)")!)
        }
      }
      // Not interactive: no gesture of its own. ONLang wraps it and routes the tap
      .register("PurchaseButton") { props, _ in
        AcmeButtonLabel(title: props["title"] as? String ?? "")
      }
  4. 04

    Configure once, at launch

    Billing and prices stay yours. StoreKitPurchases buys through StoreKit 2 and fills in {{price.annual}} and friends from the App Store; map registry product names to App Store ids if they differ.

    AppDelegate.swift
    import OnlangKit
    import OnlangStoreKit
    
    let storeKit = StoreKitPurchases(productIds: [
      "annual": "com.acme.pro.annual",
      "monthly": "com.acme.pro.monthly",
    ])
    Onlang.configure(
      OnlangConfig(app: "acme_fitness",
                   baseUrl: URL(string: "https://delivery.example.com")!,
                   sdkKey: "onl_pk_…",               // publishable: it ships in the app
                   legalUrls: ["terms": URL(string: "https://acme.example/terms")!,
                               "privacy": URL(string: "https://acme.example/privacy")!]),
      registry: registry, purchases: storeKit, store: storeKit
    )

    Purchases carry the install id as their appAccountToken, so App Store 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.installId 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.

  5. 05

    Present 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.

    PaywallPresenter.swift
    import OnlangKit
    
    let flow = OnlangFlowViewController(placement: "paywall")
    flow.delegate = self
    present(flow, animated: true)
    
    // OnlangFlowDelegate
    func flowDidFinish(_ flow: OnlangFlowViewController, result: FlowResult) {
      // .purchased, .restored, .closed, .completed, .failed, .killed
      switch result.outcome {
      case .failed, .killed: flow.dismiss(animated: true) { self.showClassicPaywall() }
      default: flow.dismiss(animated: true)
      }
    }
    
    // SwiftUI
    .sheet(isPresented: $showPaywall) {
      OnlangFlowView(placement: "paywall") { result in showPaywall = false }
    }
  6. 06

    Bundle the first launch

    So the very first launch works offline, add onlang/<placement>.<locale>.json to the app bundle as a folder reference: 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.

    terminal
    onlang compose --placement paywall --locale en --out build/onlang/paywall
    # build/onlang/paywall/<screen>.bundle.json → Resources/onlang/paywall.en.json (an array)
  7. 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.

    terminal
    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                                   # every assignment compiled and checked
    ONLANG_TOKEN=onl_sk_… onlang publish          # delivery serves the release at once
    ONLANG_TOKEN=onl_sk_… onlang experiment create --placement paywall --holdout 0.1 --start
    ONLANG_TOKEN=onl_sk_… onlang experiment status <id>   # uplift vs holdout, 95% interval
  8. 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.

    terminal
    onlang kill paywall --reason "INC-12: CTA does nothing on Android 15"
    onlang kill --status
    onlang kill paywall --lift

What the SDK does for you

  • Loads network → cache → the bundled flow; bundles are content-addressed and verified (SHA-256)
  • Renders your registered UIViews with DivKit: no web views, no downloaded code
  • Records impressions, dwell and scroll depth, taps with rage and dead clicks, plan switches and purchases
  • Queues events on the device and uploads them in the background, batched and retried; ingest drops duplicates

Rules of thumb

  • Billing, prices and entitlement stay in your app; ONLang never charges anyone
  • A component that isn’t interactive must not handle touches itself
  • Copy that has to be true about the device or the account belongs in a component, not a token
  • Treat .failed and .killed the same: show your own paywall

The kit suite renders the same 45 golden and fuzz bundles as the Android and React Native SDKs on the iOS Simulator, with pixel snapshots; a host app runs real touches and StoreKit 2 purchases against StoreKit Testing. OnlangKit adds 395 KB to a release app over DivKit.

Bring your iOS paywall.

Early-access teams get the SDK, the CLI and a hand with the first placement.

Request early access