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
React Native SDK
React Native quickstart
Display a paywall
Make and restore purchases
Check access
Identify users
Evaluate feature flags
Capture analytics
Groups
Track screens
Development mode
React Native SDK reference
Errors
Troubleshooting

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.

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

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 android

It 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.xcframework
  • android/jniLibs/arm64-v8a/libvoidhash_ui.so and android/jniLibs/x86_64/libvoidhash_ui.so

Put it where the SDK looks

  • iOS: copy the repository's libraries/ios-ui folder to node_modules/@voidhash/ios-ui, next to @voidhash/ios, and copy the xcframework into it as Artifacts/VoidhashUIEngine.xcframework. That folder holds the VoidhashUI pod.
  • Android: copy the jniLibs folder to node_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:

app.json
{
  "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 closed

Handle the result

statusFieldsMeaning
purchasedproductIdA purchase from the paywall completed and the paywall closed. Paywalls only.
restored—A restore from the paywall completed and the paywall closed. Paywalls only.
finishedresult, variablesThe 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.
failederrorNothing 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.

CodeCauseFix
FLOW_NOT_ASSIGNEDNothing 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_UNAVAILABLEThe 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_MISSINGThe build doesn't include the renderer.Add the paywall renderer.
FLOW_UNSUPPORTED_PACKAGEThe paywall was built for a newer SDK.Update the SDK.
FLOW_PRESENTATION_FAILEDThere was no screen to present from, or the paywall couldn't be drawn.Present from a mounted screen; report the error.
FLOW_DISABLEDThe 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"],
});
CapabilityAllows the Studio actionPlatform requirement
linksOpen URL—
notificationsRequest permission for notificationsAndroid 13+: declare android.permission.POST_NOTIFICATIONS
trackingRequest permission for trackingiOS: NSUserTrackingUsageDescription in Info.plist. Android always answers false.
attributesSet 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

  • Set up paywall locations
  • Build onboarding flows
  • Check access after a purchase
  • Build your own purchase screen

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

PreviousReact Native quickstartNextMake and restore purchases

On this page

Add the paywall rendererBuild the rendererPut it where the SDK looksTurn the renderer onPresent itHandle the resultFall back when nothing was shownCaching and preloadingChoose what paywalls may doSaved progressAnalyticsNext steps