VoidhashDocs
DevelopersHelp centerGuidesAPI reference
Dashboard
Get started
Introduction
Quickstart
How Voidhash works
Glossary
Versions and releases
Concepts
Teams, projects and environments
API keys
Products and perks
Offers
Subscription lifecycle
People and identity
Groups
Events
Features
Paywalls in your app
Feature flags and A/B tests
Paywall components
Push notifications
Ad attribution in your app
Client SDKs
React Native
Swift
Kotlin
Web
Your backend
Node.js
PHP
Go
Rust
Other languages
Payment sources
Forward store notifications
Developer tools
CLI
MCP server
Query analytics with VoidQL
Kotlin SDK
Kotlin quickstart
Display a paywall
Make and restore purchases
Check access
Identify users
Evaluate feature flags
Capture analytics
Groups
Track screens
Development mode
Kotlin SDK reference
Errors
Diagnostic codes
Troubleshooting

Display a paywall

Present the paywall or onboarding flow assigned to a location and handle how it ended.

presentFlow shows the paywall or onboarding flow assigned to a paywall location and returns how it ended. You design, deploy and assign flows in Studio; see Paywalls in your app and Paywall locations.

Early access

Paywalls are in early access. Ask us on Discord, or email support@voidhash.com on a paid plan, to turn them on for your team.

Add the paywall renderer

The SDK's optional ui module draws flows natively with a native library, libvoidhash_ui.so, that you build from source. You need Git, Node.js, the Rust toolchain and Android NDK 27.1.12297006 from Android Studio's SDK Manager.

In the clone you made in the quickstart, build the library and copy it into the ui module:

cd third_party/voidhash
rustup target add --toolchain 1.96.0 aarch64-linux-android x86_64-linux-android
node scripts/build-voidhash-ui.mjs android
cp -R crates/target/voidhash-ui/android/jniLibs libraries/android/ui/src/main/

The script looks for the NDK under ANDROID_HOME; set ANDROID_NDK_HOME if it lives elsewhere. Rebuild the library whenever you update the clone. The library ships for arm64-v8a and x86_64; on other devices, and without the library, no flow can be drawn.

The included build from the quickstart already contains the module. Depend on it:

app/build.gradle.kts
dependencies {
    implementation("com.voidhash:sdk")
    implementation("com.voidhash:ui")
}

Pass its renderer when you configure the SDK, with the capabilities your flows may use:

App.kt
import com.voidhash.sdk.FlowCapability
import com.voidhash.sdk.Voidhash
import com.voidhash.sdk.VoidhashOptions
import com.voidhash.ui.VoidhashUiPaywallRenderer

val voidhash = Voidhash.configure(
    context = this,
    publishableKey = "vh_pk_...",
    options = VoidhashOptions(
        paywallRenderer = VoidhashUiPaywallRenderer(),
        flowCapabilities = setOf(FlowCapability.LINKS, FlowCapability.ATTRIBUTES),
    ),
)

Without a renderer, presentFlow returns RENDERER_MISSING.

Present a flow

Pass an activity and a location slug. The argument is named placement, but it takes the slug of a paywall location, such as onboarding.

import com.voidhash.sdk.FlowResult

lifecycleScope.launch {
    when (val result = voidhash.presentFlow(activity, "onboarding")) {
        is FlowResult.Purchased -> refreshAccess()
        FlowResult.Restored -> refreshAccess()
        is FlowResult.Finished -> saveAnswers(result.result, result.variables)
        FlowResult.Closed -> Unit
        is FlowResult.Failed -> showOwnUpgradeScreen()
    }
}

The flow covers the activity in a fullscreen dialog. presentFlow suspends until the flow is gone and never throws: a flow that can't be shown is a Failed result.

ResultWhen
Purchased(productId)A purchase in a paywall succeeded. productId is the Google Play product ID.
RestoredA restore in a paywall succeeded.
Finished(result, variables)The flow ran a Finish flow action. result is the name you gave the action; variables holds the flow's variables, such as answers.
ClosedThe flow's close action, the back button on the first screen, dismissFlow(), or another flow presented in its place.
Failed(error)The flow couldn't be shown, or stopped on screen. See Handle failures.

A paywall ends when a purchase or restore succeeds. An onboarding flow keeps running after one, so it ends with Finished or Closed and never returns Purchased or Restored. Check access with hasActivePerk after it ends.

A failed or cancelled purchase shows its error inside the flow and leaves it open. In observer mode, purchases in flows fail with READ_ONLY_PURCHASE_NOT_ALLOWED.

When a paywall has an On close screen, such as a last offer, the first close attempt shows it instead of closing, and presentFlow keeps waiting. It shows at most once per presentation.

