Server-driven paywall
Let the RevenueHog dashboard decide which products your paywall offers and in what order, with a compiled-in fallback, a TTL cache and the impression beacon.
RevenueHog can answer one question for your paywall: which products should it offer for an entitlement, and in what order. Your app keeps its paywall UI, StoreKit or Play Billing stays the price authority, and your own entitlement check still decides whether a paywall shows at all.
Concepts
- Entitlement: a key your app defines, such as
pro. It labels an offer set. RevenueHog never decides whether a user is subscribed. - Defaults: the ordered SKU list an entitlement serves when no experiment is running.
- SKU:
{ "productId": "...", "kind": "subscription" | "iap" }. Useiapfor consumables and non-consumables. A list holds at most 20 SKUs. - Experiment: an optional A/B split of an entitlement into variants. See Experiments.
Entitlement keys are lowercase identifiers matching ^[a-z0-9][a-z0-9_.-]{0,63}$. Defaults are edited in the dashboard or through the paywall management API.
Fetch and render
let paywall = await RevenueHog.paywall(
for: "pro",
fallback: ["com.example.pro.annual", "com.example.pro.monthly"]
)
let products = try await paywall.products() // loaded and sorted to menu order
// render, then:
RevenueHog.paywallShown(paywall, rendered: products.map(\.id))How the SDK resolves a paywall
The paywall is the money path, so resolution never fails and never blocks long:
- Fresh cache: the last answer for this entitlement, if it was fetched for the current user and is younger than its TTL.
- Network: one request with a 2.5 second deadline. On iOS it does not wait for App Attest enrollment.
- Stale cache: however old.
- Fallback: the compiled-in list you passed, with
isFallbackset totrue.
An empty server answer also resolves to the fallback. The cache TTL is the server's ttlSeconds, clamped to 60 seconds to 24 hours. identify and reset clear the cache, because the user ID sent on fetches is how purchases join experiment results. Forced previews are never cached.
Contract rules
These are requirements, not suggestions:
- Compile in a real fallback list at every call site: the product IDs your app shows today, not a placeholder.
- Render in the returned order.
Product.products(for:)andqueryProductDetailsAsyncreturn products unordered; re-sort. Under an experiment, order is often the treatment. - Fetch when the paywall is about to show, not at launch. A launch prefetch assigns a variant to someone who may never see a paywall.
- Call
paywallShownonce per appearance, after products load, with the IDs that actually rendered. Fetching assigns; the beacon exposes. The rendered list lets RevenueHog flag a variant whose SKU failed to load as a broken test rather than a losing offer. - Keep attributing purchases. Attribution is what makes an experiment measurable.
- Do not gate anything on the response. Never show an error or an empty paywall.
Server behavior
| Situation | Response |
|---|---|
| No experiment, entitlement has defaults | Defaults, ttlSeconds: 3600 |
| Unknown entitlement or unresolvable bundle ID | { "entitlement": "pro", "skus": [], "ttlSeconds": 3600 } |
| Running experiment | The install's variant, experimentId, variantKey, ttlSeconds: 900 |
| Paused experiment | Defaults with ttlSeconds: 900, so a resume propagates as fast as the pause |
| Experiment past its end date | Defaults with ttlSeconds: 3600 |
forceVariant from a non-Production environment | That variant with forced: true, nothing recorded |
Assignment is deterministic and sticky per install ID: sha256(experimentId + ":" + installId) picks a point on the cumulative weights in sorted variant-key order. Once stored, the assignment always wins, so reweighting only redirects installs not yet assigned.
Install ID and environment
The install ID is a UUID minted once per install. The iOS SDK keeps it in a non-synchronizable, device-only Keychain item, which survives reinstalls; Android uses SharedPreferences; React Native uses its storage adapter. Raw HTTP callers that cannot mint one may repeat appUserId as installId, with weaker stickiness.
environment is StoreKit's AppTransaction.environment (Production, Sandbox or Xcode). TestFlight reports Sandbox. Any other value reads as Production. It is a health filter for experiment results, never a gate. The iOS SDK sends it automatically on iOS 16+; React Native sends storeEnvironment from config; Android does not send it.
Raw HTTP
See the SDK API reference for POST /api/sdk/v1/paywall and POST /api/sdk/v1/paywall/impression.
Moving an existing app
Register the app's current product IDs as the entitlement's defaults first, in today's order. The first fetch then returns exactly what the app already shows, and the rewire is a provable no-op. Then test the fallback in airplane mode. The revenuehog-paywall skill automates this audit (see Skills).
If RevenueCat, Superwall or Adapty already owns the offer set, pick one owner per entitlement.
Related
Last updated on
Signpost events
Send low-volume funnel milestones such as paywall views and onboarding steps to RevenueHog, including the reserved onboarding_step event that powers the funnel.
Paywall experiments
Run A/B tests on the products and order your paywall offers, with sticky per-install assignment, exposure counting, weight epochs and readable results.