Steplark
Remote-configured onboarding & paywalls for iOS. Live on launch, no redeploy.
Author your onboarding flow in the Steplark dashboard and ship it with one line of code. The SDK renders it on a dedicated window above your app at launch, runs the user through it, handles StoreKit, and reports analytics, then slides itself away. Change the flow anytime from the dashboard; no App Store release required.
Steplark.configure(apiKey: "sl_...", placement: "onboarding")
Features
| 🚀 Renders at t=0 | Covers the screen before your first frame paints, no flash |
| 🎛️ Remote-configured | Edit flows in the dashboard, no redeploy |
| 💳 StoreKit 2 built in | Purchase, restore, free-trial detection, entitlements |
| 📊 Analytics out of the box | Funnels, dwell time, conversion |
| ♻️ Resume after app kill | Users pick up where they left off |
| 🪶 No third-party dependencies | One Swift package, self-contained |
Requirements
iOS 15+ · Swift 5.9+ / Xcode 15+
Installation
In Xcode: File → Add Package Dependencies…, then paste:
https://github.com/steplark/steplark-ios.git
Or add it to Package.swift
dependencies: [ .package(url: "https://github.com/steplark/steplark-ios.git", from: "0.1.0") ], targets: [ .target(name: "YourApp", dependencies: [ .product(name: "Steplark", package: "steplark-ios") ]) ]
Quickstart
import SwiftUI import Steplark @main struct YourApp: App { init() { Steplark.configure( apiKey: "sl_...", placement: "onboarding", autoPresent: true, onOnboardingComplete: { // user finished the flow }, onPurchaseComplete: { productId, transactionId in // wire into your entitlement store } ) } var body: some Scene { WindowGroup { ContentView() } } }
That's the whole integration. On launch the SDK shows the flow on its own window above your app, runs the user through it, then slides away and fires onOnboardingComplete.
Quickstart with Claude Code
Using Claude Code, Cursor, Copilot, or another AI coding agent? Copy this prompt; it'll read the docs and wire Steplark into your app:
Steplark is an A/B testing platform for mobile onboarding flows and paywalls. Design and A/B test them in a Figma-like canvas with analytics shown right on the canvas, and ship changes without an App Store release. Read https://github.com/steplark/steplark-ios and follow the README to help me set it up, then ask me for my API key and placement when you need them.
Presentation modes
configure(autoPresent:) controls whether the SDK shows itself.
Auto (default) (autoPresent: true). The SDK auto-shows on launch, no host code beyond configure(), snapping the overlay into place before your first frame paints.
Manual (autoPresent: false). The SDK still pre-fetches and pre-warms at configure() time, but waits for you:
Steplark.configure(apiKey: "sl_...", placement: "paywall_main", autoPresent: false) // later, on a tap Button("Upgrade") { Steplark.present() }
Manual present slides up with UIKit's native modal animation and slides down on dismiss. A per-call completion overrides the configure-time closure for that call only:
Steplark.present(onOnboardingComplete: { /* ... */ })
Note: with autoPresent: true, call configure() early in your app's startup so the overlay can install before the first frame. If something else puts a window on screen first, the user may briefly glimpse host content before it's covered.
Purchase callbacks fire on every entitlement path
onPurchaseComplete fires on all three of these, so you wire entitlement gating in one place. Telemetry is split by event name so revenue analytics stay clean while engagement signals are still captured:
| Path | Telemetry | onPurchaseComplete |
|---|---|---|
| Fresh purchase: new verified transaction | purchase_start → purchase_complete (with price/currency/trial) | ✓ |
Already-owned: user re-taps a product they own; SDK checks Transaction.currentEntitlements first and skips Apple's "already subscribed" dialog | purchase_start → purchase_already_owned | ✓ |
| Restore: user taps "Restore Purchases" | restore_start → restore_complete (with count) | ✓ per entitlement |
Actions: call host code from a screen
Screens can trigger host work the SDK doesn't ship: permissions, opening URLs, recording a choice.
Screen JS:
Steplark.action("request_notification_permission")
Steplark.action("set_goal", "lose_weight")Host: handle via onAction: (or the delegate's steplarkDidReceiveAction(name:value:)):
Steplark.configure( apiKey: "sl_...", placement: "onboarding", onAction: { name, value in switch name { case "request_notification_permission": UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in } case "set_goal": UserDefaults.standard.set(value, forKey: "user_goal") default: break } } )
value is nil when the screen called Steplark.action(name) with no second argument.
Dismissing programmatically
Steplark.dismiss()
Host-side only, not exposed to screen JS. Most commonly called from inside onPurchaseComplete after flipping your entitlement flag. The overlay animates out using the same path as a natural completion. dismiss() does not fire onOnboardingComplete, the complete telemetry event, or steplarkDidCompleteOnboarding.
Resume
If the user kills the app mid-onboarding, the SDK resumes them on the screen they were on at next launch, with no host code needed. The saved position is wiped on completion, on reset(), and after seven days of inactivity. If the flow was edited and the saved screen no longer exists, the SDK clamps to the most recent visited screen still present; if none survive, it starts fresh.
Identifying users
Steplark.identify(userId: "user_123") // your stable user id Steplark.reset() // clear on sign-out
Anonymous events are joined into the identified user the moment identify is called. reset() wipes local user state and starts a fresh anonymous session.
Delegate (optional)
For observability beyond the configure() closures:
@MainActor final class MyObserver: SteplarkDelegate { func steplarkDidCompleteOnboarding(flowId: String, durationMs: Int) {} func steplarkDidCompletePurchase(productId: String, transactionId: String) {} func steplarkDidAbandonPurchase(productId: String) {} func steplarkDidReceiveAction(name: String, value: String?) {} func steplarkDidFail(error: SteplarkError) {} } Steplark.shared.delegate = MyObserver()
Every method has a default no-op, so implement only what you need. The delegate fires alongside the configure-time closures, not instead of them.
Errors
SteplarkError is what steplarkDidFail(error:) hands you:
| Case | Meaning |
|---|---|
apiKeyInvalid | API key rejected by the server |
noActiveCampaign(placement:) | No published campaign for this placement |
campaignHasNoVariants(campaignId:) | Campaign exists but has nothing to show |
temporarilyUnavailable | Server in maintenance or overloaded |
loadFailed(reason:) | Network or decode failure |
The user-visible recovery is a "Tap to retry" UI inside the same WebView; the SDK never auto-dismisses on error. The delegate fires for observability.
Full API reference
public static func configure( apiKey: String, placement: String, environment: SteplarkEnvironment = .production, autoPresent: Bool = true, onOnboardingComplete: (() -> Void)? = nil, onPurchaseComplete: ((_ productId: String, _ transactionId: String) -> Void)? = nil, onAction: ((_ name: String, _ value: String?) -> Void)? = nil ) public static func present() public static func present(onOnboardingComplete: @escaping () -> Void) public static func dismiss() public static func identify(userId: String) public static func reset() public weak var delegate: SteplarkDelegate?
Made by Steplark
One line of code. Live on launch.
Create your API key and ship your first flow today.