Features

In-App Purchases

Monetization is usually the last thing you build and the easiest to get wrong: unverified receipts, missed renewals, restore flows that don't restore. ShipThatApp gives you a production-grade purchase layer on day one. A single @Observable PurchaseManager wraps StoreKit 2 — async purchases, cryptographically verified transactions, live entitlement tracking, and background renewal updates — and you get three drop-in paywalls to choose from, including one powered by RevenueCat.

StoreKit 2 for the engine, RevenueCat for a paywall

The purchasing engine (PurchaseManager) is pure StoreKit 2 — no SDK lock-in, no server dependency to take a payment. RevenueCat v5 is also configured at launch and powers the optional Paywall2View, so you can A/B test RevenueCat's remote-configurable paywalls without rewiring your purchase logic. Pick the path that fits; both ship working.

Architecture at a Glance

  • ManagerServices/Purchases/PurchaseManager.swift: the @Observable source of truth for products and entitlements.
  • PaywallsViews/Paywalls/: Paywall1View (StoreKit SubscriptionStoreView), Paywall2View (RevenueCat PaywallView), Paywall3View (fully hand-built), and the reusable PaywallProductView card.
  • ConfigConfig.Purchases in Utils/Config.swift: product IDs and the subscription group ID.
  • Launch wiringShipThatAppApp.configureRevenueCat() initialises RevenueCat from a gitignored key.

PurchaseManager is injected into the environment and read with @Environment(PurchaseManager.self). Because it's @Observable, any view that reads products or hasUnlockedPro re-renders automatically when a purchase completes.

PurchaseManager

PurchaseManager is a @MainActor @Observable final class. It loads products, runs purchases, and keeps the set of owned product IDs current — including transactions that complete while the app is backgrounded.

@MainActor
@Observable
final class PurchaseManager {
    private let productIds = Config.Purchases.productIds
    var products: [Product] = []
    var productsLoaded = false
    var purchasedProductIDs = Set<String>()

    init() {
        // Start listening for renewals/refunds the moment the manager exists.
        self.updates = self.observeTransactionUpdates()
    }
}

The Pro gate

A single computed property tells the rest of the app whether the user owns anything — wire your premium features to it:

var hasUnlockedPro: Bool {
    return !self.purchasedProductIDs.isEmpty
}

Loading products

loadProducts() fetches Product metadata (localized price, title, subscription period) from the App Store for the IDs in Config.Purchases, and no-ops if it already has them:

func loadProducts() async throws {
    guard !self.productsLoaded else { return }
    self.products = try await Product.products(for: self.productIds)
    self.productsLoaded = true
}

Buying — with verification

purchase(_:) runs the StoreKit 2 purchase and inspects the result. Only .verified transactions unlock content — unverified results (e.g. a jailbroken device) are ignored, pending purchases (Ask to Buy / SCA) wait, and cancellations are tracked for analytics:

func purchase(_ product: Product) async throws {
    let result = try await product.purchase()

    switch result {
    case let .success(.verified(transaction)):
        TelemetryManager.send("succesfullySubscribe")
        await transaction.finish()
        await self.updatePurchasedProducts()
    case .success(.unverified):
        break // failed StoreKit signature check — do not unlock
    case .pending:
        break // awaiting external approval
    case .userCancelled:
        TelemetryManager.send("cancelledSubscribe")
    @unknown default:
        break
    }
}

Entitlements and background renewals

updatePurchasedProducts() walks the current entitlements and reconciles the owned set, dropping anything that's been revoked or refunded:

func updatePurchasedProducts() async {
    for await result in Transaction.currentEntitlements {
        guard case let .verified(transaction) = result else { continue }
        if transaction.revocationDate == nil {
            self.purchasedProductIDs.insert(transaction.productID)
        } else {
            self.purchasedProductIDs.remove(transaction.productID)
        }
    }
}

A long-lived listener started in init() keeps this current even when the purchase happens outside the app (renewals, family sharing, refunds):

private func observeTransactionUpdates() -> Task<Void, Never> {
    Task(priority: .background) { [unowned self] in
        for await _ in Transaction.updates {
            await self.updatePurchasedProducts()
        }
    }
}

The task is stored as @ObservationIgnored nonisolated(unsafe) private var updates and cancelled in deinit, so it never leaks or fires after the manager is gone.

The Three Paywalls

ShipThatApp ships three paywall styles so you can match your app's design and test conversion — each is a self-contained View you can present from anywhere via AppRouter.

Paywall1View — StoreKit's native subscription store

The least code, fully native. SubscriptionStoreView renders Apple's subscription UI for your whole group, with localized pricing and Apple-managed purchase/restore:

import StoreKit

struct Paywall1View: View {
    var body: some View {
        SubscriptionStoreView(groupID: Config.Purchases.subGroupId)
            .subscriptionStoreControlStyle(.automatic)
    }
}

Swap .automatic for .buttons, .picker, or .prominentPicker to change the layout — the commented options are right there in the file.

