Display a paywall
Present the paywall assigned to a location and react to how it ended.
presentFlow(placement:) presents the paywall or onboarding flow assigned to a
paywall location in Studio, and returns how it ended. The placement
argument takes the location's slug, such as onboarding. Paywalls and onboarding flows are
explained in 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 natively, with Metal, by the VoidhashUI library. VoidhashUI isn't in any
published package today: the repository URL package and the @voidhash/ios npm package contain
only Voidhash and VoidhashCore. Its engine, VoidhashUIEngine.xcframework, is built from the
Rust sources in the Voidhash repository, so you build it once and link the SDK from your checkout.
-
Install Node.js and the Rust toolchain, then clone the repository and build the engine:
git clone https://github.com/voidhashcom/voidhash.git third_party/voidhash cd third_party/voidhash rustup target add --toolchain 1.96.0 aarch64-apple-ios aarch64-apple-ios-sim node scripts/build-voidhash-ui.mjs ios mkdir -p libraries/ios-ui/Artifacts cp -R crates/target/voidhash-ui/ios/VoidhashUIEngine.xcframework libraries/ios-ui/Artifacts/ -
In Xcode, remove the repository URL package if you added it, so
Voidhashcomes from one place. Choose File → Add Package Dependencies… → Add Local… and addlibraries/ios, thenlibraries/ios-ui, from the clone. AddVoidhash,VoidhashCoreandVoidhashUIto your app target. -
Pass the renderer when you configure the SDK:
MyApp.swift import Voidhash import VoidhashUI func configureVoidhash() { var options = VoidhashOptions() options.paywallRenderer = VoidhashUIPaywallRenderer() Voidhash.configure(publishableKey: "vh_pk_...", options: options) }
The engine has slices for devices and for the Simulator on Apple silicon Macs; it doesn't run in the
Simulator on Intel Macs. Rebuild it whenever you update the clone. Without a renderer,
presentFlow returns .failed(.rendererMissing) and the SDK makes no paywall requests.
Present a paywall
The call returns once the paywall or flow has ended and left the screen, so you can present your next screen as soon as it returns.
switch await voidhash.presentFlow(placement: "onboarding") {
case .purchased(let productId):
print("Bought \(productId)")
case .restored:
print("Restored")
case .finished(let result, let variables):
handleOnboarding(result, variables)
case .closed:
break
case .failed(let error):
showOwnUpgradeScreen(reason: error)
}| Result | Meaning |
|---|---|
.purchased(productId:) | A purchase on a paywall completed, and the paywall closed. |
.restored | A restore on a paywall completed, and the paywall closed. |
.finished(result:variables:) | The flow ran a Finish flow action. result is the name you gave that action in Studio; variables holds the flow's variables, such as survey answers. |
.closed | The user closed it, your app called dismissFlow() or cancelled the task awaiting presentFlow, or another presentFlow replaced it. |
.failed(FlowError) | Nothing was shown, or the paywall could no longer be drawn. See When nothing is shown. |
An onboarding flow doesn't end on a purchase or restore: it continues to its next screen and ends
with .finished or .closed. It never returns .purchased or .restored. Check access after any
result instead of relying on it; see Check access.
A cancelled or failed purchase leaves the paywall on screen so the user can try again. Purchases run
through purchase(product:), so the SDK syncs and finishes them as usual. In observer mode they're
refused with READ_ONLY_PURCHASE_NOT_ALLOWED, reported through onWarning.
A paywall can intercept the user's first attempt to close it, for example to show a last offer. The
paywall then stays on screen and presentFlow keeps waiting; the next attempt closes it.
dismissFlow() always closes it at once.
Present from a view controller
The paywall is presented full screen from the key window's top view controller. UIKit apps can choose the presenter:
let result = await voidhash.presentFlow(placement: "onboarding", from: viewController)The view controller has to be on screen and not presenting anything itself. Otherwise
presentFlow returns .failed(.presentationFailed).
Close the paywall from your own code with await voidhash.dismissFlow(); the pending
presentFlow returns .closed. dismissFlow() returns once the paywall is off the screen. Called
while the paywall is still loading, it keeps it from being shown. Cancelling the task that awaits
presentFlow does the same.
Shutting the client down, or configuring the SDK again, also ends its paywalls: one on screen is
closed and returns .closed, and one that is still loading isn't shown and returns
.failed(.disabled).
Choose what paywalls may do
Purchases, restores, closing, navigation and analytics always work. Anything else a paywall can do
is off until you grant it in flowCapabilities. By default only .links is granted.
options.flowCapabilities = [.links, .notifications, .attributes]| Capability | Lets the paywall |
|---|---|
.links | Open URLs outside the app (Open URL). |
.notifications | Ask for notification permission (Request permission). |
.tracking | Ask for App Tracking Transparency permission (Request permission). Your Info.plist needs NSUserTrackingUsageDescription. |
.attributes | Save values as person attributes (Set user attribute), like setPersonAttributes. |
An action you didn't grant is skipped and reported through onWarning.
Saved progress
An onboarding flow saves its variables on the device as the user moves through it, separately for each person and flow, for up to 90 days. When the same flow is presented again, it starts with those variables, so earlier answers are kept; the user starts again on the first screen.
A Finish flow action clears the saved variables, and so does a purchase on a paywall. A purchase
inside an onboarding flow doesn't. Saved variables belong to the current distinct ID, so after
identify or signOut the flow starts fresh.
When nothing is shown
.failed carries a FlowError. Its description starts with a stable code.
| Error | Code | Cause |
|---|---|---|
.notAssigned | FLOW_NOT_ASSIGNED | Nothing is assigned to the location, the location doesn't exist or is archived, or the deployed build needs a newer SDK than this app has. |
.unavailable | FLOW_UNAVAILABLE | The device has never loaded this location and can't reach Voidhash, the paywall couldn't be downloaded, or it wasn't ready to show within 15 seconds. |
.rendererMissing | FLOW_RENDERER_MISSING | options.paywallRenderer isn't set. |
.unsupportedPackage(abiVersion:) | FLOW_UNSUPPORTED_PACKAGE | The paywall was built in a format this SDK can't read. Update the SDK. |
.presentationFailed(String) | FLOW_PRESENTATION_FAILED | There was nothing to present from, the view controller you passed can't present, or the paywall can no longer be drawn. |
.disabled | FLOW_DISABLED | The client is disabled or shut down. |
FLOW_NOT_ASSIGNED is the normal answer for a location you haven't set up. Keep a fallback upgrade
screen for important entry points.
Caching and offline
The SDK caches what each location shows for up to 7 days, so a location shown once works offline, and a new deploy reaches devices within about an hour. Paywalls in your app explains when it asks Voidhash again.
A paywall has 15 seconds from the presentFlow call to be ready to show. When it takes longer,
nothing is shown, then or later, and presentFlow returns .failed(.unavailable). The download
carries on, so the next presentation finds the paywall on the device. A paywall on screen has no
time limit.
A paywall package larger than 16 MB isn't loaded. A paywall shows images from http and https
URLs only, and skips an image larger than 10 MB.
The images a paywall shows are kept on the device too, up to 64 MB, so a paywall shown or preloaded before has them while their host can't be reached. Only PNG, JPEG, WebP and GIF images are kept. The SDK fetches them ahead when it loads a paywall for a presentation or a preload, except on cellular, on a personal hotspot or in Low Data Mode. A kept image isn't downloaded again, so publish changed artwork under a new URL.
Preload locations
The SDK remembers up to 20 locations the device has presented and downloads their paywalls at launch. To have paywalls ready on the very first launch, list their location slugs:
options.preloadPlacements = ["onboarding"]Language
Each paywall text, image and property is shown in the device's first preferred language
(Locale.preferredLanguages.first) when the paywall has a translation for it. An exact match such
as pt-BR wins, then the language alone (pt), then the paywall's base content. A paywall
translated only to pt-BR doesn't serve pt devices. Prices are formatted by the App Store. See
Translate a paywall.
Analytics
Events a paywall tracks are recorded with paywall_location and paywall_id properties, and each
screen it shows is recorded as a $screen event. An intercepted close attempt records
$paywall_close_intercepted.
A purchase made on a paywall is credited, with its renewals, to the location, paywall, build and A/B test variant. Purchases made outside a paywall, restores, and Ask to Buy purchases approved after the paywall closed aren't credited to a paywall. A/B tests have no SDK API: Voidhash picks the variant when the location resolves. See A/B tests on paywalls.
Embed a paywall view
VoidhashPaywallView is the UIView the renderer presents. It draws a paywall package and
exchanges messages with its host. Apps don't need it: presentFlow handles presentation,
purchases and analytics. Use getPaywall(location:) and the view only to build your own presenter.
Next steps
Something wrong or missing on this page? Tell us by email or on Discord.