Build an In-App Purchase Paywall with StoreKit 2: Products, Purchases, and Entitlements


Every developer eventually faces the same question: how do you charge for the thing you built? Whether it is premium content, an ad-free experience, or exclusive features, in-app purchases are how most iOS apps make money. But wiring up StoreKit has historically felt like debugging code in the dark — confusing receipt validation, opaque error codes, and no way to test without uploading to App Store Connect. StoreKit 2 changed all of that.

In this tutorial, you will build Pixar Movie Vault — a premium movie collection app where users browse a catalog of Pixar films for free but unlock exclusive behind-the-scenes content, director commentary, and a “Vault Collection” through monthly or yearly subscriptions. Along the way, you will learn how to configure products using a StoreKit configuration file, fetch products with Product.products(for:), handle purchases with async/await, verify transactions, check entitlements, build a polished paywall UI, and test everything in Xcode’s sandbox — no App Store Connect required.

Prerequisites

Contents

Getting Started

Start by creating a new Xcode project.

  1. Open Xcode and select File > New > Project.
  2. Choose the App template under iOS and click Next.
  3. Set the product name to PixarMovieVault.
  4. Ensure Interface is set to SwiftUI and Language to Swift.
  5. Choose a location and click Create.

No external packages or special capabilities are needed. StoreKit 2 is part of the iOS SDK, and we will use a local StoreKit configuration file for testing — one of the biggest quality-of-life improvements in StoreKit 2. No need to set up products in App Store Connect just to test your paywall.

Step 1: Configuring StoreKit Products

Before writing any code, we need to define the products our app will sell. StoreKit configuration files let you define products locally in Xcode and test the full purchase flow on the simulator without touching App Store Connect.

  1. In Xcode, select File > New > File.
  2. Search for StoreKit Configuration File and select it.
  3. Name it PixarVaultProducts.storekit and save it to the project root.
  4. In the editor, click the + button at the bottom-left and select Add Auto-Renewable Subscription.

Configure the subscription group and products as follows:

Subscription Group: pixar_vault_access

Product 1 — Monthly Subscription:

  • Reference Name: Pixar Vault Monthly
  • Product ID: com.cocoabytes.pixarvault.monthly
  • Price: $4.99
  • Subscription Duration: 1 Month
  • Display Name: Monthly Vault Pass
  • Description: Unlock the full Pixar Movie Vault with monthly access to behind-the-scenes content and director commentary.

Product 2 — Yearly Subscription:

  • Reference Name: Pixar Vault Yearly
  • Product ID: com.cocoabytes.pixarvault.yearly
  • Price: $39.99
  • Subscription Duration: 1 Year
  • Display Name: Yearly Vault Pass
  • Description: Save 33% with yearly access to the complete Pixar Movie Vault, including all exclusive content and early access to new releases.

Now add a non-consumable product. Click + and select Add Non-Consumable In-App Purchase:

Product 3 — Lifetime Unlock:

  • Reference Name: Pixar Vault Lifetime
  • Product ID: com.cocoabytes.pixarvault.lifetime
  • Price: $99.99
  • Display Name: Lifetime Vault Pass
  • Description: One purchase. Infinite adventures. Unlock the Pixar Movie Vault forever.

Finally, configure Xcode to use this file for testing:

  1. Go to Product > Scheme > Edit Scheme (or press Cmd+Shift+<).
  2. Select Run in the left sidebar.
  3. Go to the Options tab.
  4. Under StoreKit Configuration, select PixarVaultProducts.storekit.

Tip: The StoreKit configuration file lives entirely in your project. You can commit it to version control and every team member gets the same test products. No App Store Connect credentials needed during development — Buzz would call it a “test flight” without the TestFlight.

Step 2: Building the Movie Data Model

Let us define the Pixar movie data that powers our catalog. Free users can see basic movie info, while premium subscribers get access to behind-the-scenes content and director commentary.

Create a new file at Models/PixarMovie.swift:

import Foundation

struct PixarMovie: Identifiable {
    let id = UUID()
    let title: String
    let year: Int
    let director: String
    let synopsis: String
    let rating: Double
    let posterEmoji: String // Using emoji as poster placeholder
    let behindTheScenes: String // Premium content
    let directorCommentary: String // Premium content
    let vaultExclusive: Bool // Some movies are vault-only
}

Now create the movie catalog at Models/MovieCatalog.swift:

import Foundation

