View as Markdownllms.txt

Billing

Offerings, placements and experiments

Decide from AppRefer what each paywall sells, for whom, and test it, without hard-coding product ids or shipping an app update.

AppRefer Billing uses the same catalogue model as RevenueCat (see AppRefer vs RevenueCat). Edit it in an app’s dashboard under Revenue → Products, Offerings and Experiments, with PUT /api/v1/apps/{appId}/billing (organization key with integrations:write), or with the admin MCP tools get_billing_catalog and update_billing_catalog. Each catalogue section (entitlements, offerings, currentOfferingId, placements, targeting, experiments) is replaced whole when you send it. Billing must be set up first: see Billing.

Concepts

ConceptMeaning
EntitlementAccess a customer has, such as premium. Optional catalogue in billing.entitlements; once it has entries, every product and defaultEntitlement may only name listed ids.
ProductA store product (App Store product id, Google Play subscription or one-time product id, Stripe Price lookup key) with the entitlements it unlocks. An empty list unlocks nothing (credits, add-ons). See Billing → Products and entitlements.
OfferingA named set of 1 to 12 packages plus optional metadata JSON (up to 8 KB) for paywall copy. Up to 50 offerings. One is the current (default) offering. Archive offerings instead of deleting them; archived ones are never returned.
PackageOne choice on the paywall, with a product per store (app_store, play_store, stripe). Ids: $ar_weekly, $ar_monthly, $ar_two_month, $ar_three_month, $ar_six_month, $ar_annual, $ar_lifetime (RevenueCat $rc_* ids are accepted) or a custom id. A Google Play product is subscriptionId:basePlanId, subscriptionId (the SDK picks the base plan matching the package period) or a one-time product id.
PlacementA paywall location (onboarding, settings…) mapped to an offering, or to null for no paywall. Up to 50. A placement the app asks for that isn’t configured uses the current offering.
Targeting ruleUp to 20 ordered rules, each with at least one condition: countries (storefront, ISO alpha-2), platforms (ios, android, web) and an app version range (appVersionMin, appVersionMax). The first enabled match sets the current offering. A condition the request can’t answer (no country, unreadable version) doesn’t match.
ExperimentAn A/B test of 2 to 4 offerings (the first is the control) with whole-number weights (default 50), an enrolment share of 1 to 100% and optional audience conditions. Statuses: draft, running, paused, completed. One runs at a time.

How the current offering is chosen

  1. A running experiment, when the customer matches its audience and falls inside its enrolment share.
  2. Otherwise the first enabled targeting rule that matches.
  3. Otherwise currentOfferingId.

Assignment hashes the install’s first anonymous id, so a customer keeps their variant after signing in. While an experiment runs, placements that show the default offering show the customer’s variant instead, so apps that open paywalls through placements still take part.

Experiments lock once started

After an experiment starts, its variants, weights, audience and enrolment share can’t change, and neither can the packages or metadata of the offerings in it, until it is completed. A started experiment can’t be deleted or go back to draft (pause or complete it), and a completed one can’t be restarted: create a new one. Every variant of a running experiment must sell on the same stores, so no platform sees an empty paywall in one arm.

SDK (0.8.0+)

let offerings = try await AppReferBilling.offerings()
let offering = offerings.currentOffering(forPlacement: "onboarding") // or offerings.current
if let annual = offering?.annual {
    let result = try await AppReferBilling.purchase(package: annual)
}

Each offering has metadata, availablePackages, a package lookup by id and the shortcuts weekly, monthly, twoMonth, threeMonth, sixMonth, annual and lifetime. Packages without a product for this platform, or that the store doesn’t return, are left out.

The SDK caches offerings in memory and on disk for five minutes, refreshes stale data on foreground and after logIn/logOut, and falls back to the last cached copy (of any age) when AppRefer can’t be reached. With no cache at all it throws: catch it and fall back to your built-in product ids. Purchasing a package sends its offering context (offering, package, placement, targeting rule, experiment and variant) with the purchase; it is kept on the subscription the first time.

Endpoint

HTTP
POST /api/billing/v1/offerings
X-AppRefer-Key: pk_live_…
{ "appUserId": "user_1", "bucketId": "$ARAnon:…", "platform": "ios", "country": "GB", "appVersion": "1.5.0",
  "known": { "appUserId": "user_1", "experimentId": "price_test", "variantId": "b" } }

200 { "currentOfferingId": "default",
      "offerings": [{ "id": "default", "displayName": "Default", "metadata": {},
                      "packages": [{ "id": "$ar_annual", "packageType": "annual", "displayName": null, "productId": "com.example.annual" }] }],
      "placements": { "onboarding": "default", "settings": null },
      "context": { "targetingRuleId": null, "experimentId": "price_test", "variantId": "b" },
      "requestDateMs": 1792000000000 }

platform is ios, android or web (Stripe), and only packages with a product for it are returned. bucketId is the install-stable id used for experiment assignment (defaults to appUserId). country is the storefront country. known is the context of the SDK’s cached response, so an experiment enrolment is recorded once per app user id. packageType is one of weekly, monthly, two_month, three_month, six_month, annual, lifetime or custom.

Experiment results

Per variant: enrolled customers, trial starts and conversions, paying customers, conversion to paying, gross, refunds and net USD, net revenue per enrolled customer, and the chance each variant beats the control on revenue per customer and on conversion (normal approximation, shown once each arm has 30 customers). Only production purchases made after a customer first saw the experiment count, joined on every app user id seen for the install, with sales deduplicated by store transaction. Revenue is after refunds and before store fees. Wait for about two weeks and a few hundred customers per variant before deciding. Results are cached for five minutes.

Read them in the dashboard, with GET /api/v1/apps/{appId}/billing/experiments/{experimentId}/results (organization key with billing:read), or with the MCP tool get_experiment_results.