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.
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:
dependencies {
implementation("com.voidhash:sdk")
implementation("com.voidhash:ui")
}Pass its renderer when you configure the SDK, with the capabilities your flows may use:
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.
| Result | When |
|---|---|
Purchased(productId) | A purchase in a paywall succeeded. productId is the Google Play product ID. |
Restored | A 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. |
Closed | The 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.
| Reason | Meaning |
|---|---|
NOT_ASSIGNED | Nothing 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. |
UNAVAILABLE | The 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_MISSING | paywallRenderer isn't set. |
UNSUPPORTED_PACKAGE | The 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_FAILED | The 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. |
DISABLED | The 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.
| Capability | Lets a flow |
|---|---|
LINKS | Run Open URL. |
ATTRIBUTES | Run Set user attribute on the current person. |
NOTIFICATIONS | Run Request permission for notifications. |
TRACKING | Run 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:
<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.