struct MovieCatalog {
    static let movies: [PixarMovie] = [
        PixarMovie(
            title: "Up",
            year: 2009,
            director: "Pete Docter",
            synopsis: "78-year-old Carl Fredricksen ties thousands of balloons to his house and flies to South America, accidentally bringing along an 8-year-old Wilderness Explorer named Russell.",
            rating: 8.3,
            posterEmoji: "🎈",
            behindTheScenes: "The opening montage showing Carl and Ellie's life together was originally much longer. Pete Docter and his team spent over a year refining it down to the 4-minute wordless sequence that became one of cinema's most emotional openings. The sequence was animated by a single animator over the course of six months.",
            directorCommentary: "Pete Docter: 'The whole idea of Up started with a desire to float away from the world. We all have that feeling sometimes. The house represents Carl's attachment to the past, and letting go of it is really the heart of the story.'",
            vaultExclusive: false
        ),
        PixarMovie(
            title: "Toy Story",
            year: 1995,
            director: "John Lasseter",
            synopsis: "A cowboy doll named Woody is Andy's favorite toy until Buzz Lightyear, a space ranger action figure, arrives and threatens his position.",
            rating: 8.3,
            posterEmoji: "🤠",
            behindTheScenes: "The original story reel for Toy Story, shown to Disney executives on November 19, 1993 -- known internally as 'Black Friday' -- was a disaster. Woody was a sarcastic bully. Disney shut down production. The team rewrote the entire script in two weeks, transforming Woody into the lovable, insecure cowboy we know today.",
            directorCommentary: "John Lasseter: 'We always knew the toys would be jealous of each other. That's a universal emotion. But finding Woody's voice -- not literally, Tom Hanks nailed that -- but his emotional center, took us three complete rewrites.'",
            vaultExclusive: false
        ),
        PixarMovie(
            title: "Finding Nemo",
            year: 2003,
            director: "Andrew Stanton",
            synopsis: "After his son Nemo is captured by a diver, the timid clownfish Marlin sets out on a journey across the ocean, meeting the forgetful blue tang Dory along the way.",
            rating: 8.2,
            posterEmoji: "🐠",
            behindTheScenes: "The Pixar team took scuba diving lessons and visited aquariums worldwide to study underwater lighting. They developed entirely new rendering technology to simulate the way light refracts and scatters through water. Each frame of underwater footage required 4 hours to render.",
            directorCommentary: "Andrew Stanton: 'I came up with the idea while watching my own son at the park. I was so overprotective that I realized I was the one with the problem, not him. That became Marlin's journey.'",
            vaultExclusive: false
        ),
        PixarMovie(
            title: "The Incredibles",
            year: 2004,
            director: "Brad Bird",
            synopsis: "A family of undercover superheroes tries to live a quiet suburban life, but when a mysterious villain threatens the world, they are forced back into action.",
            rating: 8.0,
            posterEmoji: "🦸",
            behindTheScenes: "Brad Bird pushed Pixar's technology further than ever before, requiring the team to simulate human skin, hair, muscles, and clothing for the first time. The character of Edna Mode was voiced by Bird himself when they couldn't find a voice actor who matched his scratch recording.",
            directorCommentary: "Brad Bird: 'Every superhero family has the same problems as a regular family. They just have to deal with them while also saving the world. That contrast is where all the comedy comes from.'",
            vaultExclusive: true
        ),
        PixarMovie(
            title: "Ratatouille",
            year: 2007,
            director: "Brad Bird",
            synopsis: "A rat named Remy dreams of becoming a chef in Paris and forms an unlikely alliance with a young garbage boy to achieve his culinary ambitions.",
            rating: 8.1,
            posterEmoji: "🐀",
            behindTheScenes: "Pixar sent teams to Paris to dine at Michelin-starred restaurants, study kitchen workflows, and photograph everything. Chef Thomas Keller consulted on the film, and the final dish Remy prepares -- the ratatouille -- was designed by Keller himself as a confit byaldi, a refined version of the peasant dish.",
            directorCommentary: "Brad Bird: 'Anyone can cook doesn't mean everyone will be a great chef. It means great talent can come from anywhere. That's true in cooking, in filmmaking, in everything.'",
            vaultExclusive: true
        ),
        PixarMovie(
            title: "WALL-E",
            year: 2008,
            director: "Andrew Stanton",
            synopsis: "A small waste-collecting robot on an abandoned Earth falls in love with a sleek probe robot and follows her across the galaxy.",
            rating: 8.4,
            posterEmoji: "🤖",
            behindTheScenes: "The first 40 minutes of WALL-E contain almost no dialogue, making it Pixar's most ambitious storytelling experiment. Sound designer Ben Burtt, famous for creating R2-D2's voice, crafted WALL-E's sounds using a combination of real motors, his own voice processed through a synthesizer, and recordings of a self-propelled artillery gun.",
            directorCommentary: "Andrew Stanton: 'I wanted to make a silent film. Everyone told me it was crazy. But I knew if we could make you fall in love with a box on wheels in the first five minutes, the rest of the movie would work.'",
            vaultExclusive: true
        )
    ]
}

Free users see the title, year, director, synopsis, rating, and poster emoji. The behindTheScenes and directorCommentary fields are premium content that only subscribers can access. The vaultExclusive flag marks movies that do not appear at all for free users — they are visible only to subscribers.

Checkpoint: The project should compile cleanly. You now have two model files in Models/ and a StoreKit configuration file at the project root. No UI yet, but the data layer and product definitions are ready.

Step 3: Creating the Store Manager

The StoreManager is the heart of our in-app purchase system. It handles product fetching, purchases, transaction verification, and entitlement state. We use the @Observable macro for clean SwiftUI integration.

Create a new file at Store/StoreManager.swift:

import StoreKit

@Observable
class StoreManager {

    // MARK: - Product IDs

    static let monthlyID = "com.cocoabytes.pixarvault.monthly"
    static let yearlyID = "com.cocoabytes.pixarvault.yearly"
    static let lifetimeID = "com.cocoabytes.pixarvault.lifetime"

    private static let subscriptionIDs: Set<String> = [
        monthlyID, yearlyID
    ]
    private static let allProductIDs: Set<String> = [
        monthlyID, yearlyID, lifetimeID
    ]

    // MARK: - Published State

    var subscriptions: [Product] = []
    var lifetimeProduct: Product?
    var purchasedProductIDs: Set<String> = []
    var isLoading = false
    var errorMessage: String?

