diff --git a/content/docs/images/kmp/spm-add-bridge-product.jpg b/content/docs/images/kmp/spm-add-bridge-product.jpg
new file mode 100644
index 00000000..b2eb187b
Binary files /dev/null and b/content/docs/images/kmp/spm-add-bridge-product.jpg differ
diff --git a/content/docs/kmp/changelog.mdx b/content/docs/kmp/changelog.mdx
new file mode 100644
index 00000000..346985d9
--- /dev/null
+++ b/content/docs/kmp/changelog.mdx
@@ -0,0 +1,32 @@
+---
+title: "Changelog"
+description: "Release notes for the Superwall KMP SDK"
+---
+
+# CHANGELOG
+
+The changelog for `Superwall-KMP`. Also see the [releases](https://github.com/superwall/Superwall-KMP/releases) on GitHub.
+
+## 0.1.1
+
+## Enhancements
+- Adds threading improvements to reduce main thread load
+
+## 0.1.0
+
+Initial release of the Kotlin Multiplatform SDK for Superwall.
+
+## Enhancements
+- Adds `com.superwall.sdk:superwall-kmp`, a Kotlin Multiplatform wrapper over the native Superwall SDKs. The entire public API lives in `commonMain` — no platform types leak into it, and `Superwall.configure` has an identical signature on both platforms (no `Context` parameter on Android).
+- Android support (`minSdk 26`) wrapping `com.superwall.sdk:superwall-android` 2.8.0. The dependency is bundled transitively and an `androidx.startup` initializer captures the `Application`, so integration is a single Gradle dependency.
+- iOS support (iOS 14+, `iosArm64`/`iosSimulatorArm64`/`iosX64`) forwarding through **SuperwallKMPBridge**, a self-authored `@objc` Swift facade over SuperwallKit iOS 4.16.1 (pinned exactly). The bridge destructures Swift-only constructs — enum associated values, structs, `async` — into ObjC-visible envelopes consumed via cinterop.
+- Adds `Superwall.register(placement:params:handler:feature:)` for gating features behind paywalls, with a `PaywallPresentationHandler` exposing `onPresent`, `onDismiss`, `onError` and `onSkip`.
+- Adds `Superwall.subscriptionStatusFlow`, a `StateFlow` that is collectable before `configure` (seeded with `SubscriptionStatus.Unknown`) and emits on the main thread.
+- Adds `PurchaseController` for apps that own their purchase logic, with separate `purchaseFromAppStore` and `purchaseFromGooglePlay` entry points.
+- Adds `SuperwallDelegate` covering the paywall presentation lifecycle, subscription-status changes, deep links, URLs, custom paywall actions, logging, and link redemption.
+- Adds `SuperwallOptions` (including `PaywallOptions` and `TestModeBehavior`), user identity (`identify`, `reset`, user attributes), and deep-link handling via `handleDeepLink`.
+- Adds `configureAndAwait`, a suspending twin of `configure`, plus the `Superwall.isConfigured` flag for ordering calls.
+
+## Notes
+- There is no pre-configure call queue: most members throw `SuperwallError.NotConfigured` before `configure`. Guard-exempt members are `handleDeepLink`, the flows, `delegate`, and the introspection properties.
+- iOS integration is two steps — the Gradle dependency plus the `SuperwallKMPBridge` Swift package — and the Kotlin framework must be exported as `isStatic = true`, because it does not embed the bridge binary. See the [README](README.md#ios) for details.
diff --git a/content/docs/kmp/guides/3rd-party-analytics.mdx b/content/docs/kmp/guides/3rd-party-analytics.mdx
new file mode 100644
index 00000000..1a571073
--- /dev/null
+++ b/content/docs/kmp/guides/3rd-party-analytics.mdx
@@ -0,0 +1,169 @@
+---
+title: "3rd Party Analytics"
+description: "Forward Superwall events to your own analytics stack."
+---
+
+
+
+**Beta**
+
+The KMP SDK is in beta and its API may change between releases.
+
+
+
+Superwall tracks events internally: paywalls opening, transactions completing, placements firing. You can forward all of them to your own analytics provider through `SuperwallDelegate`.
+
+## Forwarding events
+
+Implement `handleSuperwallEvent`:
+
+```kotlin
+import com.superwall.sdk.kmp.SuperwallDelegate
+import com.superwall.sdk.kmp.models.events.SuperwallEventInfo
+
+class AnalyticsDelegate : SuperwallDelegate {
+ override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
+ analytics.track(
+ name = eventInfo.eventType.name,
+ properties = eventInfo.params.orEmpty(),
+ )
+ }
+}
+
+Superwall.delegate = AnalyticsDelegate()
+```
+
+
+
+On Android, `handleSuperwallEvent` has **no guaranteed thread**. It adds no dispatcher hop, so it runs wherever the SDK tracked the event from. Usually that is a background thread. On iOS it is always main.
+
+Two consequences: do not touch UI from it without hopping to main yourself, and do not assume it is off the main thread either. Keep the body cheap and non-blocking. See [Platform differences](/kmp/guides/platform-differences#delegate-threading).
+
+
+
+## The event envelope
+
+`SuperwallEventInfo` is a flat envelope. `eventType` identifies the event, and only the fields relevant to that event are non-null:
+
+```kotlin
+import com.superwall.sdk.kmp.models.events.EventType
+
+override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
+ when (eventInfo.eventType) {
+ EventType.PAYWALL_OPEN -> {
+ analytics.track("paywall_open", mapOf(
+ "paywall_id" to eventInfo.paywallInfo?.identifier,
+ "paywall_name" to eventInfo.paywallInfo?.name,
+ ))
+ }
+ EventType.TRANSACTION_COMPLETE -> {
+ analytics.track("purchase", mapOf(
+ "product_id" to eventInfo.product?.productIdentifier,
+ ))
+ }
+ else -> analytics.track(eventInfo.eventType.name, eventInfo.params.orEmpty())
+ }
+}
+```
+
+Commonly useful fields on the envelope:
+
+?",
+ },
+ placementName: {
+ description: "The placement that produced the event, where applicable.",
+ type: "String?",
+ },
+ paywallInfo: {
+ description: "The paywall involved in the event.",
+ type: "PaywallInfo?",
+ },
+ transaction: {
+ description: "The store transaction involved in the event.",
+ type: "StoreTransaction?",
+ },
+ product: {
+ description: "The store product involved in the event.",
+ type: "StoreProduct?",
+ },
+ error: {
+ description: "A description of the error, for failure events.",
+ type: "String?",
+ },
+ }}
+/>
+
+## Sending your identifiers to Superwall
+
+The reverse direction matters too, since Superwall can attribute better if it knows your analytics identifiers:
+
+```kotlin
+import com.superwall.sdk.kmp.models.events.IntegrationAttribute
+
+Superwall.setIntegrationAttributes(
+ mapOf(
+ IntegrationAttribute.AMPLITUDE_USER_ID to amplitude.userId,
+ IntegrationAttribute.MIXPANEL_DISTINCT_ID to mixpanel.distinctId,
+ IntegrationAttribute.APPSFLYER_ID to appsFlyer.uid,
+ ),
+)
+```
+
+Supported providers include Adjust, Amplitude, AppsFlyer, Braze, OneSignal, Meta, Firebase, Singular, Iterable, Mixpanel, mParticle, CleverTap, Airship, Kochava, Tenjin, PostHog, Customer.io, and Appstack. Passing `null` for a value removes it.
+
+
+ `IntegrationAttribute.FIREBASE_INSTALLATION_ID` is **iOS only**. Setting it on Android is skipped
+ and logs a warning. Every other attribute works on both platforms.
+
+
+## Capturing SDK logs
+
+`handleLog` gives you the SDK's own log stream:
+
+```kotlin
+override fun handleLog(
+ level: LogLevel,
+ scope: LogScope,
+ message: String?,
+ info: Map?,
+ error: String?,
+) {
+ if (level == LogLevel.ERROR) {
+ crashReporter.log("Superwall/${scope.name}: $message")
+ }
+}
+```
+
+
+ `handleLog` fires for **every** internal log line, regardless of the configured log level, which is
+ hundreds of calls for a single `register`. Filter early, keep the body cheap, and never block in
+ it.
+
+
+## Controlling what Superwall collects
+
+To limit what leaves the device, set `eventTrackingBehavior`:
+
+```kotlin
+Superwall.configure(
+ apiKey = "pk_your_api_key",
+ options = SuperwallOptions(
+ eventTrackingBehavior = EventTrackingBehavior.SUPERWALL_ONLY,
+ ),
+)
+```
+
+| Value | Effect |
+| --- | --- |
+| `ALL` | Everything is tracked. The default. |
+| `SUPERWALL_ONLY` | Only internal Superwall events; your tracking calls, trigger-fire events, and user-attribute updates are suppressed. |
+| `NONE` | Nothing is sent to Superwall's servers. |
diff --git a/content/docs/kmp/guides/advanced-configuration.mdx b/content/docs/kmp/guides/advanced-configuration.mdx
new file mode 100644
index 00000000..e7aca9e4
--- /dev/null
+++ b/content/docs/kmp/guides/advanced-configuration.mdx
@@ -0,0 +1,172 @@
+---
+title: "Purchases and Subscription Status"
+description: "Own your purchase logic with a PurchaseController."
+---
+
+
+
+**Beta**
+
+The KMP SDK is in beta and its API may change between releases.
+
+
+
+By default Superwall handles purchases and subscription status for you, and most apps should leave it that way. If you already have purchase logic (your own billing stack, or a provider like RevenueCat), you can take it over with a `PurchaseController`.
+
+
+ Passing a `PurchaseController` means **you** own subscription status. Superwall will not set it for
+ you, so you must set `Superwall.subscriptionStatus` yourself after every purchase, restore, and app
+ launch.
+
+
+## The interface
+
+One interface covers both stores. Each platform invokes only its own store's method, so you implement all three and only two ever run on a given device.
+
+```kotlin
+import com.superwall.sdk.kmp.PurchaseController
+import com.superwall.sdk.kmp.models.results.PurchaseResult
+import com.superwall.sdk.kmp.models.results.RestorationResult
+
+class MyPurchaseController : PurchaseController {
+
+ override suspend fun purchaseFromAppStore(productId: String): PurchaseResult {
+ // Your StoreKit logic
+ return PurchaseResult.Purchased
+ }
+
+ override suspend fun purchaseFromGooglePlay(
+ productId: String,
+ basePlanId: String?,
+ offerId: String?,
+ ): PurchaseResult {
+ // Your Play Billing logic
+ return PurchaseResult.Purchased
+ }
+
+ override suspend fun restorePurchases(): RestorationResult {
+ // Your restore logic
+ return RestorationResult.Restored
+ }
+}
+```
+
+All three are `suspend` functions, so you can do the real asynchronous work inline without callbacks.
+
+
+ `purchaseFromGooglePlay` takes `basePlanId` and `offerId` alongside the product id, because Play
+ models subscriptions with base plans and offers. iOS has no equivalent, which is why the two entry
+ points are separate rather than one method with platform-shaped arguments.
+
+
+## Handling every case
+
+`PurchaseResult` is a sealed interface, so handle all four:
+
+| Result | When |
+| --- | --- |
+| `Purchased` | The product was purchased |
+| `Cancelled` | The user cancelled. StoreKit 2's `.userCancelled`, or RevenueCat's `userCancelled == true` |
+| `Pending` | Awaiting action. StoreKit 1's `.deferred`, or RevenueCat's `paymentPendingError` |
+| `Failed(error)` | Anything else |
+
+```kotlin
+override suspend fun purchaseFromAppStore(productId: String): PurchaseResult {
+ return try {
+ when (val outcome = myStore.purchase(productId)) {
+ is Success -> PurchaseResult.Purchased
+ is UserCancelled -> PurchaseResult.Cancelled
+ is Deferred -> PurchaseResult.Pending
+ is Error -> PurchaseResult.Failed(outcome.message)
+ }
+ } catch (e: Exception) {
+ PurchaseResult.Failed(e.message ?: "Unknown error")
+ }
+}
+```
+
+There are convenience factories if you prefer them: `PurchaseResult.purchased()`, `.cancelled()`, `.pending()`, `.failed(error)`.
+
+`RestorationResult` has two cases, `Restored` and `Failed(error)`.
+
+
+ `RestorationResult.Restored` means the restore completed **without errors**, not that the user has
+ an active subscription. Set subscription status from the entitlements you actually resolved, not
+ from the fact that restore succeeded.
+
+
+## Wire it up
+
+Pass the controller at configure time:
+
+```kotlin
+Superwall.configure(
+ apiKey = "pk_your_api_key",
+ purchaseController = MyPurchaseController(),
+)
+```
+
+
+ A second `configure` call will **not** install a different purchase controller, because repeat calls are
+ a no-op. Set it on the first call.
+
+
+## Keep subscription status current
+
+This is the part that is easy to forget. After any purchase, restore, or launch-time entitlement check, tell Superwall what you found:
+
+```kotlin
+import com.superwall.sdk.kmp.models.entitlements.Entitlement
+import com.superwall.sdk.kmp.models.entitlements.SubscriptionStatus
+
+fun syncSubscriptionStatus(activeEntitlementIds: Set) {
+ Superwall.subscriptionStatus = if (activeEntitlementIds.isEmpty()) {
+ SubscriptionStatus.Inactive
+ } else {
+ SubscriptionStatus.Active(
+ activeEntitlementIds.map { Entitlement(id = it) }.toSet(),
+ )
+ }
+}
+```
+
+`Entitlement` requires only an `id`; the remaining fields have defaults.
+
+
+ Do not leave the status at `SubscriptionStatus.Unknown` once you know the answer. Gated paywalls
+ and your own UI both branch on it.
+
+
+## Consumables on Android
+
+Play Billing requires consuming a purchase before the same product can be bought again:
+
+```kotlin
+val token = Superwall.consume(purchaseToken)
+```
+
+This is an **Android** operation. On iOS it echoes the token back unchanged, so it is safe to call from shared code without a platform check.
+
+## Restoring
+
+`Superwall.restorePurchases()` routes through your controller's `restorePurchases()` when one is configured, and through the native SDK otherwise:
+
+```kotlin
+when (val result = Superwall.restorePurchases()) {
+ is RestorationResult.Restored -> println("Restored")
+ is RestorationResult.Failed -> println("Failed: ${result.error}")
+}
+```
+
+Failure stays in the return type and does not throw.
+
+## Observing purchases you did not make
+
+If you want Superwall to see transactions that happen outside of a paywall without taking over purchasing entirely, skip the controller and set an option instead:
+
+```kotlin
+Superwall.configure(
+ apiKey = "pk_your_api_key",
+ options = SuperwallOptions(shouldObservePurchases = true),
+)
+```
diff --git a/content/docs/kmp/guides/handling-deep-links.mdx b/content/docs/kmp/guides/handling-deep-links.mdx
new file mode 100644
index 00000000..2f9443c1
--- /dev/null
+++ b/content/docs/kmp/guides/handling-deep-links.mdx
@@ -0,0 +1,137 @@
+---
+title: "Handling Deep Links"
+description: "Handle Superwall deep links for paywall previews and web checkout redemption."
+---
+
+
+
+**Beta**
+
+The KMP SDK is in beta and its API may change between releases.
+
+
+
+Superwall uses deep links for two things: previewing paywalls on a real device, and redeeming web checkout codes. Both flow through one call.
+
+## Pass the URL to Superwall
+
+```kotlin
+val handled = Superwall.handleDeepLink(url)
+```
+
+It returns whether the SDK recognized and handled the link, so you can fall through to your own routing when it did not:
+
+```kotlin
+fun onDeepLink(url: String) {
+ if (Superwall.handleDeepLink(url)) return
+ myRouter.navigate(url)
+}
+```
+
+
+
+`handleDeepLink` is **guard-exempt**, so you can call it before `Superwall.configure` without hitting `SuperwallError.NotConfigured`. That is deliberate: cold-starting from a deep link is its primary use, and it would be useless if you had to sequence it behind configuration.
+
+It takes a `String`, not a platform URL type, so it is callable from `commonMain`.
+
+
+
+## Wiring it up per platform
+
+The SDK takes a plain `String`, so the only platform-specific part is getting the URL from the OS to your shared code.
+
+**Android**, from the activity that receives the intent:
+
+```kotlin
+// androidMain
+override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ intent?.data?.let { Superwall.handleDeepLink(it.toString()) }
+}
+
+override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ intent.data?.let { Superwall.handleDeepLink(it.toString()) }
+}
+```
+
+Declare your intent filter in `AndroidManifest.xml` as you would for any deep link.
+
+**iOS**, from your `App` or `AppDelegate`:
+
+```swift
+// SwiftUI
+.onOpenURL { url in
+ Superwall.shared.handleDeepLink(url: url.absoluteString)
+}
+```
+
+
+ Register your URL scheme with the OS as usual: an `intent-filter` on Android, a URL type in your
+ Xcode target on iOS. Superwall does not do that part for you.
+
+
+## Web checkout redemption
+
+When a user buys on the web and returns to your app, the redemption arrives as a deep link. Observe the outcome through the delegate:
+
+```kotlin
+import com.superwall.sdk.kmp.models.redemption.RedemptionResult
+
+class MyDelegate : SuperwallDelegate {
+ override fun willRedeemLink() {
+ showSpinner()
+ }
+
+ override fun didRedeemLink(result: RedemptionResult) {
+ hideSpinner()
+ when (result) {
+ is RedemptionResult.Success -> unlock(result.redemptionInfo)
+ is RedemptionResult.Error -> showError(result.error)
+ is RedemptionResult.ExpiredCode -> showExpired(result.info)
+ is RedemptionResult.InvalidCode -> showInvalid()
+ is RedemptionResult.ExpiredSubscription -> showExpiredSubscription()
+ }
+ }
+}
+```
+
+Every case carries the `code` that was redeemed.
+
+
+
+`willRedeemLink` and `didRedeemLink` are analytics hooks: they arrive on a **background thread on Android**. The spinner calls above need a main-thread hop on Android. See [Platform differences](/kmp/guides/platform-differences#delegate-threading).
+
+
+
+## Superwall app links
+
+`SuperwallDelegate.handleSuperwallDeepLink` reports links of the form `yoursubdomain.superwall.app/app-link/...`, broken into path components and query parameters:
+
+```kotlin
+override fun handleSuperwallDeepLink(
+ fullURL: String,
+ pathComponents: List,
+ queryParameters: Map,
+) {
+ // Route based on pathComponents
+}
+```
+
+
+
+**This hook is iOS only.** `superwall-android` has no equivalent delegate method, so it is never invoked on Android.
+
+`Superwall.handleDeepLink(url)` itself works on **both** platforms. Only this structured callback is missing. On Android, parse the URL yourself in the activity that receives it.
+
+
+
+## Paywall previews
+
+Deep links are also how you preview a paywall on a real device from the dashboard. Point the link at your app and pass it to `handleDeepLink`.
+
+
+
+On **Android**, previews need the SDK's debug activities declared in your own manifest. The KMP library declares the paywall activity but not the debug ones. See [Platform differences](/kmp/guides/platform-differences#in-app-paywall-previews-on-android) for the snippet. This path is not yet verified end-to-end on KMP.
+
+
diff --git a/content/docs/kmp/guides/platform-differences.mdx b/content/docs/kmp/guides/platform-differences.mdx
new file mode 100644
index 00000000..5ee9c490
--- /dev/null
+++ b/content/docs/kmp/guides/platform-differences.mdx
@@ -0,0 +1,155 @@
+---
+title: "Platform Differences"
+description: "Where Android and iOS behavior is not one-to-one in the KMP SDK."
+---
+
+
+
+**Beta**
+
+The KMP SDK is in beta and its API may change between releases.
+
+
+
+The KMP SDK's public API is identical on both platforms: one signature, no platform types, no `expect`/`actual` of your own. But it wraps two different native SDKs, and in a handful of places they do not offer the same thing.
+
+This page is the complete list. Everything not mentioned here behaves the same on Android and iOS.
+
+## APIs that differ
+
+| API | Behavior |
+| --- | --- |
+| `SuperwallDelegate.handleSuperwallDeepLink` | **iOS only.** `superwall-android` has no equivalent delegate hook, so this is never invoked on Android. |
+| `Superwall.consume(purchaseToken)` | **Android.** Consumes a Play Billing purchase so it can be bought again. On iOS it echoes the token back unchanged. |
+| `IntegrationAttribute.FIREBASE_INSTALLATION_ID` | **iOS only.** `superwall-android` has no counterpart; setting it on Android is skipped and logs a warning. Every other `IntegrationAttribute` works on both. |
+
+That is the whole list of behavioral gaps. Notably, customer info is **not** on it. See [below](#what-is-not-a-difference).
+
+## Options that only apply to one platform
+
+Setting one of these on the other platform is harmless. It is ignored.
+
+**Android only**
+
+| Option | What it does |
+| --- | --- |
+| `SuperwallOptions.passIdentifiersToPlayStore` | Sends the raw `appUserId` to Play instead of a SHA-256 hash |
+| `SuperwallOptions.useMockReviews` | Enables mock review functionality |
+| `PaywallOptions.preloadDeviceOverrides` | Per-device-tier overrides for `shouldPreload` |
+| `PaywallOptions.onBackPressed` | Callback for the hardware back button while a paywall shows |
+
+**iOS only**
+
+| Option | What it does |
+| --- | --- |
+| `SuperwallOptions.shouldBypassAppTransactionCheck` | Skips the app transaction check on launch |
+| `SuperwallOptions.maxConfigRetryCount` | Retry attempts for fetching configuration (default `6`) |
+| `PaywallOptions.shouldShowWebRestorationAlert` | Offers web restoration after a failed restore |
+| `PaywallOptions.shouldShowWebPurchaseConfirmationAlert` | Confirms a successful web checkout purchase |
+
+
+ `PaywallOptions.transactionBackgroundView` works on **both** platforms, despite its KDoc saying
+ "iOS only". `superwall-android` has the same option, and the KMP mapper wires it. `SPINNER` maps
+ to the native spinner and `NONE` maps to the native `null` ("show nothing").
+
+
+## Delegate threading
+
+This is the difference most likely to cause problems, because it is a runtime behavior rather than a missing method.
+
+`SuperwallDelegate` callbacks are **not** forced onto the main thread. They arrive on whatever thread the native SDK called from, which splits cleanly:
+
+| Hooks | Android | iOS |
+| --- | --- | --- |
+| `willPresentPaywall`, `didPresentPaywall`, `willDismissPaywall`, `didDismissPaywall`, `handleCustomPaywallAction`, `paywallWillOpenURL`, `paywallWillOpenDeepLink` | Main | Main |
+| `subscriptionStatusDidChange`, `customerInfoDidChange`, `userAttributesDidChange`, `willRedeemLink`, `didRedeemLink` | Background | Main |
+| `handleSuperwallEvent`, `handleLog` | **Not guaranteed** | Main |
+
+So the paywall lifecycle hooks are safe for UI work everywhere. The rest are not, on Android.
+
+
+
+`handleSuperwallEvent` and `handleLog` have their own row because they are the least predictable. Neither adds a dispatcher hop on Android, so they run on whatever thread the SDK called from. `handleLog` is invoked inline wherever a log statement executes, which includes the main thread. `handleSuperwallEvent` inherits the context of the code that tracked the event. Usually that is a background thread, but do not rely on it in either direction: do not assume it is safe for UI, and do not assume it is off the main thread.
+
+
+
+```kotlin
+override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {
+ // Fine: forwarding to an analytics SDK.
+ analytics.track(eventInfo.eventType.name)
+
+ // NOT fine on Android: this is a background thread.
+ // updateMyUi()
+}
+```
+
+If you need UI from one of those, hop yourself, or collect a flow instead:
+
+```kotlin
+scope.launch(Dispatchers.Main) { updateMyUi() }
+```
+
+
+ Collecting `Superwall.subscriptionStatusFlow` is usually the easier path when you want subscription
+ changes to drive UI, but it is a plain `StateFlow`, so it delivers on *your* collector's context,
+ not on main. Collect it from a main-dispatched scope (`collectAsState`, `viewModelScope`,
+ `lifecycleScope`) and you are safe; collect it on `Dispatchers.IO` and you are not.
+
+
+Two more things to plan for: delegate implementations should be **thread-safe** (the analytics hooks are not serialized against each other), and they run **synchronously on an SDK thread**, so blocking in one slows the SDK. Keep them short.
+
+
+ `PaywallPresentationHandler` closures and the `register` `feature` closure are a different story:
+ those *are* delivered on the main thread on both platforms, deliberately, because they gate UI.
+
+
+## Install differences
+
+The two platforms do not take the same amount of setup. See [Install the SDK](/kmp/quickstart/install) for the detail.
+
+| | Android | iOS |
+| --- | --- | --- |
+| Steps | One Gradle dependency | Gradle dependency **plus** the `SuperwallKMPBridge` Swift package |
+| Manifest / project edits | None. The library manifest declares the paywall activity and the startup initializer | Kotlin framework must be exported with `isStatic = true` |
+| Native SDK | `superwall-android` 2.8.0, transitively | SuperwallKit 4.16.1, pinned exactly by the bridge |
+| Minimum | `minSdk` 26 | iOS 14 |
+
+## In-app paywall previews on Android
+
+
+
+The KMP library manifest declares `SuperwallPaywallActivity`, which is what paywall presentation needs. It does **not** declare the debug activities that the standalone Android SDK's [in-app paywall previews](/android/quickstart/in-app-paywall-previews) rely on, and neither does `superwall-android`.
+
+If you need previews on Android, declare them in your own `AndroidManifest.xml`:
+
+```xml
+
+
+
+```
+
+This path is not yet verified end-to-end on KMP. If you try it, we would like to hear how it goes, so please [open an issue](https://github.com/superwall/Superwall-KMP/issues).
+
+
+
+## Getting the right API key
+
+Your Android app and your iOS app are separate apps in the Superwall dashboard, and each one has its own Public API Key. In a KMP project, though, `Superwall.configure` is usually called once, from shared code. That single call site needs to end up with the Android key when the app runs on Android and the iOS key when it runs on iOS.
+
+One way to do that is Kotlin's `expect`/`actual`:
+
+```kotlin
+// commonMain
+expect val superwallApiKey: String
+
+// androidMain
+actual val superwallApiKey: String = "pk_your_android_key"
+
+// iosMain
+actual val superwallApiKey: String = "pk_your_ios_key"
+
+// commonMain: one call site, correct key on each platform
+Superwall.configure(apiKey = superwallApiKey)
+```
+
+This is the same pattern the [sample app](https://github.com/superwall/Superwall-KMP/blob/main/sample/shared/src/commonMain/kotlin/com/superwall/sdk/kmp/sample/ApiKey.kt) uses. Passing the key in from each platform's entry point works just as well; use whatever your project already does for per-platform values.
diff --git a/content/docs/kmp/guides/using-superwall-delegate.mdx b/content/docs/kmp/guides/using-superwall-delegate.mdx
new file mode 100644
index 00000000..37171f26
--- /dev/null
+++ b/content/docs/kmp/guides/using-superwall-delegate.mdx
@@ -0,0 +1,139 @@
+---
+title: "Using the Superwall Delegate"
+description: "Observe the paywall lifecycle and SDK events from shared Kotlin code."
+---
+
+
+
+**Beta**
+
+The KMP SDK is in beta and its API may change between releases.
+
+
+
+`SuperwallDelegate` is how you observe what the SDK is doing: paywalls opening and closing, subscription status changing, events being tracked, links being redeemed.
+
+Every method has a default no-op implementation, so override only the ones you need.
+
+## Setting the delegate
+
+```kotlin
+import com.superwall.sdk.kmp.Superwall
+import com.superwall.sdk.kmp.SuperwallDelegate
+import com.superwall.sdk.kmp.models.paywall.PaywallInfo
+
+class MyDelegate : SuperwallDelegate {
+ override fun didPresentPaywall(paywallInfo: PaywallInfo) {
+ println("Presented ${paywallInfo.name}")
+ }
+
+ override fun didDismissPaywall(paywallInfo: PaywallInfo) {
+ println("Dismissed ${paywallInfo.name}")
+ }
+}
+
+Superwall.delegate = MyDelegate()
+```
+
+
+ `delegate` is one of the few members you can set **before** `configure`. The value is stored
+ immediately and installed into the native SDK when configuration happens, so you will not miss
+ early events. Setting it to `null` clears it.
+
+
+## What you can observe
+
+**Paywall lifecycle**
+
+```kotlin
+override fun willPresentPaywall(paywallInfo: PaywallInfo) {}
+override fun didPresentPaywall(paywallInfo: PaywallInfo) {}
+override fun willDismissPaywall(paywallInfo: PaywallInfo) {}
+override fun didDismissPaywall(paywallInfo: PaywallInfo) {}
+```
+
+**Paywall interactions**
+
+```kotlin
+override fun handleCustomPaywallAction(name: String) {}
+override fun paywallWillOpenURL(url: String) {}
+override fun paywallWillOpenDeepLink(url: String) {}
+```
+
+**State changes**
+
+```kotlin
+override fun subscriptionStatusDidChange(from: SubscriptionStatus, to: SubscriptionStatus) {}
+override fun customerInfoDidChange(from: CustomerInfo, to: CustomerInfo) {}
+override fun userAttributesDidChange(newAttributes: Map) {}
+```
+
+**Analytics and logging**
+
+```kotlin
+override fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {}
+
+override fun handleLog(
+ level: LogLevel,
+ scope: LogScope,
+ message: String?,
+ info: Map?,
+ error: String?,
+) {}
+```
+
+**Web checkout redemption**
+
+```kotlin
+override fun willRedeemLink() {}
+override fun didRedeemLink(result: RedemptionResult) {}
+```
+
+## Threading
+
+
+
+Delegate callbacks are **not** forced onto the main thread. They arrive on whatever thread the native SDK called from, and that is not the same on both platforms.
+
+- **Paywall lifecycle hooks** arrive on the **main thread** on Android and iOS. Update UI from these directly.
+- **State-change hooks** (`subscriptionStatusDidChange`, `customerInfoDidChange`, `userAttributesDidChange`, `willRedeemLink`, `didRedeemLink`) arrive on a **background thread on Android**, and on the main thread on iOS.
+- **`handleSuperwallEvent` and `handleLog`** are **not guaranteed** either way on Android. Neither adds a dispatcher hop, so they run on whatever thread the SDK called from. `handleLog` is invoked inline wherever a log statement executes, which includes the main thread. On iOS both are on main.
+
+
+
+Two things this asks of your implementation:
+
+1. **Be thread-safe.** The analytics hooks are not serialized against each other.
+2. **Be quick.** They run synchronously on an SDK thread, so blocking in one slows the SDK.
+
+If you need UI work from an analytics hook, hop yourself:
+
+```kotlin
+override fun subscriptionStatusDidChange(from: SubscriptionStatus, to: SubscriptionStatus) {
+ scope.launch(Dispatchers.Main) {
+ updateUi(to)
+ }
+}
+```
+
+Or skip the delegate for that case entirely and collect [`subscriptionStatusFlow`](/kmp/quickstart/tracking-subscription-state) from a main-dispatched scope, which keeps the threading question in one place.
+
+## Platform gap
+
+`handleSuperwallDeepLink` is **iOS only**. `superwall-android` has no equivalent delegate hook, so it is never invoked on Android. See [Platform differences](/kmp/guides/platform-differences).
+
+```kotlin
+// iOS only
+override fun handleSuperwallDeepLink(
+ fullURL: String,
+ pathComponents: List,
+ queryParameters: Map,
+) {}
+```
+
+## The delegate and the flows
+
+Setting or clearing the delegate never uninstalls the SDK's own internal delegate, so `subscriptionStatusFlow` and `customerInfoFlow` keep working whether or not you have one set. Use whichever fits:
+
+- **Delegate** when you want the full event firehose, or the paywall lifecycle.
+- **Flows** when you want subscription state to drive UI, and you would rather not think about threads.
diff --git a/content/docs/kmp/index.mdx b/content/docs/kmp/index.mdx
new file mode 100644
index 00000000..70dd0edc
--- /dev/null
+++ b/content/docs/kmp/index.mdx
@@ -0,0 +1,82 @@
+---
+title: "Welcome"
+description: "Welcome to the Superwall KMP SDK documentation"
+---
+
+
+
+**Beta**
+
+The KMP SDK is in beta. The API may change between releases, and these docs describe behavior that is still settling. If you hit something that does not match what you see, please [open an issue](https://github.com/superwall/Superwall-KMP/issues).
+
+
+
+The Superwall KMP SDK brings paywalls, placements, and entitlements to Kotlin Multiplatform. It wraps the native Superwall SDKs (`superwall-android` on Android, SuperwallKit on iOS) behind a single API that lives entirely in `commonMain`.
+
+This means there's no `expect`/`actual` of your own, no platform types in your shared code, and an identical `configure` signature on both platforms. There is no `Context` parameter on Android.
+
+```kotlin
+import com.superwall.sdk.kmp.Superwall
+
+Superwall.configure(apiKey = "pk_your_api_key")
+
+Superwall.register(placement = "campaign_trigger") {
+ launchTheFeature()
+}
+```
+
+## Platform support
+
+| Platform | Support | Wraps |
+| --- | --- | --- |
+| Android | `minSdk 26` | `com.superwall.sdk:superwall-android` 2.8.0 |
+| iOS | iOS 14+ (`iosArm64`, `iosSimulatorArm64`, `iosX64`) | SuperwallKit iOS 4.16.1, pinned exactly |
+
+Those are the only two targets. There are no JVM, JS, desktop, or watchOS artifacts since the SDK is a wrapper over two native SDKs.
+
+
+ Android's `minSdk` here is **26**, higher than the standalone Android SDK's 23. If you are adding
+ KMP to an existing project, check your `minSdk` before you start.
+
+
+## How it fits together
+
+Your shared Kotlin code calls one API. Underneath, each platform resolves to its own native SDK:
+
+- **Android** wraps `superwall-android` directly. One Gradle dependency pulls it in, and an `androidx.startup` initializer captures the `Application` for you.
+- **iOS** forwards through **SuperwallKMPBridge**, an `@objc` Swift bridging layer over SuperwallKit that flattens Swift-only constructs (enum associated values, structs, `async`) into something Kotlin can consume via cinterop. Your app supplies that bridge as a Swift package.
+
+## Quick Links
+
+
+
+ Install, configure, and present your first paywall
+
+
+ Where Android and iOS behavior is not one-to-one
+
+
+ Own your purchase logic with a `PurchaseController`
+
+
+ Observe the paywall lifecycle and SDK events
+
+
+ A runnable Compose Multiplatform sample for Android and iOS
+
+
+ Release notes for the KMP SDK
+
+
+
+## Feedback
+
+The KMP SDK is actively developed and we want to hear what is missing.
+
+If you have feedback on these docs, please leave a rating and message at the bottom of the page. For SDK bugs, [open an issue on GitHub](https://github.com/superwall/Superwall-KMP/issues).
+
+
diff --git a/content/docs/kmp/meta.json b/content/docs/kmp/meta.json
new file mode 100644
index 00000000..afa95270
--- /dev/null
+++ b/content/docs/kmp/meta.json
@@ -0,0 +1,25 @@
+{
+ "title": "KMP SDK",
+ "icon": "Layers",
+ "root": true,
+ "pages": [
+ "index",
+ "changelog",
+
+ "---Quickstart---",
+ "quickstart/install",
+ "quickstart/configure",
+ "quickstart/present-first-paywall",
+ "quickstart/user-management",
+ "quickstart/feature-gating",
+ "quickstart/tracking-subscription-state",
+
+ "---Common Use Cases---",
+ "guides/platform-differences",
+ "guides/advanced-configuration",
+ "guides/using-superwall-delegate",
+ "guides/3rd-party-analytics",
+ "guides/handling-deep-links",
+ "[Example App](https://github.com/superwall/Superwall-KMP/tree/main/sample)"
+ ]
+}
diff --git a/content/docs/kmp/quickstart/configure.mdx b/content/docs/kmp/quickstart/configure.mdx
new file mode 100644
index 00000000..50020df4
--- /dev/null
+++ b/content/docs/kmp/quickstart/configure.mdx
@@ -0,0 +1,191 @@
+---
+title: "Configure the SDK"
+description: Configure Superwall in your shared Kotlin code.
+---
+
+
+
+**Beta**
+
+The KMP SDK is in beta and its API may change between releases.
+
+
+
+## Configure
+
+Call `Superwall.configure` as early as possible in your app's lifecycle, from shared code:
+
+```kotlin
+import com.superwall.sdk.kmp.Superwall
+
+Superwall.configure(apiKey = "pk_your_api_key") { result ->
+ result.onFailure { println("Superwall configuration failed: $it") }
+}
+```
+
+The signature is identical on both platforms:
+
+) -> Unit)?",
+ default: "null",
+ },
+ }}
+/>
+
+The call is fire-and-forget. `completion` reports the real outcome. An invalid API key surfaces there, not as a thrown exception.
+
+
+ Calling `configure` a second time is a no-op. The repeat call logs a warning through the delegate's
+ `handleLog`, does not re-install your options or purchase controller, and invokes its completion
+ with the first call's outcome.
+
+
+## There is no pre-configure call queue
+
+This is important to understand before you write anything else.
+
+
+
+Calls made before `configure` are **not** buffered and replayed. Almost every member throws `SuperwallError.NotConfigured` instead.
+
+The deliberate exemptions, which are safe to touch at any time:
+
+- `handleDeepLink`: deep-link cold start is its whole purpose
+- `subscriptionStatusFlow` and `customerInfoFlow`: pre-seeded, common-owned flows
+- `delegate`: stored immediately, installed natively at configure
+- `isConfigured`, `isInitialized`, and `configurationStatus`
+
+
+
+Everything else (`register`, `identify`, `setUserAttributes`, `entitlements`, and the rest) needs configuration to have happened first.
+
+## Ordering your calls
+
+Two supported ways to sequence work behind configuration.
+
+**Await it.** `configureAndAwait` is the suspending twin, and the sanctioned ordering tool. It resumes when the native SDK reports completion and throws `SuperwallError.ConfigurationFailed` on failure:
+
+```kotlin
+suspend fun startSuperwall() {
+ Superwall.configureAndAwait(apiKey = "pk_your_api_key")
+
+ // Safe from here on.
+ Superwall.identify(userId = "abc123")
+}
+```
+
+**Gate on the flag.** `Superwall.isConfigured` is readable at any time:
+
+```kotlin
+if (Superwall.isConfigured) {
+ Superwall.register(placement = "campaign_trigger")
+}
+```
+
+`Superwall.configurationStatus` gives you the fuller picture: `PENDING`, `CONFIGURED`, or `FAILED`.
+
+## Options
+
+Pass `SuperwallOptions` to customize behavior. Every field has a default, so set only what you need:
+
+```kotlin
+import com.superwall.sdk.kmp.models.options.PaywallOptions
+import com.superwall.sdk.kmp.models.options.SuperwallOptions
+
+Superwall.configure(
+ apiKey = "pk_your_api_key",
+ options = SuperwallOptions(
+ paywalls = PaywallOptions(
+ shouldPreload = false,
+ isHapticFeedbackEnabled = false,
+ ),
+ ),
+)
+```
+
+Frequently used `SuperwallOptions` fields:
+
+
+
+Some options only apply to one platform. `passIdentifiersToPlayStore` and `useMockReviews` are Android-only; `shouldBypassAppTransactionCheck` and `maxConfigRetryCount` are iOS-only. Setting one on the other platform is harmless. It is ignored. See [Platform differences](/kmp/guides/platform-differences).
+
+
+ Leave `networkEnvironment` alone unless the Superwall team has explicitly told you otherwise.
+
+
+## Logging
+
+Set the log level at configure time, or change it later:
+
+```kotlin
+import com.superwall.sdk.kmp.models.options.LogLevel
+
+Superwall.logLevel = LogLevel.WARN
+```
+
+Levels are `DEBUG`, `INFO`, `WARN`, `ERROR`, and `NONE`.
+
+
+ On iOS, `LogLevel.NONE` maps to Swift's `.none`. If you are reading native logs or Swift docs
+ alongside these, do not mistake it for an absent optional.
+
+
+Next, [present your first paywall](/kmp/quickstart/present-first-paywall).
diff --git a/content/docs/kmp/quickstart/feature-gating.mdx b/content/docs/kmp/quickstart/feature-gating.mdx
new file mode 100644
index 00000000..9ab04785
--- /dev/null
+++ b/content/docs/kmp/quickstart/feature-gating.mdx
@@ -0,0 +1,94 @@
+---
+title: "Feature Gating"
+description: "Control access to premium features with Superwall placements."
+---
+
+
+
+**Beta**
+
+The KMP SDK is in beta and its API may change between releases.
+
+
+
+## The idea
+
+`Superwall.register` lets you register a [placement](/dashboard/dashboard-campaigns/campaigns-placements) to access a feature that may or may not be paywalled later in time. Whether the user can access that feature without paying is a dashboard decision, not a code decision.
+
+```kotlin
+fun pressedWorkoutButton() {
+ // Remotely decide if a paywall is shown, and whether
+ // startWorkout() is a paid-only feature.
+ Superwall.register(placement = "StartWorkout") {
+ navigation.startWorkout()
+ }
+}
+```
+
+Given how cheap `register` is, we strongly recommend registering **all core functionality**. That is what lets you change what is gated without shipping an app update.
+
+## What happens when you register
+
+When you register a placement:
+
+1. The SDK checks your campaigns for a matching audience filter.
+2. If one matches and the user is not in a holdout, the assigned paywall is presented.
+3. Once a user is assigned a paywall for an audience, they keep seeing that paywall until you remove it from the audience or reset assignments.
+4. After the paywall closes, the SDK looks at the paywall's **Feature Gating** value, set in the paywall editor under **General → Feature Gating**:
+ - **Non Gated**: the `feature` closure runs when the paywall is dismissed, whether they paid or not.
+ - **Gated**: the `feature` closure runs only if the user is already paying, or begins paying.
+5. If no paywall is configured for the placement, the feature runs immediately with no extra network calls.
+
+## Gating with entitlements directly
+
+Sometimes you need to branch on subscription state rather than gate a call. Read it synchronously:
+
+```kotlin
+import com.superwall.sdk.kmp.models.entitlements.SubscriptionStatus
+
+if (Superwall.subscriptionStatus.isActive) {
+ showProContent()
+} else {
+ showFreeContent()
+}
+```
+
+Or collect the flow to keep UI in sync. See [Tracking subscription state](/kmp/quickstart/tracking-subscription-state).
+
+
+ Prefer `register` with a `feature` closure over hand-rolled `if` checks where you can. The closure
+ keeps the decision on the dashboard; an `if` statement hard-codes it into the build.
+
+
+## Inspecting entitlements
+
+`Superwall.entitlements` is an immutable snapshot:
+
+```kotlin
+val entitlements = Superwall.entitlements
+
+entitlements.active // Set
+entitlements.inactive // Set
+entitlements.all // Set
+entitlements.web // Set, granted via web checkout
+```
+
+Each `Entitlement` carries its `id`, `productIds`, `store`, expiry and renewal dates, and whether it is a lifetime purchase.
+
+To resolve entitlements for specific products, use `getEntitlementsByProductIds`, which asks the native SDK on both platforms:
+
+```kotlin
+val granted = Superwall.getEntitlementsByProductIds(setOf("pro_monthly", "pro_annual"))
+```
+
+## Previewing the outcome
+
+To adjust UI *before* a placement fires (hiding an upgrade button for users who would never see a paywall, for example), ask what registering would do:
+
+```kotlin
+val result = Superwall.getPresentationResult(placement = "StartWorkout")
+```
+
+This presents nothing. It just tells you what would happen.
+
+Next, [track subscription state](/kmp/quickstart/tracking-subscription-state).
diff --git a/content/docs/kmp/quickstart/install.mdx b/content/docs/kmp/quickstart/install.mdx
new file mode 100644
index 00000000..9dd532af
--- /dev/null
+++ b/content/docs/kmp/quickstart/install.mdx
@@ -0,0 +1,160 @@
+---
+title: "Install the SDK"
+description: Add the Superwall KMP SDK to your Kotlin Multiplatform project.
+---
+
+
+
+**Beta**
+
+The KMP SDK is in beta and its API may change between releases.
+
+
+
+## Requirements
+
+| Target | Requirement |
+| --- | --- |
+| Android | `minSdk` 26, `compileSdk` 36 |
+| iOS | iOS 14+ (`iosArm64`, `iosSimulatorArm64`, `iosX64`) |
+| Kotlin | 2.3.10 |
+
+Android is the shorter path: one Gradle dependency and you are done. iOS needs a second step, because the Kotlin framework does not embed the Swift bridge binary it compiles against.
+
+## Android
+
+Add the dependency to your shared module. Because the public API lives in `commonMain`, you can declare it there and both targets pick it up.
+
+
+
+```kotlin build.gradle.kts
+kotlin {
+ sourceSets {
+ commonMain.dependencies {
+ implementation("com.superwall.sdk:superwall-kmp:0.1.1")
+ }
+ }
+}
+```
+
+```toml libs.versions.toml
+[versions]
+superwall-kmp = "0.1.1"
+
+[libraries]
+superwall-kmp = { module = "com.superwall.sdk:superwall-kmp", version.ref = "superwall-kmp" }
+
+# And in your shared module's build.gradle.kts
+# commonMain.dependencies { implementation(libs.superwall.kmp) }
+```
+
+
+
+`superwall-android` comes along transitively, so do not add it yourself.
+
+
+
+**You do not need to edit `AndroidManifest.xml`.** This is the main way KMP install differs from the [standalone Android SDK](/android/quickstart/install), whose docs ask you to declare the paywall activity and permissions by hand.
+
+The KMP library manifest already declares `SuperwallPaywallActivity`, and `superwall-android` declares the `INTERNET`, `ACCESS_NETWORK_STATE`, and `POST_NOTIFICATIONS` permissions. (`com.android.vending.BILLING` arrives from the Play Billing library.) Manifest merging folds all of it into your app.
+
+**Migrating from the standalone Android SDK?** Remove the `SuperwallPaywallActivity` declaration from your own manifest first. Keeping it will fail the manifest merger, because the KMP library declares the same activity with a different theme (`Theme.AppCompat.NoActionBar`). If you need your own theme, override it with `tools:replace="android:theme"` rather than declaring the activity twice.
+
+
+
+### No `Context`, and what to do if that breaks
+
+`Superwall.configure` takes no `Context` on Android. An `androidx.startup` initializer in the library manifest captures the `Application` before any of your code runs, which is what lets the `commonMain` signature stay platform-free.
+
+If your app strips the startup provider (some apps remove `InitializationProvider` deliberately, and some shrinkers remove it by accident), that capture never happens and `configure()` fails with `SuperwallError.NotInitialized`. The fallback is an Android-only extension function:
+
+```kotlin
+// androidMain: only needed if the androidx.startup provider was removed
+import com.superwall.sdk.kmp.androidSetup
+
+class MyApplication : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ Superwall.androidSetup(this)
+ }
+}
+```
+
+It is idempotent, so calling it defensively alongside a working initializer is harmless.
+
+## iOS
+
+iOS is two steps. Miss the second one and the app fails to link.
+
+### 1. Export the Kotlin framework as static
+
+In your shared module, the framework **must** be static:
+
+```kotlin
+// shared/build.gradle.kts
+kotlin {
+ listOf(iosArm64(), iosSimulatorArm64(), iosX64()).forEach {
+ it.binaries.framework {
+ baseName = "Shared"
+ isStatic = true // required
+ }
+ }
+}
+```
+
+
+
+`isStatic = true` is required, not optional. The Kotlin framework compiles against the bridge's Objective-C headers only (compile-only cinterop) and never embeds the bridge binary, so a dynamic framework has nothing to resolve those symbols against at link time.
+
+
+
+### 2. Add the SuperwallKMPBridge Swift package
+
+In Xcode: **File → Add Package Dependencies…**, then paste the repository URL:
+
+```
+https://github.com/superwall/Superwall-KMP
+```
+
+Add the **`SuperwallKMPBridge`** product to your app target.
+
+
+
+
+ Xcode defaults the Dependency Rule to **Up to Next Major Version**, as shown. While the SDK is in
+ beta, pin **Exact Version** instead. The bridge binary and the Kotlin klib ship from the same tag
+ and are one release unit, so letting SPM drift ahead of the version your shared module was built
+ against is what breaks the link step.
+
+
+
+
+**Do not add SuperwallKit separately.**
+
+The bridge package pins SuperwallKit iOS to exactly `4.16.1` and pulls it in transitively. The pinned binary and the headers the Kotlin side was compiled against are one release unit. Adding SuperwallKit yourself invites a version conflict that SPM cannot resolve.
+
+
+
+
+
+**A missing bridge is a build-time failure, not a runtime one.** Because the Kotlin side uses compile-only cinterop, forgetting this step surfaces as undefined-symbol link errors when Xcode builds your app, not as a crash on launch or a paywall that silently fails to present. If you see linker errors mentioning bridge symbols, this step is what is missing.
+
+
+
+## Get your API key
+
+You need your **Public API Key** from the Superwall dashboard, under your app's settings. It is safe to ship in client code.
+
+Your Android and iOS apps are separate apps in the dashboard, and each has its own key. Since your `configure` call lives in shared code, it needs to receive the Android key on Android and the iOS key on iOS. [Getting the right API key](/kmp/guides/platform-differences#getting-the-right-api-key) shows a pattern for this.
+
+## Verify the install
+
+Build both targets before you write any integration code. On iOS in particular, a successful build is the signal that step 2 landed:
+
+```bash
+./gradlew :shared:build
+```
+
+Then build the iOS app from Xcode.
+
+**And you're done!** Now you're ready to [configure the SDK](/kmp/quickstart/configure) 👇
diff --git a/content/docs/kmp/quickstart/present-first-paywall.mdx b/content/docs/kmp/quickstart/present-first-paywall.mdx
new file mode 100644
index 00000000..d0f74906
--- /dev/null
+++ b/content/docs/kmp/quickstart/present-first-paywall.mdx
@@ -0,0 +1,166 @@
+---
+title: "Present your first paywall"
+description: Register a placement and present a Superwall paywall from shared code.
+---
+
+
+
+**Beta**
+
+The KMP SDK is in beta and its API may change between releases.
+
+
+
+## Register a placement
+
+Paywalls are presented by registering a **placement**. You do not tell the SDK to show a paywall. You tell it a placement occurred, and your campaign on the dashboard decides what happens.
+
+```kotlin
+Superwall.register(placement = "campaign_trigger")
+```
+
+That one line is a complete integration. Whether a paywall appears, which one, and to whom, is all configured on the dashboard without shipping an app update.
+
+
+ The placement name must match one you have added to a campaign on the
+ [dashboard](https://superwall.com/dashboard). A name that is not in any campaign resolves to
+ `PaywallSkippedReason.PlacementNotFound`.
+
+
+## Gate a feature behind it
+
+Pass a `feature` closure to run code that should only happen if the user is entitled to it:
+
+```kotlin
+Superwall.register(placement = "campaign_trigger") {
+ launchTheFeature()
+}
+```
+
+When the closure runs depends on the paywall's feature-gating behavior: immediately when no paywall shows, or after a purchase or restore when the placement is gated. [Feature gating](/kmp/quickstart/feature-gating) covers the rules.
+
+## Pass parameters
+
+`params` are usable in audience filters and on the paywall itself:
+
+```kotlin
+Superwall.register(
+ placement = "campaign_trigger",
+ params = mapOf(
+ "source" to "onboarding",
+ "isTrialEligible" to true,
+ ),
+)
+```
+
+Values may be `String`, `Boolean`, `Long`, `Double`, `List`, `Map`, or `Set`. Anything else is stringified.
+
+## Observe what happened
+
+A `PaywallPresentationHandler` reports on the presentation. Set only the closures you care about; unset ones are never invoked.
+
+```kotlin
+import com.superwall.sdk.kmp.PaywallPresentationHandler
+import com.superwall.sdk.kmp.models.results.PaywallResult
+import com.superwall.sdk.kmp.models.results.PaywallSkippedReason
+
+val handler = PaywallPresentationHandler()
+
+handler.onPresent { info ->
+ println("Presented ${info.name}")
+}
+
+handler.onDismiss { info, result ->
+ when (result) {
+ is PaywallResult.Purchased -> println("Purchased ${result.productId}")
+ is PaywallResult.Restored -> println("Restored")
+ is PaywallResult.Declined -> println("Declined")
+ }
+}
+
+handler.onSkip { reason ->
+ when (reason) {
+ is PaywallSkippedReason.Holdout -> println("Holdout: ${reason.experiment.id}")
+ is PaywallSkippedReason.NoAudienceMatch -> println("No audience match")
+ is PaywallSkippedReason.PlacementNotFound -> println("Placement not found")
+ }
+}
+
+handler.onError { error ->
+ println("Paywall error: $error")
+}
+
+Superwall.register(
+ placement = "campaign_trigger",
+ handler = handler,
+) {
+ launchTheFeature()
+}
+```
+
+
+ `onSkip` is not a failure path. A holdout or an unmatched audience filter means your campaign
+ worked as configured. The user was not meant to see a paywall.
+
+
+All handler closures and the `feature` closure are delivered on the **main thread**, on both platforms. You can touch UI from them directly.
+
+### Custom callbacks
+
+`onCustomCallback` is the one closure that returns a value. A paywall can request an action from your app and branch on the result:
+
+```kotlin
+import com.superwall.sdk.kmp.models.callbacks.CustomCallbackResult
+
+handler.onCustomCallback { callback ->
+ when (callback.name) {
+ "validate_email" -> {
+ val email = callback.variables?.get("email") as? String
+ if (isValidEmail(email)) {
+ CustomCallbackResult.success(mapOf("validated" to true))
+ } else {
+ CustomCallbackResult.failure(mapOf("error" to "Invalid email"))
+ }
+ }
+ else -> CustomCallbackResult.failure()
+ }
+}
+```
+
+It is a `suspend` closure, so you can do real work in it. When it is unset, the SDK responds with `CustomCallbackResult.failure()`.
+
+## Check before you register
+
+`getPresentationResult` previews what registering *would* do without presenting anything, which is useful for adjusting UI ahead of time, like hiding an upgrade button for users who would not see a paywall:
+
+```kotlin
+val result = Superwall.getPresentationResult(placement = "campaign_trigger")
+```
+
+## Controlling the paywall
+
+A few members act on the presented paywall:
+
+```kotlin
+Superwall.dismiss() // suspend; resumes once dismissed
+Superwall.isPaywallPresented // Boolean
+Superwall.latestPaywallInfo // PaywallInfo?
+Superwall.togglePaywallSpinner(isHidden = true)
+```
+
+## Preloading
+
+Paywalls preload by default. To take over the timing, disable it and preload yourself:
+
+```kotlin
+Superwall.configure(
+ apiKey = "pk_your_api_key",
+ options = SuperwallOptions(paywalls = PaywallOptions(shouldPreload = false)),
+)
+
+// Later
+Superwall.preloadAllPaywalls()
+Superwall.preloadPaywalls(placementNames = setOf("campaign_trigger"))
+```
+
+Next, [manage your users](/kmp/quickstart/user-management).
diff --git a/content/docs/kmp/quickstart/tracking-subscription-state.mdx b/content/docs/kmp/quickstart/tracking-subscription-state.mdx
new file mode 100644
index 00000000..15ad9321
--- /dev/null
+++ b/content/docs/kmp/quickstart/tracking-subscription-state.mdx
@@ -0,0 +1,162 @@
+---
+title: "Tracking Subscription State"
+description: "Observe whether a user is on a paid plan from shared Kotlin code."
+---
+
+
+
+**Beta**
+
+The KMP SDK is in beta and its API may change between releases.
+
+
+
+Superwall tracks subscription state for you. But there are times you need to know directly whether a user is on a paid plan, either to show different UI or to unlock something without a placement.
+
+## Read it synchronously
+
+```kotlin
+import com.superwall.sdk.kmp.models.entitlements.SubscriptionStatus
+
+when (val status = Superwall.subscriptionStatus) {
+ is SubscriptionStatus.Active -> showPro(status.entitlements)
+ is SubscriptionStatus.Inactive -> showFree()
+ is SubscriptionStatus.Unknown -> showLoading()
+}
+```
+
+`SubscriptionStatus` has three states:
+
+| State | Meaning |
+| --- | --- |
+| `Unknown` | Not yet determined, typically before configuration completes |
+| `Active(Set)` | The user has one or more active entitlements |
+| `Inactive` | The user has no active entitlements |
+
+There is also a convenience boolean:
+
+```kotlin
+if (Superwall.subscriptionStatus.isActive) { /* ... */ }
+```
+
+
+ Treat `Unknown` as its own case, not as "not subscribed". Showing free-tier UI during `Unknown`
+ will flash the wrong state at paying users on cold start.
+
+
+## Observe changes
+
+`subscriptionStatusFlow` is a `StateFlow`, so it always has a current value and emits on change:
+
+```kotlin
+import kotlinx.coroutines.launch
+
+scope.launch {
+ Superwall.subscriptionStatusFlow.collect { status ->
+ when (status) {
+ is SubscriptionStatus.Active -> showPro(status.entitlements)
+ is SubscriptionStatus.Inactive -> showFree()
+ is SubscriptionStatus.Unknown -> showLoading()
+ }
+ }
+}
+```
+
+
+
+**Two naming details to watch for if you are coming from the Android SDK.**
+
+The flow is `Superwall.subscriptionStatusFlow`. The plain `Superwall.subscriptionStatus` is a synchronous property, not a flow. On the Android SDK, `subscriptionStatus` *is* the flow.
+
+And this flow is **guard-exempt**: you can collect it before `configure`, where it is seeded with `SubscriptionStatus.Unknown` and attached to the native source once configuration completes. You do not have to sequence collection behind configuration.
+
+
+
+
+
+This is a plain `StateFlow`, which means it delivers on **your collector's context** and does not force emissions onto the main thread. Collect it from a main-dispatched scope (`collectAsState`, `viewModelScope`, `lifecycleScope`) and updating UI from the collector is safe. Collect it on `Dispatchers.IO` and it is not.
+
+The SDK's own KDoc says emissions arrive on the main thread; that is true of the common case, not a guarantee the flow enforces.
+
+
+
+### With Compose Multiplatform
+
+```kotlin
+@Composable
+fun ContentScreen() {
+ val status by Superwall.subscriptionStatusFlow.collectAsState()
+
+ when (val current = status) {
+ is SubscriptionStatus.Active -> PremiumContent(current.entitlements)
+ is SubscriptionStatus.Inactive -> FreeContent()
+ is SubscriptionStatus.Unknown -> LoadingIndicator()
+ }
+}
+```
+
+## Setting it yourself
+
+If you configured with a [`PurchaseController`](/kmp/guides/advanced-configuration), you own subscription state and must set it:
+
+```kotlin
+Superwall.subscriptionStatus = SubscriptionStatus.Active(entitlements)
+```
+
+
+ Only set this when you have a `PurchaseController`. Without one, Superwall manages the value and
+ writing to it will fight the SDK.
+
+
+## Detailed purchase history
+
+`CustomerInfo` carries more than `SubscriptionStatus` does, including full transaction history that merges device and web purchases:
+
+```kotlin
+val info = Superwall.getCustomerInfo()
+
+info.subscriptions // List
+info.nonSubscriptions // List
+info.entitlements // List
+info.userId // String
+```
+
+Each `SubscriptionTransaction` includes `productId`, `purchaseDate`, `expirationDate`, `willRenew`, `isActive`, `isInGracePeriod`, and more.
+
+Observe changes with `customerInfoFlow`:
+
+```kotlin
+scope.launch {
+ Superwall.customerInfoFlow.collect { info ->
+ render(info)
+ }
+}
+```
+
+
+
+Customer info works on **both** platforms. If you are reading the SDK's own KDoc, note that the
+`@platform iOS` annotations on `customerInfoFlow` and `getCustomerInfo` are out of date. They
+describe a limitation that no longer applies. On Android the flow is fed by the native
+`customerInfoDidChange` delegate hook, and `getCustomerInfo()` calls straight through to
+`superwall-android` 2.8.0.
+
+
+
+
+ `customerInfoFlow` has no replay, so a new collector gets nothing until the next change. Use
+ `getCustomerInfo()` for the current value.
+
+
+## Restoring purchases
+
+```kotlin
+import com.superwall.sdk.kmp.models.results.RestorationResult
+
+when (val result = Superwall.restorePurchases()) {
+ is RestorationResult.Restored -> println("Restored")
+ is RestorationResult.Failed -> println("Restore failed: ${result.error}")
+}
+```
+
+Restoration failure stays in the return type and does not throw. `Restored` means the restore completed without errors, not that the user necessarily has an active subscription.
diff --git a/content/docs/kmp/quickstart/user-management.mdx b/content/docs/kmp/quickstart/user-management.mdx
new file mode 100644
index 00000000..6f6246f0
--- /dev/null
+++ b/content/docs/kmp/quickstart/user-management.mdx
@@ -0,0 +1,145 @@
+---
+title: "User Management"
+description: "Identify users and set attributes from shared Kotlin code."
+---
+
+
+
+**Beta**
+
+The KMP SDK is in beta and its API may change between releases.
+
+
+
+It is necessary to uniquely identify users to track their journey within Superwall.
+
+## Anonymous users
+
+Superwall automatically generates a random user ID that persists until the user deletes or reinstalls your app. You do not have to do anything to get one.
+
+```kotlin
+Superwall.userId // the generated alias, or your ID once identified
+Superwall.isLoggedIn // false until identify() is called
+```
+
+## Identified users
+
+If you have your own user management system, call `identify` as soon as you have an ID, right after log in or sign up. This aliases your ID with the anonymous Superwall ID, which is what lets us load that user's assigned paywalls.
+
+```kotlin
+// After retrieving a user's ID, e.g. from logging in or creating an account
+Superwall.identify(userId = user.id)
+
+// When the user signs out
+Superwall.reset()
+```
+
+`reset()` returns the user to a fresh random ID and clears on-device paywall assignments and stored data.
+
+### Waiting for assignments
+
+If your users switch accounts often, or delete and reinstall frequently, you can make the SDK hold paywalls back until assignments have been restored from the server:
+
+```kotlin
+import com.superwall.sdk.kmp.models.identity.IdentityOptions
+
+Superwall.identify(
+ userId = user.id,
+ options = IdentityOptions(restorePaywallAssignments = true),
+)
+```
+
+
+ This is an advanced option and defaults to `false`. Turning it on delays paywall presentation until
+ assignments arrive, so only reach for it when logging a user into an *existing* account.
+
+
+## User attributes
+
+Attributes are usable in audience filters and can be templated onto paywalls.
+
+```kotlin
+Superwall.setUserAttributes(
+ mapOf(
+ "firstName" to "Jack",
+ "plan" to "trial",
+ "workoutCount" to 12L,
+ ),
+)
+```
+
+Values may be `String`, `Boolean`, `Long`, `Double`, `List`, `Map`, or `Set`. Anything else is stringified.
+
+
+
+**`setUserAttributes` merges rather than replaces.** Keys you pass are merged into the existing attributes, a `null` value **removes** that key, and keys you leave out are untouched.
+
+That asymmetry is deliberate, and it is why this is a method rather than a settable property: reading `Superwall.userAttributes` after setting will not give you back only what you set.
+
+
+
+```kotlin
+// Remove a single attribute
+Superwall.setUserAttributes(mapOf("plan" to null))
+
+// Read the current snapshot
+val attributes = Superwall.userAttributes
+```
+
+## Third-party integration attributes
+
+To line Superwall up with your analytics and attribution providers, set integration attributes:
+
+```kotlin
+import com.superwall.sdk.kmp.models.events.IntegrationAttribute
+
+Superwall.setIntegrationAttribute(IntegrationAttribute.AMPLITUDE_DEVICE_ID, "device-123")
+
+Superwall.setIntegrationAttributes(
+ mapOf(
+ IntegrationAttribute.AMPLITUDE_USER_ID to "user-abc",
+ IntegrationAttribute.MIXPANEL_DISTINCT_ID to "distinct-xyz",
+ ),
+)
+
+// Passing null removes an attribute
+Superwall.setIntegrationAttribute(IntegrationAttribute.AMPLITUDE_USER_ID, null)
+```
+
+Read them back with `Superwall.integrationAttributes`.
+
+## Device attributes
+
+The device attributes Superwall tracks are also available for audience filters:
+
+```kotlin
+val deviceAttributes = Superwall.getDeviceAttributes()
+```
+
+## Google Play account identifiers
+
+
+
+By default the SDK SHA-256 hashes your `userId` before forwarding it to Google Play. If you need the raw `appUserId` to appear in Play Console and downstream server events, set `passIdentifiersToPlayStore = true` when configuring:
+
+```kotlin
+Superwall.configure(
+ apiKey = "pk_your_api_key",
+ options = SuperwallOptions(passIdentifiersToPlayStore = true),
+)
+```
+
+This option is **Android only** and is ignored on iOS. Make sure the value complies with [Google's policies](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setObfuscatedAccountId), and note that it must not contain personally identifiable information.
+
+
+
+## Setting the locale
+
+Override the locale used to evaluate audience filters, or pass `null` to follow the device:
+
+```kotlin
+Superwall.localeIdentifier = "en_GB"
+Superwall.localeIdentifier = null // back to the device locale
+```
+
+Next, [gate your features](/kmp/quickstart/feature-gating).
diff --git a/content/docs/meta.json b/content/docs/meta.json
index c5419580..6a470cff 100644
--- a/content/docs/meta.json
+++ b/content/docs/meta.json
@@ -17,6 +17,7 @@
"android",
"expo",
"flutter",
+ "kmp",
"unity",
"web",
"react-native",
diff --git a/src/lib/grid-icons.ts b/src/lib/grid-icons.ts
index a3d86845..6b5b385e 100644
--- a/src/lib/grid-icons.ts
+++ b/src/lib/grid-icons.ts
@@ -154,6 +154,10 @@ const NAME_TO_GRID: Record = {
// Ideas / tips
lightbulb: "lightbulb",
Lightbulb: "lightbulb",
+ // KMP has no brand mark of its own; layers reads as "multiplatform" and is
+ // not already used elsewhere in the top nav.
+ Layers: "layers",
+ layers: "layers",
// Checkboxes / install-method cards
"box-check": "box_checked",
coconut: "box_checked", // "Use CocoaPods" card — matches its sibling install cards