Paywall2View — RevenueCat

One line, remotely configurable. PaywallView() renders the paywall you design in the RevenueCat dashboard, so you can change copy, pricing, and layout without an App Store release:

import RevenueCat
import RevenueCatUI

struct Paywall2View: View {
    var body: some View {
        PaywallView()
    }
}

Paywall3View — fully custom

When you need pixel control, Paywall3View is a hand-built layout that drives PurchaseManager directly. It loads products on appear, lets the user pick a plan, and fires analytics at each step:

struct Paywall3View: View {
    @Environment(PurchaseManager.self) private var purchaseManager
    @State private var selectedProduct: Product?

    var body: some View {
        VStack(spacing: 20) {
            // ... logo + title
            ForEach(purchaseManager.products) { product in
                PaywallProductView(
                    featured: product.subscription?.subscriptionPeriod.unit == .week,
                    selected: selectedProduct == product,
                    displayPrice: product.displayPrice,
                    displayName: product.displayName,
                    price: product.price,
                    unit: product.subscription!.subscriptionPeriod.unit,
                    value: product.subscription!.subscriptionPeriod.value,
                    priceFormatStyle: product.priceFormatStyle
                )
                .onTapGesture { withAnimation { selectedProduct = product } }
            }

            Button("Subscribe") {
                Task {
                    do {
                        if let selectedProduct {
                            TelemetryManager.send("clickedSubscribe")
                            try await purchaseManager.purchase(selectedProduct)
                        }
                    } catch {
                        TelemetryManager.send("purchaseError", with: ["error": error.localizedDescription])
                    }
                }
            }
        }
        .task { try? await purchaseManager.loadProducts() }
    }
}

PaywallProductView is the reusable plan card. It takes a Product's display fields and computes a normalized 12-month equivalent cost from the subscription period — so a weekly and a monthly plan can be compared apples-to-apples on screen.

Restore purchases

Paywall3View restores with StoreKit's AppStore.sync() and reports it through analytics — Apple requires a visible restore path for non-consumables and subscriptions:

Button("Restore Purchases") {
    Task {
        do {
            TelemetryManager.send("clickedRestorePurchase")
            try await AppStore.sync()
        } catch {
            TelemetryManager.send("restoreError", with: ["error": error.localizedDescription])
        }
    }
}

How to Use It

1. Configure products. Set your IDs and subscription group in Config.Purchases:

enum Purchases {
    static let productIds = ["sta_999_1m_1w0", "sta_499_1w"]
    static let subGroupId = "21397077"
    static let minAppRunsBeforeReviewReq = 4
}

These must match App Store Connect (and your RevenueCat project, if you use Paywall2View).

2. Add your RevenueCat key. It's read from a gitignored Config.xcconfig (RC_API_KEY) and configured at launch — never hardcoded:

private func configureRevenueCat() {
    guard let revenueCatApiString = Bundle.main.infoDictionary?["RC_API_KEY"] as? String else { return }
    Purchases.logLevel = .debug
    Purchases.configure(withAPIKey: revenueCatApiString)
}

3. Present a paywall and gate features. Route to paywall1/paywall2/paywall3 via AppRouter, then read the entitlement anywhere:

@Environment(PurchaseManager.self) private var purchaseManager

if purchaseManager.hasUnlockedPro {
    PremiumFeatureView()
} else {
    Button("Unlock Pro") { router.navigateTo(.paywall1) }
}

Customize / Extend It

  • Per-feature gates instead of a global flag. hasUnlockedPro is a simple "owns anything" check. For tiered access, query purchasedProductIDs.contains("your_pro_id") directly.
  • Lifetime / consumable products. The verified-transaction path in purchase(_:) already handles non-subscriptions — add the IDs to Config.Purchases.productIds and surface them in a paywall. For consumables you'll call transaction.finish() after granting the item (already done) and skip the entitlement gate.
  • Build your own paywall. Copy Paywall3View and restyle PaywallProductView; everything reads from purchaseManager.products, so the data layer is done.
  • Switch fully to RevenueCat. Lean on Paywall2View and RevenueCat's customerInfo entitlements if you want server-side receipt validation and cross-platform sync — PurchaseManager and RevenueCat can coexist since both observe StoreKit.

Test with a StoreKit configuration file

Set Purchases.logLevel = .debug (already on) and run against a local StoreKit configuration or a Sandbox account before going live. Verify the full loop — purchase, hasUnlockedPro flips, force-quit, relaunch, and confirm updatePurchasedProducts() restores the entitlement from Transaction.currentEntitlements.

  • Configurations — where RC_API_KEY and the product IDs live.
  • PurchaseManager — the manager-level reference.
  • Analytics — the purchase funnel events (clickedSubscribe, succesfullySubscribe, purchaseError…) emitted above.
  • Review Requests — prompt for a rating after a user hits value, gated by minAppRunsBeforeReviewReq. </content>
Previous
Authentication