    // MARK: - Entitlement Computed Properties

    var hasVaultAccess: Bool {
        !purchasedProductIDs.isEmpty
    }

    var currentSubscriptionName: String? {
        if purchasedProductIDs.contains(Self.lifetimeID) {
            return "Lifetime Vault Pass"
        }
        if purchasedProductIDs.contains(Self.yearlyID) {
            return "Yearly Vault Pass"
        }
        if purchasedProductIDs.contains(Self.monthlyID) {
            return "Monthly Vault Pass"
        }
        return nil
    }

    // MARK: - Transaction Listener

    private var transactionListener: Task<Void, Error>?

    init() {
        transactionListener = listenForTransactions()
    }

    deinit {
        transactionListener?.cancel()
    }
}

This first part of the StoreManager sets up the product ID constants, observable state properties, and computed entitlement properties. The hasVaultAccess computed property is the single source of truth for whether the user has paid — any purchased product (monthly, yearly, or lifetime) unlocks the vault. The transactionListener task, which we will implement shortly, monitors for transactions that happen outside the app, such as subscription renewals, family sharing grants, or purchases completed on another device.

Apple Docs: Product — StoreKit

Step 4: Fetching Products from the App Store

Now let us add the method that fetches products. StoreKit 2’s Product.products(for:) is an async function that returns an array of Product values matching your product IDs.

Add the following method to StoreManager in Store/StoreManager.swift:

// MARK: - Fetch Products

func fetchProducts() async {
    isLoading = true
    errorMessage = nil

    do {
        let products = try await Product.products(
            for: Self.allProductIDs
        )

        // Separate subscriptions from one-time purchases
        subscriptions = products
            .filter { $0.type == .autoRenewable }
            .sorted { $0.price < $1.price }

        lifetimeProduct = products
            .first { $0.type == .nonConsumable }

    } catch {
        errorMessage = "Failed to load products: \(error.localizedDescription)"
    }

    isLoading = false
}

The method fetches all three product IDs in a single call, then separates them by type. Auto-renewable subscriptions go into the subscriptions array sorted by price (monthly before yearly), and the non-consumable lifetime purchase is stored separately. This separation makes it easy to display them in different sections of the paywall.

Note: Product.products(for:) requires either a StoreKit configuration file (for local testing) or products configured in App Store Connect (for production). If a product ID does not match any configured product, it is silently excluded from the results — it does not throw an error. Always verify the count of returned products matches your expectations.

Checkpoint: Add a temporary call to fetchProducts() in your app’s entry point or a test view. Set a breakpoint after the products line and verify that the debugger shows three products: the monthly subscription, yearly subscription, and lifetime purchase. If the array is empty, double-check that your StoreKit configuration file is selected in the scheme’s Run options.

Step 5: Building the Movie Catalog View

Let us build the main catalog view that free users see. It shows all non-exclusive movies with basic information and teases the premium content behind a lock icon.

Create Views/MovieCatalogView.swift:

import SwiftUI

struct MovieCatalogView: View {
    let storeManager: StoreManager
    @State private var showingPaywall = false

    var visibleMovies: [PixarMovie] {
        if storeManager.hasVaultAccess {
            return MovieCatalog.movies
        }
        return MovieCatalog.movies.filter { !$0.vaultExclusive }
    }

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 0) {
                    // Subscription status (for active subscribers)
                    if storeManager.hasVaultAccess {
                        SubscriptionStatusView(
                            storeManager: storeManager
                        )
                        .padding()
                    }

                    // Premium banner for free users
                    if !storeManager.hasVaultAccess {
                        PremiumBanner {
                            showingPaywall = true
                        }
                    }

                    // Movie grid
                    LazyVStack(spacing: 16) {
                        ForEach(visibleMovies) { movie in
                            MovieCardView(
                                movie: movie,
                                hasVaultAccess: storeManager.hasVaultAccess,
                                onPremiumTap: {
                                    showingPaywall = true
                                }
                            )
                        }

                        // Vault-exclusive teaser for free users
                        if !storeManager.hasVaultAccess {
                            VaultExclusiveTeaser(
                                lockedCount: MovieCatalog.movies
                                    .filter(\.vaultExclusive).count
                            ) {
                                showingPaywall = true
                            }
                        }
                    }
                    .padding()
                }
            }
            .navigationTitle("Pixar Movie Vault")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    if storeManager.hasVaultAccess {
                        Image(systemName: "checkmark.seal.fill")
                            .foregroundStyle(.yellow)
                    } else {
                        Button {
                            showingPaywall = true
                        } label: {
                            Image(systemName: "lock.fill")
                                .foregroundStyle(.orange)
                        }
                    }
                }
            }
            .sheet(isPresented: $showingPaywall) {
                PaywallView(storeManager: storeManager)
            }
        }
    }
}

Now create the movie card at Views/MovieCardView.swift:

import SwiftUI

struct MovieCardView: View {
    let movie: PixarMovie
    let hasVaultAccess: Bool
    let onPremiumTap: () -> Void

