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
- Xcode 16+ with iOS 18 deployment target
- Familiarity with StoreKit 2 fundamentals
- Familiarity with async/await
- Familiarity with SwiftUI state management
Contents
- Getting Started
- Step 1: Configuring StoreKit Products
- Step 2: Building the Movie Data Model
- Step 3: Creating the Store Manager
- Step 4: Fetching Products
- Step 5: Building the Movie Catalog View
- Step 6: Building the Paywall View
- Step 7: Handling the Purchase Flow
- Step 8: Verifying Transactions
- Step 9: Listening for Transaction Updates
- Step 10: Unlocking Premium Content
- Step 11: Adding Subscription Management
- Step 12: Testing in the StoreKit Sandbox
- Where to Go From Here?
Getting Started
Start by creating a new Xcode project.
- Open Xcode and select File > New > Project.
- Choose the App template under iOS and click Next.
- Set the product name to PixarMovieVault.
- Ensure Interface is set to SwiftUI and Language to Swift.
- 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.
- In Xcode, select File > New > File.
- Search for StoreKit Configuration File and select it.
- Name it
PixarVaultProducts.storekitand save it to the project root. - 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:
- Go to Product > Scheme > Edit Scheme (or press Cmd+Shift+<).
- Select Run in the left sidebar.
- Go to the Options tab.
- 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 theproductsline 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.swiftto 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
StoreManageris 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
- Build and run the app on the iOS Simulator.
- Tap the premium banner or any lock button to open the paywall.
- Select the Yearly Vault Pass and tap Subscribe for $39.99.
- The sandbox presents a simplified purchase dialog. Tap Confirm.
- The paywall should dismiss and you should now see all six movies, including vault-exclusive titles like The Incredibles, Ratatouille, and WALL-E.
- Tap “Show Premium Content” on any movie to see the behind-the-scenes content and director commentary.
Testing Restore Purchases
- Delete and reinstall the app on the simulator.
- The catalog should show only three free movies (the purchase state is lost).
- Tap the lock button to open the paywall.
- Tap Restore Purchases at the bottom.
- The paywall should dismiss and all six movies should appear — the transaction was restored from the sandbox.
Testing Subscription Expiration
- Open the StoreKit configuration file in Xcode.
- In the menu bar, select Editor > Manage Transactions.
- Find the active subscription transaction.
- Right-click and select Expire Subscription.
- Return to the app. After a moment, the transaction listener should detect the expiration and revert the UI to the free tier.
Testing Refunds
- In the transaction manager, right-click a transaction.
- Select Refund Purchase.
- 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.SubscriptionOfferand display them on the paywall. - Implement a
SubscriptionStoreViewas 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
jwsRepresentationto 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.