> For the complete AppRefer docs index, see https://apprefer.com/llms.txt (every page as markdown) or https://apprefer.com/llms-full.txt (all docs in one file).

# 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](https://apprefer.com/revenuecat-alternative)). 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](https://apprefer.com/docs/billing.md).

## Concepts

| Concept | Meaning |
| --- | --- |
| Entitlement | Access a customer has, such as premium. Optional catalogue in billing.entitlements; once it has entries, every product and defaultEntitlement may only name listed ids. |
| Product | A 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. |
| Offering | A 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. |
| Package | One 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. |
| Placement | A 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 rule | Up 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. |
| Experiment | An 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+)

**Swift**

```swift
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)
}
```

**Kotlin**

```kotlin
val offerings = AppReferBilling.offerings()
val offering = offerings.currentOffering(forPlacement = "onboarding") // or offerings.current
offering?.annual?.let { annual ->
    val result = AppReferBilling.purchase(activity, annual)
}
```

**Dart**

```dart
final offerings = await AppReferBilling.offerings();
final offering = offerings.currentOffering('onboarding'); // or offerings.current
final annual = offering?.annual;
if (annual != null) {
  final result = await AppReferBilling.purchasePackage(annual);
}
```

**TypeScript**

```typescript
const offerings = await AppReferBilling.offerings();
const offering = offerings.currentOffering('onboarding'); // or offerings.current
const annual = offering?.annual;
if (annual) {
  const result = await AppReferBilling.purchasePackage(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`.

---

Source: https://apprefer.com/docs/offerings · Last updated: 2026-10-19