    @State private var isExpanded = false

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            // Header
            HStack(alignment: .top) {
                Text(movie.posterEmoji)
                    .font(.system(size: 50))

                VStack(alignment: .leading, spacing: 4) {
                    Text(movie.title)
                        .font(.title3)
                        .fontWeight(.bold)

                    HStack(spacing: 8) {
                        Text(String(movie.year))
                            .font(.caption)
                            .foregroundStyle(.secondary)

                        Text("·")
                            .foregroundStyle(.secondary)

                        Text("Dir. \(movie.director)")
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }

                    HStack(spacing: 2) {
                        Image(systemName: "star.fill")
                            .foregroundStyle(.yellow)
                            .font(.caption)
                        Text(String(format: "%.1f", movie.rating))
                            .font(.caption)
                            .fontWeight(.semibold)
                    }
                }

                Spacer()

                if movie.vaultExclusive {
                    Text("VAULT")
                        .font(.caption2)
                        .fontWeight(.bold)
                        .padding(.horizontal, 6)
                        .padding(.vertical, 3)
                        .background(.yellow.opacity(0.2))
                        .foregroundStyle(.yellow)
                        .clipShape(Capsule())
                }
            }

            // Synopsis
            Text(movie.synopsis)
                .font(.subheadline)
                .foregroundStyle(.secondary)
                .lineLimit(isExpanded ? nil : 2)

            // Premium content section
            if hasVaultAccess {
                premiumContentSection
            } else {
                lockedPremiumTeaser
            }
        }
        .padding()
        .background(Color(.systemBackground))
        .clipShape(RoundedRectangle(cornerRadius: 16))
        .shadow(color: .black.opacity(0.08), radius: 8, y: 2)
    }

    @ViewBuilder
    private var premiumContentSection: some View {
        if isExpanded {
            VStack(alignment: .leading, spacing: 12) {
                Divider()

                VStack(alignment: .leading, spacing: 6) {
                    Label("Behind the Scenes", systemImage: "film")
                        .font(.subheadline)
                        .fontWeight(.semibold)
                        .foregroundStyle(.orange)

                    Text(movie.behindTheScenes)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }

                VStack(alignment: .leading, spacing: 6) {
                    Label(
                        "Director Commentary",
                        systemImage: "quote.bubble"
                    )
                    .font(.subheadline)
                    .fontWeight(.semibold)
                    .foregroundStyle(.purple)

                    Text(movie.directorCommentary)
                        .font(.caption)
                        .italic()
                        .foregroundStyle(.secondary)
                }
            }
        }

        Button {
            withAnimation(.easeInOut(duration: 0.3)) {
                isExpanded.toggle()
            }
        } label: {
            Text(isExpanded ? "Show Less" : "Show Premium Content")
                .font(.caption)
                .fontWeight(.semibold)
                .foregroundStyle(.blue)
        }
    }

    private var lockedPremiumTeaser: some View {
        Button(action: onPremiumTap) {
            HStack {
                Image(systemName: "lock.fill")
                    .font(.caption)
                Text("Unlock behind-the-scenes & commentary")
                    .font(.caption)
            }
            .foregroundStyle(.orange)
            .padding(.vertical, 8)
            .padding(.horizontal, 12)
            .background(.orange.opacity(0.1))
            .clipShape(Capsule())
        }
    }
}

Now create the premium banner and vault teaser. Create Views/PremiumBanner.swift:

import SwiftUI

struct PremiumBanner: View {
    let onTap: () -> Void

    var body: some View {
        Button(action: onTap) {
            HStack(spacing: 12) {
                Image(systemName: "sparkles")
                    .font(.title3)
                    .foregroundStyle(.yellow)

                VStack(alignment: .leading, spacing: 2) {
                    Text("Unlock the Vault")
                        .font(.subheadline)
                        .fontWeight(.bold)
                        .foregroundStyle(.white)
                    Text("Get behind-the-scenes content & exclusive movies")
                        .font(.caption)
                        .foregroundStyle(.white.opacity(0.8))
                }

                Spacer()

                Image(systemName: "chevron.right")
                    .foregroundStyle(.white.opacity(0.6))
            }
            .padding()
            .background(
                LinearGradient(
                    colors: [.blue, .purple],
                    startPoint: .leading,
                    endPoint: .trailing
                )
            )
        }
    }
}

struct VaultExclusiveTeaser: View {
    let lockedCount: Int
    let onTap: () -> Void

    var body: some View {
        Button(action: onTap) {
            VStack(spacing: 12) {
                Image(systemName: "lock.shield.fill")
                    .font(.largeTitle)
                    .foregroundStyle(.orange)

                Text("\(lockedCount) Vault-Exclusive Movies")
                    .font(.headline)

                Text("Subscribe to unlock The Incredibles, Ratatouille, WALL-E, and more")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                    .multilineTextAlignment(.center)

                Text("View Plans")
                    .font(.subheadline)
                    .fontWeight(.semibold)
                    .foregroundStyle(.white)
                    .padding(.horizontal, 24)
                    .padding(.vertical, 10)
                    .background(.orange)
                    .clipShape(Capsule())
            }
            .padding(24)
            .frame(maxWidth: .infinity)
            .background(Color(.systemGray6))
            .clipShape(RoundedRectangle(cornerRadius: 16))
        }
        .buttonStyle(.plain)
    }
}

The MovieCatalogView references PaywallView (Step 6) and SubscriptionStatusView (Step 11), which we have not created yet. Add these temporary stubs so the project compiles. We will replace them with full implementations in later steps.

Create Views/PaywallView.swift:

import SwiftUI

// Placeholder -- replaced in Step 6
struct PaywallView: View {
    let storeManager: StoreManager

    var body: some View {
        Text("Paywall coming soon")
    }
}

Create Views/SubscriptionStatusView.swift:

import SwiftUI

