Implement in-app purchases and subscriptions using StoreKit 2. Use when adding purchases, implementing subscriptions, handling receipt validation, managing entitlements, or debugging StoreKit issues. Triggers on in-app purchase, IAP, StoreKit, subscription, purchase, receipt, entitlement, paywall, monetization.
You are an in-app purchases specialist using StoreKit 2 (iOS 15+). When this skill activates, help implement robust purchase handling.
| Type | Use Case |
|---|---|
| Consumable | Coins, gems, one-time boosts |
| Non-Consumable | Unlock features, remove ads, themes |
| Auto-Renewable Subscription | Premium access, content subscriptions |
| Non-Renewing Subscription | Season pass, time-limited access |
import StoreKit
@Observable
class StoreManager {
private(set) var products: [Product] = []
private(set) var purchasedProductIDs: Set<String> = []
private(set) var subscriptionGroupStatus: Product.SubscriptionInfo.Status?
private var updateListenerTask: Task<Void, Error>?
// Product identifiers
static let productIDs: Set<String> = [
"com.app.premium.monthly",
"com.app.premium.yearly",
"com.app.coins.100",
"com.app.removeads"
]
init() {
updateListenerTask = listenForTransactions()
Task {
await loadProducts()
await updatePurchasedProducts()
}
}
deinit {
updateListenerTask?.cancel()
}
// MARK: - Load Products
func loadProducts() async {
do {
products = try await Product.products(for: Self.productIDs)
.sorted { $0.price < $1.price }
} catch {
print("Failed to load products: \(error)")
}
}
// MARK: - Purchase
func purchase(_ product: Product) async throws -> Transaction? {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
await updatePurchasedProducts()
await transaction.finish()
return transaction
case .pending:
// Ask-to-Buy or other pending states
return nil
case .userCancelled:
return nil
@unknown default:
return nil
}
}
// MARK: - Restore Purchases
func restorePurchases() async throws {
try await AppStore.sync()
await updatePurchasedProducts()
}
// MARK: - Transaction Listener
private func listenForTransactions() -> Task<Void, Error> {
Task.detached {
for await result in Transaction.updates {
do {
let transaction = try self.checkVerified(result)
await self.updatePurchasedProducts()
await transaction.finish()
} catch {
print("Transaction failed verification: \(error)")
}
}
}
}
// MARK: - Verification
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified(_, let error):
throw error
case .verified(let item):
return item
}
}
// MARK: - Update Purchased Products
func updatePurchasedProducts() async {
var purchased: Set<String> = []
for await result in Transaction.currentEntitlements {
do {
let transaction = try checkVerified(result)
switch transaction.productType {
case .autoRenewable:
if let expirationDate = transaction.expirationDate,
expirationDate > Date() {
purchased.insert(transaction.productID)
}
case .nonConsumable:
if transaction.revocationDate == nil {
purchased.insert(transaction.productID)
}
default:
break
}
} catch {
print("Failed to verify transaction: \(error)")
}
}
await MainActor.run {
self.purchasedProductIDs = purchased
}
}
// MARK: - Subscription Status
func checkSubscriptionStatus() async {
guard let product = products.first(where: { $0.type == .autoRenewable }) else {
return
}
guard let statuses = try? await product.subscription?.status else {
return
}
subscriptionGroupStatus = statuses.first { status in
switch status.state {
case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
return true
default:
return false
}
}
}
// MARK: - Helpers
var isPremium: Bool {
purchasedProductIDs.contains("com.app.premium.monthly") ||
purchasedProductIDs.contains("com.app.premium.yearly")
}
func product(for id: String) -> Product? {
products.first { $0.id == id }
}
}
struct PaywallView: View {
@Environment(StoreManager.self) private var store
@Environment(\.dismiss) private var dismiss
@State private var isPurchasing = false
@State private var error: Error?
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Header
VStack(spacing: 8) {
Image(systemName: "crown.fill")
.font(.system(size: 60))
.foregroundStyle(.yellow)
Text("Go Premium")
.font(.largeTitle.bold())
Text("Unlock all features")
.foregroundStyle(.secondary)
}
.padding(.top)
// Features
FeaturesList()
// Products
ForEach(subscriptionProducts) { product in
ProductCard(product: product) {
await purchase(product)
}
}
// Restore
Button("Restore Purchases") {
Task { await restore() }
}
.font(.footnote)
// Terms
Text("Subscriptions auto-renew unless cancelled 24 hours before the end of the current period.")
.font(.caption2)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding()
}
.padding()
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") { dismiss() }
}
}
.overlay {
if isPurchasing {
ProgressView()
.scaleEffect(1.5)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.ultraThinMaterial)
}
}
.alert("Error", isPresented: .constant(error != nil)) {
Button("OK") { error = nil }
} message: {
Text(error?.localizedDescription ?? "")
}
}
}
private var subscriptionProducts: [Product] {
store.products.filter { $0.type == .autoRenewable }
}
private func purchase(_ product: Product) async {
isPurchasing = true
defer { isPurchasing = false }
do {
if let _ = try await store.purchase(product) {
dismiss()
}
} catch {
self.error = error
}
}
private func restore() async {
isPurchasing = true
defer { isPurchasing = false }
do {
try await store.restorePurchases()
if store.isPremium {
dismiss()
}
} catch {
self.error = error
}
}
}
struct ProductCard: View {
let product: Product
let onPurchase: () async -> Void
var body: some View {
VStack(spacing: 12) {
HStack {
VStack(alignment: .leading) {
Text(product.displayName)
.font(.headline)
Text(product.description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing) {
Text(product.displayPrice)
.font(.title3.bold())
if let subscription = product.subscription {
Text(subscription.subscriptionPeriod.displayString)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
Button {
Task { await onPurchase() }
} label: {
Text("Subscribe")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
.padding()
.background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
}
}
extension Product.SubscriptionPeriod {
var displayString: String {
switch unit {
case .day: return value == 1 ? "per day" : "per \(value) days"
case .week: return value == 1 ? "per week" : "per \(value) weeks"
case .month: return value == 1 ? "per month" : "per \(value) months"
case .year: return value == 1 ? "per year" : "per \(value) years"
@unknown default: return ""
}
}
}
// Simple entitlement check
extension StoreManager {
func hasEntitlement(for productID: String) -> Bool {
purchasedProductIDs.contains(productID)
}
var canAccessPremiumContent: Bool {
isPremium
}
}
// View modifier for premium content
struct PremiumOnlyModifier: ViewModifier {
@Environment(StoreManager.self) private var store
@State private var showPaywall = false
func body(content: Content) -> some View {
Group {
if store.isPremium {
content
} else {
Button("Unlock Premium") {
showPaywall = true
}
.sheet(isPresented: $showPaywall) {
PaywallView()
}
}
}
}
}
extension View {
func premiumOnly() -> some View {
modifier(PremiumOnlyModifier())
}
}
extension StoreManager {
func purchaseConsumable(_ product: Product) async throws -> Int {
guard product.type == .consumable else {
throw StoreError.wrongProductType
}
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
// Grant the consumable (e.g., add coins)
let quantity = transaction.purchasedQuantity
await addCoins(quantity * 100)
// Always finish consumables
await transaction.finish()
return quantity
case .pending, .userCancelled:
return 0
@unknown default:
return 0
}
}
@MainActor
private func addCoins(_ amount: Int) {
UserDefaults.standard.set(
(UserDefaults.standard.integer(forKey: "coins") + amount),
forKey: "coins"
)
}
}
// Open subscription management in App Store
func openSubscriptionManagement() async {
guard let windowScene = await UIApplication.shared.connectedScenes.first as? UIWindowScene else {
return
}
do {
try await AppStore.showManageSubscriptions(in: windowScene)
} catch {
// Fallback to Settings URL
if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
await UIApplication.shared.open(url)
}
}
}
extension StoreManager {
func subscriptionRenewalInfo(for productID: String) async -> Product.SubscriptionInfo.RenewalInfo? {
guard let product = product(for: productID),
let subscription = product.subscription else {
return nil
}
guard let statuses = try? await subscription.status else {
return nil
}
for status in statuses {
if case .verified(let renewalInfo) = status.renewalInfo {
return renewalInfo
}
}
return nil
}
func willRenew(productID: String) async -> Bool {
guard let renewalInfo = await subscriptionRenewalInfo(for: productID) else {
return false
}
return renewalInfo.willAutoRenew
}
}
#if DEBUG
extension StoreManager {
func clearTestPurchases() async {
for await result in Transaction.all {
if case .verified(let transaction) = result {
await transaction.finish()
}
}
}
}
#endif
// Check environment
func isTestEnvironment() -> Bool {
#if DEBUG
return true
#else
return Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
#endif
}