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

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.

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 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.

  1. 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/
  2. In Xcode, remove the repository URL package if you added it, so Voidhash comes from one place. Choose File → Add Package Dependencies… → Add Local… and add libraries/ios, then libraries/ios-ui, from the clone. Add Voidhash, VoidhashCore and VoidhashUI to your app target.

  3. 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)
}
ResultMeaning
.purchased(productId:)A purchase on a paywall completed, and the paywall closed.
.restoredA 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.
.closedThe 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]
CapabilityLets the paywall
.linksOpen URLs outside the app (Open URL).
.notificationsAsk for notification permission (Request permission).
.trackingAsk for App Tracking Transparency permission (Request permission). Your Info.plist needs NSUserTrackingUsageDescription.
.attributesSave 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.

ErrorCodeCause
.notAssignedFLOW_NOT_ASSIGNEDNothing 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.
.unavailableFLOW_UNAVAILABLEThe 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.
.rendererMissingFLOW_RENDERER_MISSINGoptions.paywallRenderer isn't set.
.unsupportedPackage(abiVersion:)FLOW_UNSUPPORTED_PACKAGEThe paywall was built in a format this SDK can't read. Update the SDK.
.presentationFailed(String)FLOW_PRESENTATION_FAILEDThere was nothing to present from, the view controller you passed can't present, or the paywall can no longer be drawn.
.disabledFLOW_DISABLEDThe 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

  • Check access after a purchase
  • Paywall locations
  • Build and deploy a paywall
  • Troubleshooting

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

PreviousSwift quickstartNextMake and restore purchases

On this page

Add the paywall rendererPresent a paywallPresent from a view controllerChoose what paywalls may doSaved progressWhen nothing is shownCaching and offlinePreload locationsLanguageAnalyticsEmbed a paywall viewNext steps