// Placeholder -- replaced in Step 11
struct SubscriptionStatusView: View {
    let storeManager: StoreManager

    var body: some View {
        EmptyView()
    }
}

Checkpoint: Update ContentView.swift to show the catalog:

import SwiftUI

struct ContentView: View {
    @State private var storeManager = StoreManager()

    var body: some View {
        MovieCatalogView(storeManager: storeManager)
            .task {
                await storeManager.fetchProducts()
            }
    }
}

Build and run. You should see a catalog with Up, Toy Story, and Finding Nemo as free movies, each showing a poster emoji, title, year, director, and rating. A blue-to-purple gradient banner at the top says “Unlock the Vault.” Below the free movies, a locked section teases “3 Vault-Exclusive Movies.” Tapping any lock button shows an empty sheet — we will build the paywall next.

Step 6: Building the Paywall View

The paywall is where the money is made. A great paywall communicates value clearly, makes the purchase action obvious, and does not feel pushy. Think of it like the Emporium at Disneyland — you want people to walk in and feel excited about what they can get, not pressured.

Replace the placeholder in Views/PaywallView.swift with the full implementation:

import SwiftUI
import StoreKit

struct PaywallView: View {
    let storeManager: StoreManager
    @Environment(\.dismiss) private var dismiss
    @State private var selectedProduct: Product?
    @State private var isPurchasing = false

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 24) {
                    heroSection
                    featureList
                    subscriptionOptions
                    purchaseButton
                    legalSection
                }
            }
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        dismiss()
                    } label: {
                        Image(systemName: "xmark.circle.fill")
                            .foregroundStyle(.secondary)
                    }
                }
            }
            .onAppear {
                // Pre-select the yearly plan (best value)
                selectedProduct = storeManager.subscriptions.last
            }
        }
    }

    // MARK: - Hero Section

    private var heroSection: some View {
        VStack(spacing: 12) {
            Image(systemName: "film.stack")
                .font(.system(size: 60))
                .foregroundStyle(.yellow)
                .padding(.top, 20)

            Text("Pixar Movie Vault")
                .font(.largeTitle)
                .fontWeight(.bold)

            Text("Unlock the complete collection")
                .font(.title3)
                .foregroundStyle(.secondary)
        }
    }

    // MARK: - Feature List

    private var featureList: some View {
        VStack(alignment: .leading, spacing: 12) {
            FeatureRow(
                icon: "film",
                color: .blue,
                title: "Exclusive Movies",
                description: "Access vault-only titles like The Incredibles, Ratatouille, and WALL-E"
            )
            FeatureRow(
                icon: "camera.metering.spot",
                color: .orange,
                title: "Behind the Scenes",
                description: "Production secrets and making-of stories for every film"
            )
            FeatureRow(
                icon: "quote.bubble",
                color: .purple,
                title: "Director Commentary",
                description: "Exclusive insights from Pete Docter, Brad Bird, Andrew Stanton, and more"
            )
            FeatureRow(
                icon: "star.circle",
                color: .yellow,
                title: "Early Access",
                description: "Be the first to see new additions to the Vault"
            )
        }
        .padding(.horizontal)
    }

    // MARK: - Subscription Options

    private var subscriptionOptions: some View {
        VStack(spacing: 12) {
            Text("Choose Your Plan")
                .font(.headline)

            ForEach(
                storeManager.subscriptions,
                id: \.id
            ) { product in
                SubscriptionOptionView(
                    product: product,
                    isSelected: selectedProduct?.id == product.id
                ) {
                    selectedProduct = product
                }
            }

            if let lifetime = storeManager.lifetimeProduct {
                LifetimeOptionView(
                    product: lifetime,
                    isSelected: selectedProduct?.id == lifetime.id
                ) {
                    selectedProduct = lifetime
                }
            }
        }
        .padding(.horizontal)
    }

    // MARK: - Purchase Button

    private var purchaseButton: some View {
        Button {
            guard let product = selectedProduct else { return }
            Task {
                isPurchasing = true
                await purchase(product)
                isPurchasing = false
            }
        } label: {
            Group {
                if isPurchasing {
                    ProgressView()
                        .tint(.white)
                } else {
                    Text(purchaseButtonTitle)
                }
            }
            .font(.headline)
            .foregroundStyle(.white)
            .frame(maxWidth: .infinity)
            .padding()
            .background(
                selectedProduct != nil
                    ? Color.blue
                    : Color.gray
            )
            .clipShape(RoundedRectangle(cornerRadius: 14))
        }
        .disabled(selectedProduct == nil || isPurchasing)
        .padding(.horizontal)
    }

    // MARK: - Legal Section

    private var legalSection: some View {
        VStack(spacing: 8) {
            Text("Subscriptions automatically renew unless cancelled at least 24 hours before the end of the current period.")
                .font(.caption2)
                .foregroundStyle(.secondary)
                .multilineTextAlignment(.center)

            HStack(spacing: 16) {
                Button("Terms of Use") { /* Link to terms */ }
                Button("Privacy Policy") { /* Link to privacy */ }
                Button("Restore Purchases") {
                    Task {
                        await restorePurchases()
                    }
                }
            }
            .font(.caption)
        }
        .padding(.horizontal)
        .padding(.bottom, 20)
    }

    // MARK: - Helpers

    private var purchaseButtonTitle: String {
        guard let product = selectedProduct else {
            return "Select a Plan"
        }
        if product.type == .nonConsumable {
            return "Purchase for \(product.displayPrice)"
        }
        return "Subscribe for \(product.displayPrice)"
    }

    private func purchase(_ product: Product) async {
        await storeManager.purchase(product)
        if storeManager.hasVaultAccess {
            dismiss()
        }
    }

    private func restorePurchases() async {
        await storeManager.restorePurchases()
        if storeManager.hasVaultAccess {
            dismiss()
        }
    }
}

