RevenueHogdocs
SDKs

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" }. Use iap for 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:

  1. Fresh cache: the last answer for this entitlement, if it was fetched for the current user and is younger than its TTL.
  2. Network: one request with a 2.5 second deadline. On iOS it does not wait for App Attest enrollment.
  3. Stale cache: however old.
  4. Fallback: the compiled-in list you passed, with isFallback set to true.

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:) and queryProductDetailsAsync return 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 paywallShown once 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

SituationResponse
No experiment, entitlement has defaultsDefaults, ttlSeconds: 3600
Unknown entitlement or unresolvable bundle ID{ "entitlement": "pro", "skus": [], "ttlSeconds": 3600 }
Running experimentThe install's variant, experimentId, variantKey, ttlSeconds: 900
Paused experimentDefaults with ttlSeconds: 900, so a resume propagates as fast as the pause
Experiment past its end dateDefaults with ttlSeconds: 3600
forceVariant from a non-Production environmentThat 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.

Last updated on

On this page