Only one flow is on screen at a time. dismissFlow() closes it at once, and so does cancelling the coroutine that called presentFlow. dismissFlow() also cancels a flow that is still loading, so it never appears later. Call it from any thread.

A flow stays up when Android recreates the activity for a configuration change, such as a rotation or a switch to dark mode, and presentFlow keeps waiting. Call presentFlow from a coroutine that outlives the activity, such as one in viewModelScope: one launched in the activity's lifecycleScope is cancelled when the activity is destroyed, which dismisses the flow. An activity that is destroyed for good, or isn't recreated within 5 seconds, closes the flow with Closed.

A flow that comes back on screen, after a recreation or after the app was in the background, starts again at its first screen with its saved variables. It still counts as one view.

Handle failures

FlowResult.Failed carries a FlowException. Its reason is a FlowFailure, and its code is FLOW_ plus the reason, such as FLOW_NOT_ASSIGNED.

ReasonMeaning
NOT_ASSIGNEDNothing is assigned to the location for this person, the location doesn't exist or is archived, or this app's renderer can't run the deployed build.
UNAVAILABLEThe device never resolved this location and Voidhash is unreachable, the flow couldn't be downloaded or verified, or it wasn't ready to show within 15 seconds.
RENDERER_MISSINGpaywallRenderer isn't set.
UNSUPPORTED_PACKAGEThe renderer can't draw this build: update the SDK, or the native library is missing for this device. abiVersion names the build's format.
PRESENTATION_FAILEDThe flow couldn't be shown, or three frames in a row failed to draw while it was on screen. A single failed frame is drawn again.
DISABLEDThe client is disabled or shut down.

Keep a fallback screen for important entry points so people can always upgrade.

Grant capabilities

Showing screens, purchases, restores, closing and Track event need no permission. Other actions need a capability in flowCapabilities; without it, the action is skipped and the SDK logs a warning. Only LINKS is granted by default.

CapabilityLets a flow
LINKSRun Open URL.
ATTRIBUTESRun Set user attribute on the current person.
NOTIFICATIONSRun Request permission for notifications.
TRACKINGRun Request permission for tracking. Android has no such prompt, so it answers false.

On Android 13 and later, a notification request shows the system prompt. Declare the permission, or the request is refused:

AndroidManifest.xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

On earlier versions, the flow learns whether notifications are enabled for your app.

Saved progress

The SDK saves a flow's variables on the device, per person and per flow, for 90 days. Presenting the same flow again restores the variables, not the screen. A Finish flow action, a successful purchase in a paywall, identify and reset clear them. A purchase in an onboarding flow doesn't.

Caching and offline

The SDK caches what each location shows for up to 7 days, so a flow shown once works offline, and a new deploy reaches devices within about an hour. Paywalls in your app explains when it asks Voidhash again. Downloaded flows are verified before they are kept.

presentFlow gives a flow 15 seconds from the call to be ready to show. A flow that isn't ready by then is never shown: presentFlow returns UNAVAILABLE, and the download finishes in the background, so the next call shows the flow from the cache. A flow on screen has no time limit.

A flow's images are kept on the device too, up to 64 MB, so a cached flow shows them while their host can't be reached. The SDK fetches them ahead when it preloads a location or loads its flow, except on a metered connection such as cellular data. Only http and https images of up to 10 MB are loaded, and only PNG, JPEG, GIF and WebP images are kept. A kept image isn't downloaded again, so publish changed artwork under a new URL.

To make a flow available offline before its first presentation, list its location in preloadPlacements:

VoidhashOptions(
    paywallRenderer = VoidhashUiPaywallRenderer(),
    preloadPlacements = listOf("onboarding"),
)

To find out what a location would show without presenting it, call resolvePaywall("onboarding"). It returns a ResolvedPaywall with paywallId, releaseId and version, or null when nothing is assigned or no renderer is configured. resolvePaywallState adds the cache freshness.

Analytics and revenue

Each screen of a flow is recorded as a screen view with paywall_location and flow_screen_index. Events from Track event carry paywall_location and paywall_id. An intercepted close records $paywall_close_intercepted.

A purchase made in a flow credits its revenue, renewals included, to the location, paywall, build and A/B test arm. Purchases made outside a flow and restores aren't credited to a paywall.

Next steps

  • Paywall locations: assign flows and change them safely.
  • Check access after a flow ends.
  • Make and restore purchases with your own screen.
  • Troubleshooting when a paywall doesn't appear.

Something wrong or missing on this page? Tell us by email or on Discord.

PreviousKotlin quickstartNextMake and restore purchases

On this page

Add the paywall rendererPresent a flowHandle failuresGrant capabilitiesSaved progressCaching and offlineAnalytics and revenueNext steps