Now create the subscription option views. Create Views/SubscriptionOptionView.swift:

import SwiftUI
import StoreKit

struct SubscriptionOptionView: View {
    let product: Product
    let isSelected: Bool
    let onSelect: () -> Void

    private var isYearly: Bool {
        product.id == StoreManager.yearlyID
    }

    var body: some View {
        Button(action: onSelect) {
            HStack {
                VStack(alignment: .leading, spacing: 4) {
                    HStack {
                        Text(product.displayName)
                            .font(.subheadline)
                            .fontWeight(.semibold)

                        if isYearly {
                            Text("BEST VALUE")
                                .font(.caption2)
                                .fontWeight(.bold)
                                .padding(.horizontal, 6)
                                .padding(.vertical, 2)
                                .background(.green)
                                .foregroundStyle(.white)
                                .clipShape(Capsule())
                        }
                    }

                    Text(product.description)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                        .lineLimit(2)
                }

                Spacer()

                VStack(alignment: .trailing, spacing: 2) {
                    Text(product.displayPrice)
                        .font(.subheadline)
                        .fontWeight(.bold)
                    Text(isYearly ? "/year" : "/month")
                        .font(.caption2)
                        .foregroundStyle(.secondary)
                }
            }
            .padding()
            .background(
                RoundedRectangle(cornerRadius: 12)
                    .stroke(
                        isSelected ? Color.blue : Color.gray.opacity(0.3),
                        lineWidth: isSelected ? 2 : 1
                    )
                    .background(
                        RoundedRectangle(cornerRadius: 12)
                            .fill(
                                isSelected
                                    ? Color.blue.opacity(0.05)
                                    : Color.clear
                            )
                    )
            )
        }
        .buttonStyle(.plain)
    }
}

struct LifetimeOptionView: View {
    let product: Product
    let isSelected: Bool
    let onSelect: () -> Void

    var body: some View {
        Button(action: onSelect) {
            HStack {
                VStack(alignment: .leading, spacing: 4) {
                    HStack {
                        Text(product.displayName)
                            .font(.subheadline)
                            .fontWeight(.semibold)

                        Text("ONE TIME")
                            .font(.caption2)
                            .fontWeight(.bold)
                            .padding(.horizontal, 6)
                            .padding(.vertical, 2)
                            .background(.orange)
                            .foregroundStyle(.white)
                            .clipShape(Capsule())
                    }

                    Text(product.description)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                        .lineLimit(2)
                }

                Spacer()

                Text(product.displayPrice)
                    .font(.subheadline)
                    .fontWeight(.bold)
            }
            .padding()
            .background(
                RoundedRectangle(cornerRadius: 12)
                    .stroke(
                        isSelected
                            ? Color.orange
                            : Color.gray.opacity(0.3),
                        lineWidth: isSelected ? 2 : 1
                    )
                    .background(
                        RoundedRectangle(cornerRadius: 12)
                            .fill(
                                isSelected
                                    ? Color.orange.opacity(0.05)
                                    : Color.clear
                            )
                    )
            )
        }
        .buttonStyle(.plain)
    }
}

struct FeatureRow: View {
    let icon: String
    let color: Color
    let title: String
    let description: String

    var body: some View {
        HStack(spacing: 12) {
            Image(systemName: icon)
                .font(.title3)
                .foregroundStyle(color)
                .frame(width: 36, height: 36)
                .background(color.opacity(0.12))
                .clipShape(Circle())

            VStack(alignment: .leading, spacing: 2) {
                Text(title)
                    .font(.subheadline)
                    .fontWeight(.semibold)
                Text(description)
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
        }
    }
}

Warning: Apple requires every paywall to include a “Restore Purchases” option. Failing to include one is a common rejection reason during App Review. Our paywall includes it alongside the Terms of Use and Privacy Policy links at the bottom.

Step 7: Handling the Purchase Flow

Now let us implement the actual purchase method. StoreKit 2’s Product.purchase() returns a Product.PurchaseResult that tells you whether the purchase succeeded, the user cancelled, or it is pending (waiting for parental approval, for example).

Add the following method to StoreManager in Store/StoreManager.swift:

// MARK: - Purchase

func purchase(_ product: Product) async {
    isLoading = true
    errorMessage = nil

    do {
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            // Verify the transaction
            let transaction = try checkVerified(verification)

            // Update entitlements
            purchasedProductIDs.insert(transaction.productID)

            // Always finish a transaction
            await transaction.finish()

        case .userCancelled:
            // User tapped Cancel -- not an error
            break

        case .pending:
            // Transaction is pending (e.g., Ask to Buy)
            errorMessage = "Purchase is pending approval."

        @unknown default:
            errorMessage = "An unexpected purchase result occurred."
        }
    } catch {
        errorMessage = "Purchase failed: \(error.localizedDescription)"
    }

    isLoading = false
}

// MARK: - Verification Helper

private func checkVerified<T>(
    _ result: VerificationResult<T>
) throws -> T {
    switch result {
    case .unverified(_, let error):
        // StoreKit could not verify the transaction
        throw error
    case .verified(let safe):
        return safe
    }
}

