Display a paywall
Present the paywall or onboarding flow shown at a location and handle how it ended.
The SDK presents the paywall or onboarding flow that Studio shows at a paywall location, drawn natively on iOS and Android. You change what appears at a location in Studio, without an app release. See Paywalls in your app.
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
Paywalls are drawn by a native renderer that the published packages don't include yet. Build it from
the Voidhash repository, then turn it on in your app.
Without it, presenting fails with FLOW_RENDERER_MISSING and the SDK makes no paywall requests.
Build the renderer
In a clone of the Voidhash repository, run:
node scripts/build-voidhash-ui.mjs ios androidIt needs Rust (the version in crates/rust-toolchain.toml) and Xcode for iOS. For Android it needs
the Android NDK 27.1.12297006, or another NDK set in ANDROID_NDK_HOME. The output lands in
crates/target/voidhash-ui:
ios/VoidhashUIEngine.xcframeworkandroid/jniLibs/arm64-v8a/libvoidhash_ui.soandandroid/jniLibs/x86_64/libvoidhash_ui.so
Put it where the SDK looks
- iOS: copy the repository's
libraries/ios-uifolder tonode_modules/@voidhash/ios-ui, next to@voidhash/ios, and copy the xcframework into it asArtifacts/VoidhashUIEngine.xcframework. That folder holds theVoidhashUIpod. - Android: copy the
jniLibsfolder tonode_modules/@voidhash/android/ui/src/main/jniLibs.
Reinstalling dependencies removes these copies, so repeat this step after each install.
Turn the renderer on
In an Expo app, set the config plugin's renderer option, then run npx expo prebuild and rebuild:
{
"expo": {
"plugins": [["@voidhash/react-native", { "renderer": true }]]
}
}The plugin adds the VoidhashUI pod from @voidhash/ios-ui and sets Voidhash_renderer=true in
android/gradle.properties. In a bare app, do both yourself: add
pod "VoidhashUI", :path => File.join(voidhash_ios, "..", "ios-ui") below the Voidhash pods from
the quickstart, and add Voidhash_renderer=true to
android/gradle.properties.
Present it
presentFlow(placement) and usePresentFlow(placement) take a location slug, such as
onboarding, despite the parameter's name. They present whatever is shown there and resolve when it
ends. They never reject.
import { Button } from "react-native";
import { useRouter } from "expo-router";
import { voidhash } from "../src/lib/voidhash";
export function UpgradeButton() {
const router = useRouter();
const { present, isPresenting } = voidhash.usePresentFlow("onboarding");
return (
<Button
disabled={isPresenting}
title="See plans"
onPress={async () => {
const result = await present();
if (result.status === "failed") router.push("/plans");
}}
/>
);
}Outside React, call the client. A flow already on screen is closed first and resolves as closed.
dismissFlow() also cancels a flow that is still loading, so it doesn't appear later.
const result = await voidhash.client.presentFlow("onboarding");
await voidhash.client.dismissFlow(); // closes it; the pending call resolves as closedHandle the result
status | Fields | Meaning |
|---|---|---|
purchased | productId | A purchase from the paywall completed and the paywall closed. Paywalls only. |
restored | — | A restore from the paywall completed and the paywall closed. Paywalls only. |
finished | result, variables | The flow ran a Finish flow action. result is that action's name in Studio; variables holds the flow's variables, such as survey answers. |
closed | — | The user closed it, you called dismissFlow(), or another flow replaced it. |
failed | error | Nothing was shown. error.code says why. |
An onboarding flow doesn't end on a purchase or restore; it continues to its next screen. It ends
with finished or closed and never returns purchased or restored, so check the perk after it
ends. A failed or cancelled purchase leaves any flow open, so the user can try again.
A paywall with an On close action in Studio runs it the first time the user tries to close,
for example to show a last offer, and presentFlow keeps waiting. dismissFlow() always closes at
once.
Purchases from a paywall go through the SDK's purchase path. In observer mode
they fail with READ_ONLY_PURCHASE_NOT_ALLOWED, and the paywall shows the failure.
Fall back when nothing was shown
Keep your own upgrade screen for failed, so important entry points always offer a way to upgrade.
| Code | Cause | Fix |
|---|---|---|
FLOW_NOT_ASSIGNED | Nothing is shown at the location, the location is missing or archived, or this app version can't run the deployed build. | Show a paywall at the location, or update the SDK. |
FLOW_UNAVAILABLE | The device has never loaded this location and can't reach Voidhash, or the paywall wasn't ready to show within 15 seconds. | Retry later. |
FLOW_RENDERER_MISSING | The build doesn't include the renderer. | Add the paywall renderer. |
FLOW_UNSUPPORTED_PACKAGE | The paywall was built for a newer SDK. | Update the SDK. |
FLOW_PRESENTATION_FAILED | There was no screen to present from, or the paywall couldn't be drawn. | Present from a mounted screen; report the error. |
FLOW_DISABLED | The client was created with enabled: false. | — |
To check what a location shows without presenting it, call getPaywallForLocation(slug). It
resolves to Ok(null) when nothing is shown there.
Caching and preloading
The device caches what each location shows for up to 7 days, so a location shown before works offline, and a new deploy reaches devices within about an hour. Paywalls in your app explains when it asks Voidhash again.
presentFlow gives a paywall 15 seconds from the call to be ready to show. One that isn't ready by
then is never shown: the call resolves as failed with FLOW_UNAVAILABLE, and the download
finishes in the background for the next presentation. A paywall on screen has no time limit.
A paywall's images are kept on the device too, up to 64 MB, so a cached paywall shows them while
their host can't be reached. The SDK fetches them ahead with the paywall, 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.
Locations the device has shown before download again at launch. List new ones in
preloadPlacements to download them at launch too:
import { createVoidhashClient } from "@voidhash/react-native";
export const voidhash = createVoidhashClient("vh_pk_...", {
preloadPlacements: ["onboarding"],
});Choose what paywalls may do
Purchases, restores, closing, navigation and analytics always work. Other actions need a capability
granted with flowCapabilities; by default only links is granted.
import { createVoidhashClient } from "@voidhash/react-native";
export const voidhash = createVoidhashClient("vh_pk_...", {
flowCapabilities: ["links", "notifications", "attributes"],
});| Capability | Allows the Studio action | Platform requirement |
|---|---|---|
links | Open URL | — |
notifications | Request permission for notifications | Android 13+: declare android.permission.POST_NOTIFICATIONS |
tracking | Request permission for tracking | iOS: NSUserTrackingUsageDescription in Info.plist. Android always answers false. |
attributes | Set user attribute | — |
An action whose capability isn't granted is skipped with a warning. In Expo, declare the
requirements under android.permissions and ios.infoPlist in app.json.
Saved progress
A flow saves its variables on the device for each person and flow. Presenting the same flow again
restores the variables, not the screen. A Finish flow action clears them, and so do identify()
and reset(). A purchase inside an onboarding flow doesn't clear them.
Analytics
Events a paywall records carry paywall_location and paywall_id, and each screen is recorded as
a $screen event. A purchase made on a paywall is credited, with its renewals, to the location,
paywall and A/B test variant. Purchases made outside a paywall, and restores, aren't credited to one.
Next steps
Something wrong or missing on this page? Tell us by email or on Discord.