Skip to main content

Displaying Products

AppActor organizes products into a three-level hierarchy: Offerings โ†’ Packages โ†’ Products. This structure lets you control what products to show without app updates.

Hierarchyโ€‹

Rollout Workflow (Payment Mode)โ€‹

This is the key benefit of offerings: your paywall catalog is managed from the backend, while the app always reads offerings.current.

  • Offerings โ€” A container of all available offerings. Has a current offering that the user should see.
  • Offering โ€” A named group of packages (e.g., "default", "holiday_sale", "premium")
  • Package โ€” Pairs a type (monthly, annual, lifetime, etc.) with a product
  • Product โ€” The actual StoreKit/Play Store product with pricing information

Offering Strategyโ€‹

Use a small set of clearly named offerings:

Offering TypePurposeExample ID
PrimaryYour default paywalldefault
CampaignTime-boxed promotionholiday_sale
ExperimentTest an alternative paywall mixpaywall_v2

Safety Noteโ€‹

Changing which packages appear in an offering changes what users see, not what they already own. Access remains entitlement-driven based on purchased product IDs.

Fetching Offeringsโ€‹

// Payment mode: fetch server-driven offerings
let offerings = try await AppActor.shared.offerings()
let current = offerings.current
print("Current offering: \(current?.displayName ?? "none")")

// Cached snapshot (no network call)
if let cached = AppActor.shared.cachedOfferings {
print("Cached offerings: \(cached.all.count)")
}
tip

In payment mode, offerings() uses cache + conditional requests internally. You can call it safely on app launch and paywall open.

Accessing Packagesโ€‹

Each offering provides convenience accessors for common package types:

let offerings = try await AppActor.shared.offerings()
guard let offering = offerings.current else { return }

// Convenience accessors
let monthly = offering.monthly // monthly
let annual = offering.annual // annual
let weekly = offering.weekly // weekly
let sixMonth = offering.sixMonth // sixMonth
let threeMonth = offering.threeMonth // threeMonth
let twoMonth = offering.twoMonth // twoMonth
let lifetime = offering.lifetime // lifetime

// All packages
for package in offering.packages {
print("\(package.packageType): \(package.localizedPrice)")
}

Package Typesโ€‹

TypeDescription
weeklyWeekly subscription
monthlyMonthly subscription
twoMonth2-month subscription
threeMonth3-month subscription
sixMonth6-month subscription
annualAnnual subscription
lifetimeOne-time lifetime purchase
customCustom package type

Displaying Pricesโ€‹

Products include localized pricing from the App Store:

let offerings = try await AppActor.shared.offerings()
guard let package = offerings.current?.monthly else { return }

// Localized price string (e.g., "$9.99", "โ‚ฌ8,99", "ยฅ980")
let price = package.localizedPrice

print("Name: \(package.productName)")
print("Description: \(package.productDescription)")
print("Type: \(package.productType)")

// Optional direct StoreKit access
if let product = package.storeProduct {
print("StoreKit price: \(product.displayPrice)")
}

SwiftUI Exampleโ€‹

struct PaywallView: View {
@State private var offerings: AppActorOfferings?
@State private var isLoading = false

var body: some View {
VStack(spacing: 16) {
if let offering = offerings?.current {
Text(offering.displayName)
.font(.title)

ForEach(offering.packages) { package in
Button {
Task {
let result = try await AppActor.shared.purchase(package: package)
switch result {
case .success:
break
case .pendingServerValidation:
break
case .failedServerValidation:
break
case .cancelled, .pending:
break
}
}
} label: {
HStack {
Text(package.packageType.description)
Spacer()
Text(package.localizedPrice)
}
.padding()
.background(Color.blue.opacity(0.1))
.cornerRadius(12)
}
}
} else {
ProgressView("Loading offerings...")
}
}
.padding()
.task {
guard offerings == nil, !isLoading else { return }
isLoading = true
defer { isLoading = false }
offerings = try? await AppActor.shared.offerings()
}
}
}

Offerings in Local Modeโ€‹

In local mode, offerings are defined in your configuration and products are fetched from StoreKit:

AppActor.configure(
projectKey: "myapp",
defaultOfferingID: "default", // Which offering to set as .current
offerings: {
AppActorOfferingDefinition("default", displayName: "Standard") {
AppActorPackageDefinition(.monthly, productID: "com.myapp.monthly")
AppActorPackageDefinition(.annual, productID: "com.myapp.annual")
}
},
entitlements: { /* ... */ }
)

Next Stepsโ€‹