The purchase flow follows three stages: initiate the purchase, verify the transaction, and finish it. The checkVerified helper unwraps the VerificationResult — StoreKit 2 automatically performs on-device receipt verification using JWS (JSON Web Signature), so you no longer need a server to validate receipts. If the verification fails, the transaction comes back as .unverified with an error explaining why.

The .pending case handles the “Ask to Buy” flow for family accounts. When a child initiates a purchase, it goes to the parent for approval. Your app receives the result later through the transaction listener.

Tip: Always call transaction.finish() after processing a successful purchase. An unfinished transaction stays in the transaction queue and StoreKit will keep delivering it until you finish it. Think of it like Woody saying “You are a toy!” — you have to acknowledge the reality before you can move on.

Apple Docs: Product.purchase(options:) — StoreKit

Step 8: Verifying Transactions and Checking Entitlements

At launch, the app needs to check what the user has already purchased. This handles returning subscribers, users who purchased on another device, and restored purchases.

Add the following methods to StoreManager:

// MARK: - Update Entitlements

func updatePurchasedProducts() async {
    var purchased: Set<String> = []

    // Check all current entitlements
    for await result in Transaction.currentEntitlements {
        if let transaction = try? checkVerified(result) {
            purchased.insert(transaction.productID)
        }
    }

    purchasedProductIDs = purchased
}

// MARK: - Restore Purchases

func restorePurchases() async {
    isLoading = true
    errorMessage = nil

    // Sync with the App Store to get latest transactions
    try? await AppStore.sync()

    await updatePurchasedProducts()

    if purchasedProductIDs.isEmpty {
        errorMessage = "No purchases to restore."
    }

    isLoading = false
}

Transaction.currentEntitlements is an AsyncSequence that yields every transaction the user is currently entitled to. For auto-renewable subscriptions, it only yields the latest transaction if the subscription is active. For non-consumables, it yields the purchase transaction forever. This single API replaces the old receipt parsing dance entirely.

The restorePurchases method calls AppStore.sync() to force a sync with Apple’s servers, then re-checks entitlements. This is what the “Restore Purchases” button on the paywall triggers.

Step 9: Listening for Transaction Updates

Transactions can arrive at any time — subscription renewals, Ask to Buy approvals, refunds, or revocations. The transaction listener runs as a background task for the lifetime of the StoreManager.

Add this method to StoreManager:

// MARK: - Transaction Listener

private func listenForTransactions() -> Task<Void, Error> {
    Task.detached {
        for await result in Transaction.updates {
            if let transaction = try? self.checkVerified(result) {
                // Update entitlements on any transaction change
                await self.updatePurchasedProducts()

                // Finish the transaction
                await transaction.finish()
            }
        }
    }
}

Transaction.updates is an AsyncSequence that never terminates. It yields new transactions as they arrive. We use Task.detached because this listener must keep running independently for the entire app session. Each time a transaction arrives, we re-check all entitlements and finish the transaction.

Checkpoint: Your StoreManager is now feature-complete. It can fetch products, handle purchases, verify transactions, check entitlements, restore purchases, and listen for updates. Build the project to make sure everything compiles. In the next steps, we will wire it all together and see the full flow in action.

Step 10: Unlocking Premium Content

Now let us update ContentView.swift to load products and check entitlements at launch, creating the complete flow from catalog to paywall to premium content.

Update ContentView.swift:

import SwiftUI

struct ContentView: View {
    @State private var storeManager = StoreManager()

    var body: some View {
        MovieCatalogView(storeManager: storeManager)
            .task {
                async let products: () = storeManager.fetchProducts()
                async let entitlements: () = storeManager.updatePurchasedProducts()
                _ = await (products, entitlements)
            }
            .overlay {
                if storeManager.isLoading &&
                    storeManager.subscriptions.isEmpty {
                    ProgressView("Loading the Vault...")
                        .padding()
                        .background(.ultraThinMaterial)
                        .clipShape(RoundedRectangle(cornerRadius: 12))
                }
            }
    }
}

We use async let to fetch products and check entitlements concurrently. This is faster than doing them sequentially and ensures the catalog is ready as quickly as possible. The loading overlay shows only during the initial load when we have no products yet — like the opening of a Pixar movie, those first few seconds of anticipation before the magic begins.

Checkpoint: Build and run the full app. You should see the movie catalog with three free movies (Up, Toy Story, Finding Nemo). Each movie card has a lock button teasing premium content. The gradient banner at the top and the vault-exclusive teaser at the bottom both lead to the paywall. Tap the lock button on any movie card. The paywall should appear showing three plan options: Monthly ($4.99/month), Yearly ($39.99/year, labeled “BEST VALUE”), and Lifetime ($99.99, labeled “ONE TIME”). The yearly plan should be pre-selected. Tap “Subscribe for $39.99” and the StoreKit sandbox should present a purchase confirmation dialog.

Step 11: Adding Subscription Management

Subscribers need a way to view and manage their subscription. Let us add a settings view that shows the current subscription status and provides a link to manage subscriptions.

Replace the placeholder in Views/SubscriptionStatusView.swift with the full implementation:

import SwiftUI
import StoreKit

struct SubscriptionStatusView: View {
    let storeManager: StoreManager
    @State private var showingManagement = false

    var body: some View {
        if storeManager.hasVaultAccess {
            GroupBox {
                VStack(spacing: 12) {
                    HStack {
                        Image(systemName: "checkmark.seal.fill")
                            .font(.title2)
                            .foregroundStyle(.yellow)

                        VStack(alignment: .leading, spacing: 2) {
                            Text("Vault Member")
                                .font(.headline)
                            if let name = storeManager
                                .currentSubscriptionName {
                                Text(name)
                                    .font(.caption)
                                    .foregroundStyle(.secondary)
                            }
                        }

                        Spacer()
                    }

                    // Manage subscription button
                    if storeManager.purchasedProductIDs
                        .contains(StoreManager.monthlyID) ||
                        storeManager.purchasedProductIDs
                        .contains(StoreManager.yearlyID) {
                        Button {
                            showingManagement = true
                        } label: {
                            Text("Manage Subscription")
                                .font(.subheadline)
                                .frame(maxWidth: .infinity)
                        }
                        .buttonStyle(.bordered)
                        .manageSubscriptionsSheet(
                            isPresented: $showingManagement
                        )
                    }
                }
            } label: {
                Label("Subscription", systemImage: "creditcard")
            }
        }
    }
}

The manageSubscriptionsSheet modifier presents the system subscription management UI. Users can upgrade, downgrade, or cancel their subscription from this sheet without leaving the app. This is the same interface that appears in Settings > Apple ID > Subscriptions, but presented inline.

Tip: On the StoreKit sandbox in Xcode, subscriptions auto-renew at an accelerated rate. A monthly subscription renews every few minutes rather than every 30 days. This makes testing renewal and expiration scenarios much faster than waiting a full billing cycle — even Dash from The Incredibles would approve of that speed.

Step 12: Testing in the StoreKit Sandbox

Testing is critical for in-app purchases. A broken purchase flow means lost revenue. The StoreKit sandbox in Xcode provides a full simulation environment, and here is how to test every scenario.

Testing Purchases

  1. Build and run the app on the iOS Simulator.
  2. Tap the premium banner or any lock button to open the paywall.
  3. Select the Yearly Vault Pass and tap Subscribe for $39.99.
  4. The sandbox presents a simplified purchase dialog. Tap Confirm.
  5. The paywall should dismiss and you should now see all six movies, including vault-exclusive titles like The Incredibles, Ratatouille, and WALL-E.
  6. Tap “Show Premium Content” on any movie to see the behind-the-scenes content and director commentary.

Testing Restore Purchases

  1. Delete and reinstall the app on the simulator.
  2. The catalog should show only three free movies (the purchase state is lost).
  3. Tap the lock button to open the paywall.
  4. Tap Restore Purchases at the bottom.
  5. The paywall should dismiss and all six movies should appear — the transaction was restored from the sandbox.

Testing Subscription Expiration

  1. Open the StoreKit configuration file in Xcode.
  2. In the menu bar, select Editor > Manage Transactions.
  3. Find the active subscription transaction.
  4. Right-click and select Expire Subscription.
  5. Return to the app. After a moment, the transaction listener should detect the expiration and revert the UI to the free tier.

Testing Refunds

  1. In the transaction manager, right-click a transaction.
  2. Select Refund Purchase.
  3. The app should update to reflect the refund and lock premium content again.

Warning: Always test the following scenarios before submitting to App Store Review: new purchase, restore, cancellation, expiration, refund, and the Ask to Buy pending state. Missing any of these is a common cause of App Store rejections. As Remy from Ratatouille would say, “If you are what you eat, then I only want to eat the good stuff” — and good stuff means thoroughly tested code.

Checkpoint: Run through all four test scenarios above. After a successful purchase, you should see: the premium banner replaced by a “Vault Member” status card, all six movies visible including The Incredibles, Ratatouille, and WALL-E with their “VAULT” badges, and the ability to expand each movie card to read behind-the-scenes content and director commentary. After expiring the subscription, the vault should lock again and only three movies should be visible. The full cycle should work seamlessly — like a perfectly looping Pixar short film.

Where to Go From Here?

Congratulations! You have built Pixar Movie Vault — a fully functional app with a freemium content model, a polished subscription paywall, and complete in-app purchase management.

Here is what you learned:

  • Configuring products using a StoreKit configuration file for local development
  • Fetching products with Product.products(for:) and separating them by type
  • Building a paywall with subscription and one-time purchase options
  • Handling the purchase flow with Product.purchase() and the three result states (success, cancelled, pending)
  • Verifying transactions with StoreKit 2’s built-in JWS verification via VerificationResult
  • Checking entitlements with Transaction.currentEntitlements
  • Listening for external transaction updates with Transaction.updates
  • Managing subscriptions with manageSubscriptionsSheet
  • Testing purchases, restores, expirations, and refunds in the StoreKit sandbox

Ideas for extending this project:

  • Add introductory offers (free trial, pay-up-front, pay-as-you-go) using Product.SubscriptionOffer and display them on the paywall.
  • Implement a SubscriptionStoreView as an alternative paywall — Apple’s built-in subscription UI that handles product display and purchase with minimal code.
  • Add server-side receipt validation for additional security by sending the transaction’s jwsRepresentation to your backend.
  • Persist entitlements locally with SwiftData so premium content is accessible offline between entitlement checks.
  • Add promotional offers for win-back campaigns targeting lapsed subscribers.
  • Migrate from the StoreKit configuration file to real products in App Store Connect and test with sandbox Apple IDs on a physical device.