Kit Member Sign out
iOS Labs Kit

Zero to App Store.
One focused day.

This guide takes you through every step of building and shipping an iPhone app. Every step is grounded in official Apple documentation, not forum posts or outdated blogs.

16Modules
~15.5 hrsTotal time
1 dayZero to App Store
6+Apple doc sources
Starter project in Xcode
Xcode
Claude Code in VS Code
Claude Code
App in simulator
Simulator
App installed on home screen
Installed

Downloads

πŸ“¦
iOSLabsStarter.xcodeproj
SwiftUI starter project: open directly in Xcode. Onboarding, tab nav, home/explore/detail/profile, ViewModel, reusable components.
πŸ“‹
Templates Pack
App Store metadata template, privacy policy template (ready to fill and host).

What's in this guide

  • βœ“Module 01–03. Setup, project structure, planning your app
  • βœ“Module 04–05. Building screens in SwiftUI, data and persistence
  • βœ“Module 06–07. Monetization with StoreKit, backend and auth
  • βœ“Module 08–10. TestFlight, App Store submission, review process
  • βœ“Module 11. Rejection fix reference + Xcode error table
  • βœ“Module 12–14. Cloudflare Workers, Firebase, AI with Claude
  • βœ“Module 15. Widgets & Push Notifications
  • βœ“Module 16. App Store Optimization (ASO)
  • βœ“Post-Launch Guide. Day 1 through month 1 action plan
  • βœ“Templates. Metadata template and privacy policy
💻 Don't have a Mac?

Xcode only runs on macOS. If you're on Windows or Linux you have two options: rent a cloud Mac (easiest, costs about $1/hr) or run macOS in VirtualBox (free, more setup). Both work. Full setup instructions are at the top of Module 1. Once you have macOS running, every step in this kit is identical.

The fastest way to build: VS Code + Claude Code

You don't have to type every line yourself. This is the actual workflow for building faster:

  1. Download VS Code: free at code.visualstudio.com
  2. Install the Claude Code extension: open Extensions (βŒ˜β‡§X), search "Claude Code", install it
  3. Open your Xcode project folder in VS Code: File β†’ Open Folder β†’ select the folder containing your .xcodeproj file
  4. Describe what you want in plain English: Claude reads your existing Swift files, matches your naming and structure, and writes the code directly into the right files
  5. Switch to Xcode β†’ ⌘B to build β†’ run in simulator
ⓘ How this kit and Claude work together

The modules aren't there to make you memorize syntax. They exist so you understand what you're building well enough to direct Claude accurately, spot mistakes, and make architecture decisions. Without the knowledge, you can't tell if Claude's output is correct. With it, you move fast.

Adding assets to your Xcode project

Assets (images, icons, colors, fonts) live in Assets.xcassets inside Xcode, not in VS Code. Here's exactly how to add each type:

Images (PNG/JPG)

  1. In Xcode, open Assets.xcassets in the file navigator
  2. Right-click in the left panel β†’ New Image Set
  3. Name it without spaces (e.g. heroBanner or hero-banner)
  4. Drag your PNG into the 2Γ— slot (or 3Γ— for high-resolution artwork)
  5. Reference in SwiftUI: Image("heroBanner")
⚠ Always use the exact asset name

Asset names are case-sensitive. Image("HeroBanner") and Image("heroBanner") are different. If your image shows as blank at runtime, check the name.

App Icon

  1. Export as 1024Γ—1024 PNG: no alpha (transparency) channel. Apple rejects icons with transparency.
  2. In Assets.xcassets β†’ click AppIcon
  3. Drag your 1024Γ—1024 PNG into the single slot. Xcode generates all the required sizes automatically.
  4. Test on a real device before submitting: retina scaling can look different than the simulator.

Brand Colors

  1. In Assets.xcassets, right-click β†’ New Color Set
  2. Name it (e.g. brandPrimary)
  3. Click Any Appearance to set the light mode color, and Dark to set dark mode, they can be different values
  4. In SwiftUI: Color("brandPrimary"), adapts to dark mode automatically

Custom Fonts

  1. Drag your .ttf or .otf font file into the Xcode project navigator
  2. In the file import dialog, check "Add to target" for your app target
  3. Open Info.plist β†’ add the key Fonts provided by application β†’ add one entry per font file with the exact filename (e.g. SpaceGrotesk-Regular.ttf)
  4. In SwiftUI: .font(.custom("SpaceGrotesk-Regular", size: 16))
  5. The name in Font.custom() is the PostScript name, not the filename. Check it by opening the font in Font Book on your Mac β†’ select the font β†’ press ⌘I β†’ look for "PostScript Name".

SF Symbols (free, no assets needed)

Apple provides 6,000+ icons built into iOS, no imports required. In SwiftUI: Image(systemName: "star.fill"). Download the SF Symbols app from Apple's developer site to browse all icons and copy their exact names.

Official Apple documentation referenced

  • Apple Human Interface Guidelines: Design standards reviewers check your app against
  • App Store Review Guidelines: The 200+ rules applied to every submission
  • App Store Connect Help: Official submission portal guide
  • SwiftUI Documentation: Every view and modifier used in the starter kit
  • TestFlight User Guide: Apple's beta distribution platform
  • StoreKit 2 Documentation: In-app purchases and subscriptions
Module 0130 minutes

Dev Environment Setup

Before you write a single line of code, get the toolchain in place. This module ends with a blank app running in the simulator.

πŸ“„ Apple Source

Apple requires Xcode to build, sign, and submit iOS apps. Download only from the Mac App Store. developer.apple.com/xcode

Don't have a Mac? Set up your environment first

Xcode is macOS-only. If you're on Windows or Linux, pick one of the two options below, get macOS running, then jump back to Step 1 and follow the rest of this module normally.

Recommended
Cloud Mac (MacinCloud)
~$1.10/hr • No setup • Ready in under 5 minutes

You rent a real Mac that runs in a data center. Access it through your browser or Remote Desktop. No VM setup, no compatibility issues β€” it's just a Mac.

  1. Go to macincloud.com and sign up for a Pay Per Use plan (no monthly commitment)
  2. Launch a Mac mini M2 instance from the dashboard
  3. Connect via the browser-based desktop or the Remote Desktop client they provide
  4. You're on a real Mac. Open the App Store, download Xcode, and continue with Step 1 below
Cost estimate: Xcode downloads and installs in about 30–45 minutes. A full day building your app runs ~$12–$18 total. You only pay while connected.
VirtualBox + macOS VM
Free • Runs on your machine • 1–2 hrs setup

Run macOS as a virtual machine on your Windows or Linux PC using VirtualBox. Needs a reasonably fast machine (8+ GB RAM, 100 GB free disk).

  1. Download VirtualBox (free) from virtualbox.org and install it
  2. Get a macOS image: search YouTube for "VirtualBox macOS Monterey Windows" or "VirtualBox macOS Ventura" β€” there are well-maintained community guides with download links and exact settings. Monterey (macOS 12) has the widest VirtualBox compatibility
  3. Create the VM using the settings from that guide. Typical recommended specs:
    • RAM: 8 GB minimum, 12 GB recommended
    • CPU: 4 cores
    • Disk: 100 GB dynamically allocated
    • Display: 128 MB video memory, enable 3D acceleration
  4. Boot and install macOS inside the VM. Takes 30–60 minutes
  5. Once macOS is running, open the App Store inside the VM, download Xcode, and continue with Step 1
Performance note: the VM runs slower than native hardware. Build times in Xcode will be longer. For serious development, the cloud Mac is a better experience. The VM is a good free starting point to see if app development is for you.

Step 1: Download Xcode

1

Open the Mac App Store

Search "Xcode" and download. It's free and about 12 GB, start it first while you read ahead.

2

Install Command Line Tools

After Xcode launches it prompts for additional components. Accept and wait, required before anything else works.

3

Verify macOS version

Xcode 16 requires macOS 14 (Sonoma) or later. Go to  β†’ About This Mac to check.

Step 2: Apple Developer Program

You need a Developer account to test on a real device and submit to the App Store. Cost: $99/year paid directly to Apple at developer.apple.com/programs.

⚠️ Do this now

Enrollment takes up to 48 hours for identity verification. Start it while Xcode downloads so it doesn't block you at Module 8.

Step 3: Find your Team ID

Your Team ID is a 10-character string (e.g. A1B2C3D4E5). Find it at developer.apple.com β†’ Account β†’ Membership Details β†’ Team ID. You'll enter it in Xcode's Signing & Capabilities tab.

Step 4: Open and run the starter kit

1

Download and open iOSLabsStarter.xcodeproj

Double-click the .xcodeproj file. Xcode opens it automatically. If you see a "Trust and Open" dialog, click Trust.

2

Set your team in Signing & Capabilities

Click the project name in the file navigator (top of the left sidebar) β†’ select the iOSLabsStarter target β†’ Signing & Capabilities tab β†’ set Team to your Apple Developer account. Set Bundle Identifier to something unique like com.yourname.yourapp.

3

Select a simulator and press β–Ά

In the toolbar, choose any iPhone simulator (iPhone 16 is a good default). Press ⌘R to build and run. First build takes 30–60 seconds. You should see the onboarding screen.

💡 Keyboard shortcuts

⌘R, build and run    ⌘B, build only    ⌘K, clean build folder (run this if you get strange errors after changing settings)    βŒ˜β‡§K, clean build folder completely

⚠ Common first-run issues

"No account for team", you haven't added your Apple ID to Xcode yet. Go to Xcode β†’ Settings (⌘,) β†’ Accounts β†’ + β†’ Apple ID.

"Signing certificate not found", click Automatically manage signing in Signing & Capabilities. Xcode will create the certificate.

"Bundle identifier is already in use", your bundle ID must be unique across the App Store. Add your name or a number: com.yourname.ioslabsstarter2.

Build errors after opening, try Product β†’ Clean Build Folder (βŒ˜β‡§K), then ⌘R again. If errors persist, check that your macOS and Xcode versions meet the requirements.

Xcode layout: orientation for new developers

Xcode has five key areas. Knowing their names saves you from getting lost.

AreaWhat it isToggle shortcut
Navigator (left)File tree, search, git changes, breakpoints⌘0
Editor (center)Where you write codeAlways visible
Canvas (right of editor)Live preview, shows UI as you typeβŒ₯βŒ˜β†©
Inspector (right panel)Properties for selected file or UI element⌘βŒ₯0
Debug area (bottom)Console output, variable inspectorβŒ˜β‡§Y
💡 Live Preview shortcut

Press βŒ₯βŒ˜β†© to open the Canvas preview. Click Resume (or βŒ₯⌘P) to restart the preview. You can click buttons and scroll in the preview without running the simulator, it's much faster for UI work.

Build version numbers (important before Module 8)

Xcode uses two version numbers. Both matter for TestFlight and the App Store.

FieldWhere to set itRules
Version (Marketing)Target β†’ General β†’ Identity β†’ VersionUser-facing (e.g. 1.0, 1.2.3). Each App Store release needs a new version.
Build (Internal)Target β†’ General β†’ Identity β†’ BuildMust increment with every upload to TestFlight. Start at 1.

Common pattern: keep Version at 1.0 during testing, increment Build (1, 2, 3…) with each TestFlight upload. When you ship to the App Store, set Version to 1.0 Build to your latest number.

Module 0245 minutes

Your Project Foundation: The Starter Kit

Walk through every file so you understand what each piece does before you start changing anything.

File structure

iOSLabsStarter/                      ← unzip here, open the folder
β”œβ”€β”€ iOSLabsStarter.xcodeproj/        ← double-click this to open in Xcode
β”‚   └── project.pbxproj              ← Xcode manages β€” never edit manually
β”œβ”€β”€ iOSLabsStarter/                  ← all Swift source files live here
β”‚   β”œβ”€β”€ iOSLabsStarterApp.swift      ← entry point, @main, app setup
β”‚   β”œβ”€β”€ ContentView.swift            ← TabView routing (Home/Explore/Profile)
β”‚   β”œβ”€β”€ OnboardingView.swift         ← 3-slide first-launch flow
β”‚   β”œβ”€β”€ HomeView.swift               ← main landing screen
β”‚   β”œβ”€β”€ ExploreView.swift            ← searchable list/grid
β”‚   β”œβ”€β”€ DetailView.swift             ← item detail with hero image
β”‚   β”œβ”€β”€ ProfileView.swift            ← settings and account
β”‚   β”œβ”€β”€ AppViewModel.swift           ← shared @Observable state
β”‚   β”œβ”€β”€ Models.swift                 ← data structs and AppColors
β”‚   β”œβ”€β”€ Components.swift             ← PrimaryButton, ItemRow, ItemCard
β”‚   └── Assets.xcassets/             ← images, icons, color definitions
└── README.md                        ← read this first β€” setup checklist
💡 README walkthrough

The README in the zip walks you through the exact first-run setup below. Keep it open while you do the initial configuration.

First-run setup (5 steps)

1

Open iOSLabsStarter.xcodeproj

Double-click the file. If Xcode asks "Trust and Open", click Trust. Never open project.pbxproj directly, always use the .xcodeproj.

2

Set your Bundle Identifier

In Xcode: click the project name at the top of the left sidebar β†’ select the iOSLabsStarter target β†’ General tab β†’ change Bundle Identifier to com.yourname.yourapp. This must be unique across the App Store.

3

Sign in with your Apple Developer account

Same General/Signing & Capabilities tab β†’ set Team to your developer account. If the list is empty, go to Xcode β†’ Settings (⌘,) β†’ Accounts β†’ + β†’ sign in with your Apple ID first.

4

Select a simulator and press ⌘R

In the toolbar at the top, pick any iPhone (iPhone 16 is fine). Press ⌘R. First build takes 30–60 seconds. You should see the iOS Labs onboarding screen.

5

Make it yours in under 5 minutes

  • Open Models.swift β†’ change AppColors.accent to your brand color
  • Open OnboardingView.swift β†’ update the slide titles and subtitles (3 lines)
  • Open Models.swift β†’ rename AppItem to match your data shape
  • Open iOSLabsStarterApp.swift β†’ update the app display name in the General tab (Product Name field)

MVVM flow

User taps β†’ View re-renders β†’ reads ViewModel β†’ ViewModel holds Model data
Module 0330 minutes

Planning Your App Before You Code

The step most people skip. It's also the one that causes the most wasted work.

πŸ“„ Apple HIG

Apple's HIG states that the best iOS apps have a single, clear purpose and a minimal, focused feature set. Read the HIG

Screen mapping

Write every screen your app needs. For each screen answer: What does it do? How do users get here? What do they do next?

Data modeling

Define your data shape before writing View code. Open Models.swift and replace AppItem:

// Example for a restaurant app
struct MenuItem: Identifiable {
    let id = UUID()
    let name: String
    let price: Double
    let category: String   // "Appetizer", "Main", "Dessert"
    let isAvailable: Bool
}

HIG checklist: before you build

  • βœ“Every screen has one clear purpose
  • βœ“Navigation follows iOS conventions (back button, tab bar, sheets)
  • βœ“Tap targets are at least 44Γ—44 points (Apple's minimum)
  • βœ“Empty states are designed: no blank white screens
  • βœ“App has a plan for offline/no-connection state
Module 042 hours

Building Screens in SwiftUI

Layout primitives

VStack(spacing: 16) { Text("Top"); Text("Bottom") }  // vertical
HStack(spacing: 12) { Image(systemName: "star"); Text("Label") }  // horizontal
ZStack { Color.blue; Text("Overlay") }  // depth layers

SF Symbols: 6,000+ free icons

Image(systemName: "heart.fill")
    .font(.title2)
    .foregroundStyle(.red)
// Browse all icons: download "SF Symbols" app from developer.apple.com/sf-symbols

Typography: use semantic styles

Text("Large Title").font(.largeTitle)   // 34pt, scales with Dynamic Type
Text("Headline").font(.headline)        // 17pt bold
Text("Body").font(.body)               // 17pt (default)
Text("Caption").font(.caption)         // 12pt

NavigationStack

NavigationStack {
    List(items) { item in
        NavigationLink(destination: DetailView(item: item)) {
            Text(item.title)
        }
    }
    .navigationTitle("My List")
}
// Tapping a row pushes DetailView with a back button β€” automatic

Sheets

@State private var showingSheet = false
Button("Open") { showingSheet = true }
.sheet(isPresented: $showingSheet) { MySheetView() }

Animations

Button("Toggle") {
    withAnimation(.spring(duration: 0.4)) { isExpanded.toggle() }
}

List and ForEach

// Scrollable list from an array
List(items) { item in
    HStack {
        Image(systemName: item.icon)
        VStack(alignment: .leading, spacing: 4) {
            Text(item.title).font(.headline)
            Text(item.subtitle).font(.caption).foregroundStyle(.secondary)
        }
    }
}
.listStyle(.insetGrouped)  // grouped sections, iOS style

// ForEach inside a ScrollView for more layout control
ScrollView {
    LazyVStack(spacing: 12) {
        ForEach(items) { item in
            ItemCard(item: item)
        }
    }
    .padding()
}

// LazyVGrid β€” card grid layout
let columns = [GridItem(.flexible()), GridItem(.flexible())]
ScrollView {
    LazyVGrid(columns: columns, spacing: 14) {
        ForEach(items) { item in ItemCard(item: item) }
    }
    .padding()
}

Conditional views

// Ternary (same type)
Text(isLoggedIn ? "Welcome back" : "Sign in")

// if/else blocks (different views)
if items.isEmpty {
    ContentUnavailableView("Nothing here yet", systemImage: "tray")
} else {
    List(items) { item in ItemRow(item: item) }
}

// Optional binding
if let user = viewModel.currentUser {
    Text("Hello, \(user.name)")
} else {
    ProgressView()
}

Button styles

// Filled (primary action)
Button("Get Started") { /* action */ }
    .buttonStyle(.borderedProminent)
    .tint(.blue)

// Bordered (secondary)
Button("Cancel") { /* action */ }
    .buttonStyle(.bordered)

// Custom style
Button("Custom") { /* action */ }
    .padding(.horizontal, 24)
    .padding(.vertical, 14)
    .background(.blue)
    .foregroundStyle(.white)
    .clipShape(RoundedRectangle(cornerRadius: 12))

Images: local and remote

// From Assets.xcassets
Image("heroBanner")
    .resizable()
    .scaledToFill()
    .frame(height: 200)
    .clipped()

// From URL (AsyncImage β€” built in, no package needed)
AsyncImage(url: URL(string: imageURL)) { phase in
    switch phase {
    case .success(let image):
        image.resizable().scaledToFill()
    case .failure:
        Image(systemName: "photo").foregroundStyle(.secondary)
    case .empty:
        ProgressView()
    @unknown default:
        EmptyView()
    }
}
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 10))

@ViewBuilder: custom reusable components

// Define once, use everywhere
struct SectionCard<Content: View>: View {
    let title: String
    @ViewBuilder let content: () -> Content

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(title).font(.headline)
            content()
        }
        .padding(16)
        .background(Color(.systemBackground))
        .clipShape(RoundedRectangle(cornerRadius: 12))
        .shadow(color: .black.opacity(0.06), radius: 8, y: 2)
    }
}

// Usage
SectionCard(title: "Recent Activity") {
    ForEach(recentItems) { item in ItemRow(item: item) }
}

Toolbar and navigation items

NavigationStack {
    ContentView()
        .navigationTitle("Home")
        .navigationBarTitleDisplayMode(.large)  // or .inline
        .toolbar {
            ToolbarItem(placement: .topBarTrailing) {
                Button(action: openSettings) {
                    Image(systemName: "gearshape")
                }
            }
            ToolbarItem(placement: .topBarLeading) {
                Button("Done") { dismiss() }
            }
        }
}

Search

@State private var searchText = ""

List(filteredItems) { item in ItemRow(item: item) }
    .searchable(text: $searchText, prompt: "Search items")

var filteredItems: [Item] {
    searchText.isEmpty ? items : items.filter {
        $0.title.localizedCaseInsensitiveContains(searchText)
    }
}
Module 051.5 hours

Data, State & Persistence

@State: single view

@State private var searchText = ""
TextField("Search", text: $searchText)  // $searchText is a two-way Binding

@Published: shared across views

class AppViewModel: ObservableObject {
    @Published var items: [AppItem] = []
    // Any view reading items re-renders when it changes
}

UserDefaults: lightweight persistence

UserDefaults.standard.set("James", forKey: "user_name")
let name = UserDefaults.standard.string(forKey: "user_name") ?? "Guest"
// Auto-save pattern:
@Published var userName: String {
    didSet { UserDefaults.standard.set(userName, forKey: "user_name") }
}
⚠️ Don't store secrets in UserDefaults

UserDefaults is not encrypted. Use the Keychain for passwords and tokens.

SwiftData: structured local storage (iOS 17+)

@Model class Task {
    var title: String
    var isCompleted: Bool
    init(title: String) { self.title = title; self.isCompleted = false }
}
// In your App entry point:
.modelContainer(for: Task.self)
// In a View:
@Query var tasks: [Task]
@Environment(\.modelContext) var context
// Add: context.insert(Task(title: "New task"))

Async/await: API calls

func fetchItems() async {
    let (data, _) = try await URLSession.shared.data(from: url)
    let items = try JSONDecoder().decode([AppItem].self, from: data)
    await MainActor.run { self.items = items }
}
// In View: .task { await viewModel.fetchItems() }

@AppStorage: simplest way to persist settings

// Reads and writes UserDefaults automatically β€” no boilerplate
struct SettingsView: View {
    @AppStorage("notifications_enabled") var notificationsEnabled = true
    @AppStorage("user_name") var userName = ""
    @AppStorage("selected_theme") var theme = "system"

    var body: some View {
        Form {
            Toggle("Push Notifications", isOn: $notificationsEnabled)
            TextField("Display Name", text: $userName)
        }
    }
}
// Changes persist immediately to UserDefaults. No save button needed.

SwiftData: full CRUD

// Create
let task = Task(title: "Buy groceries")
context.insert(task)

// Read (with sort and filter)
@Query(sort: \Task.createdAt, order: .reverse) var tasks: [Task]
@Query(filter: #Predicate { $0.isCompleted == false }) var pending: [Task]

// Update β€” just change the property, SwiftData auto-saves
task.isCompleted = true

// Delete
context.delete(task)

// Manual save (usually not needed β€” SwiftData auto-saves)
try? context.save()

Keychain: secure storage for tokens

// Add the KeychainAccess package (or use Apple's raw Security framework)
// File β†’ Add Package Dependency β†’ github.com/kishikawakatsumi/KeychainAccess
import KeychainAccess

let keychain = Keychain(service: "com.yourcompany.yourapp")

// Write
keychain["auth_token"] = userToken

// Read
let token = keychain["auth_token"]

// Delete
try? keychain.remove("auth_token")
ⓘ When to use each storage type

@AppStorage / UserDefaults: user preferences, settings, onboarding completion, last-viewed tab.
SwiftData: structured app data, tasks, items, posts, saved content.
Keychain: any credentials or tokens, auth tokens, API keys, passwords.
Files (DocumentDirectory): large blobs, downloaded PDFs, exported files, cached media.

Network error handling

enum AppError: LocalizedError {
    case networkUnavailable
    case decodingFailed
    case serverError(Int)

    var errorDescription: String? {
        switch self {
        case .networkUnavailable: return "No internet connection"
        case .decodingFailed: return "Unexpected data format"
        case .serverError(let code): return "Server error \(code)"
        }
    }
}

// In View β€” show alert on error
.alert("Error", isPresented: $showError, presenting: viewModel.error) { _ in
    Button("OK") {}
} message: { error in
    Text(error.localizedDescription)
}
Module 0645 minutes

Monetization with StoreKit

πŸ“„ App Store Review Guidelines Β§3.1

Digital goods must use Apple's in-app purchase system. External payment links for digital content will result in rejection. Read Β§3.1

Four monetization models

  • Free: Monetize with ads (AdMob, Apple's SKAdNetwork)
  • Paid upfront: User pays once to download. Apple takes 30% (15% under $1M/yr)
  • One-time in-app purchase: Free download, unlock features. Most flexible.
  • Auto-renewable subscription: Monthly/annual. Highest LTV, highest scrutiny.

StoreKit 2 implementation

import StoreKit

class StoreManager: ObservableObject {
    @Published var purchasedIDs: Set<String> = []

    func purchase(_ product: Product) async throws {
        let result = try await product.purchase()
        if case .success(let verification) = result,
           case .verified(let tx) = verification {
            purchasedIDs.insert(tx.productID)
            await tx.finish()
        }
    }

    // REQUIRED by Guideline 3.1.1 β€” you will be rejected without this
    func restorePurchases() async {
        for await result in Transaction.currentEntitlements {
            if case .verified(let tx) = result {
                purchasedIDs.insert(tx.productID)
            }
        }
    }
}
⚠ Restore Purchases is required

Every app with in-app purchases must have a "Restore Purchases" button. Guideline 3.1.1. This is one of the most common rejections.

Set up products in App Store Connect

Before StoreKit can show a product, you have to create it in App Store Connect. This is separate from your code.

1

Go to App Store Connect β†’ your app β†’ In-App Purchases

Click the + button. Choose Non-Consumable (one-time unlock), Consumable (coins, credits), or Auto-Renewable Subscription.

2

Set a Product ID

This string is what your code uses to fetch the product. Convention: use reverse-domain like com.yourapp.pro_unlock. Write it down exactly, it's case-sensitive in code.

3

Add a localized name and description

Required before you can test. The name and description appear on the Stripe payment sheet.

4

Set price

App Store Connect uses price tiers, not free-form amounts. Tier 1 = $0.99, Tier 9 = $8.99, etc. Apple pays out in your local currency at a set exchange rate.

Loading products and checking entitlement

// Load products at app launch
class StoreManager: ObservableObject {
    @Published var products: [Product] = []
    @Published var isPro = false
    let productIDs = ["com.yourapp.pro_unlock"]

    func loadProducts() async {
        do {
            products = try await Product.products(for: productIDs)
        } catch { print("StoreKit load error:", error) }
    }

    // Call this at app launch to restore state
    func checkEntitlements() async {
        for await result in Transaction.currentEntitlements {
            if case .verified(let tx) = result, tx.productID == "com.yourapp.pro_unlock" {
                await MainActor.run { isPro = true }
                await tx.finish()
            }
        }
    }
}

PaywallView pattern

struct PaywallView: View {
    @StateObject private var store = StoreManager()

    var body: some View {
        VStack(spacing: 20) {
            Text("Unlock Pro").font(.title.bold())
            ForEach(store.products) { product in
                Button {
                    Task { try? await store.purchase(product) }
                } label: {
                    Text("Buy \(product.displayName) β€” \(product.displayPrice)")
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(Color.accentColor)
                        .foregroundStyle(.white)
                        .clipShape(.rect(cornerRadius: 12))
                }
            }
            // Required by Guideline 3.1.1
            Button("Restore Purchases") {
                Task { await store.restorePurchases() }
            }
            .foregroundStyle(.secondary)
        }
        .padding()
        .task {
            await store.loadProducts()
            await store.checkEntitlements()
        }
    }
}

Testing in-app purchases

Use the StoreKit Configuration File in Xcode, no App Store Connect products needed during development.

  1. File β†’ New β†’ File β†’ StoreKit Configuration File β†’ name it Products.storekit
  2. Add a product entry matching your Product ID
  3. Edit scheme: Run β†’ Options β†’ StoreKit Configuration β†’ select your file
  4. Purchases now work in the simulator with test cards: no real money charged
💡 Sandbox testing on device

For real-device testing before submission, use a Sandbox account. App Store Connect β†’ Users and Access β†’ Sandbox Testers β†’ + Create a test account. Sign into this account under iOS Settings β†’ App Store β†’ Sandbox Account (scroll to bottom). Sandbox purchases are free and don't hit your card.

Module 071 hour (optional)

Backend & Auth

Skip this if your app works offline. Come back when you're ready.

πŸ“„ App Store Review Guidelines Β§4.8

If your app offers Google/Facebook/Twitter login, it MUST also offer Sign in with Apple. Read Β§4.8

Sign in with Apple

import AuthenticationServices
SignInWithAppleButton(.signIn) { request in
    request.requestedScopes = [.fullName, .email]
} onCompletion: { result in
    if case .success(let auth) = result,
       let cred = auth.credential as? ASAuthorizationAppleIDCredential {
        let userID = cred.user   // Stable anonymous ID
        let email  = cred.email  // Only on first sign-in
    }
}
.signInWithAppleButtonStyle(.black)
.frame(height: 50)

Firebase setup (3 steps)

  1. Go to console.firebase.google.com β†’ New Project β†’ Add iOS app (use your bundle ID)
  2. Download GoogleService-Info.plist and add it to your Sources folder
  3. Add Firebase via Xcode: File β†’ Add Package Dependencies β†’ paste the Firebase iOS SDK URL
Module 0830 minutes

Testing with TestFlight

πŸ“„ TestFlight User Guide

developer.apple.com/testflight

Archive and upload

1

Set destination to "Any iOS Device (arm64)"

Change the Xcode device selector, archiving requires this.

2

Product β†’ Archive

Builds a release version. 1–3 minutes. Organizer opens when done.

3

Distribute App β†’ TestFlight & App Store

Xcode uploads to App Store Connect. Processing takes 5–30 minutes.

Internal vs external testers

TypeLimitNeeds Apple review?Use case
InternalUp to 100 testers on your teamNoImmediate testing, dev team
ExternalUp to 10,000 testersYes (Beta App Review, ~24h)Public beta, friends and family

Start with an internal tester (yourself). After a few builds are stable, create an external group and invite people via email or a public TestFlight link.

Build version numbers before archiving

Every TestFlight upload requires a new Build number. Set it in Target β†’ General β†’ Build. If you upload the same build number, Xcode rejects it. Use 1, 2, 3… or a date stamp like 20260802.

What testers see

After uploading, go to App Store Connect β†’ TestFlight β†’ Builds β†’ click your build β†’ add "What to Test" notes. Tell testers exactly what changed and what you want them to check. Builds without notes still work, but your feedback rate improves dramatically with a specific prompt.

What simulators miss: test on a real iPhone

  • βœ“Camera and microphone
  • βœ“Face ID / Touch ID
  • βœ“Push notifications (never work in simulator)
  • βœ“Haptic feedback feel
  • βœ“Performance under memory pressure
  • βœ“Layout on real notch / Dynamic Island

Reading crash reports

TestFlight automatically collects crashes. App Store Connect β†’ TestFlight β†’ Crashes. Each crash has a symbolicated stack trace. The most useful line is usually the first frame under your app name. Look for the view name and line number, it's the first place to investigate.

💡 Direct device installation

To install directly on your iPhone without TestFlight: connect via cable, unlock the phone, select it in Xcode's device menu, press ⌘R. The app installs and runs directly. The cable connection lets Xcode attach a debugger so you can see print() output and breakpoints live on the device.

Module 091 hour

App Store Connect: Complete Submission

πŸ“„ App Store Connect Help

developer.apple.com/help/app-store-connect

App name & subtitle

  • Name: 30 chars max. No keyword stuffing.
  • Subtitle: 30 chars. Strongest benefit statement.

Keywords

100 characters, comma-separated. Don't repeat words already in your name or subtitle. No competitor names, no "best", "free", "#1".

Required screenshots

  • βœ“iPhone 6.9", 1320Γ—2868px: Required
  • βœ“iPhone 6.5", 1242Γ—2688px: Strongly recommended
  • βœ“iPhone 5.5", 1242Γ—2208px: Recommended
  • βœ“iPad 12.9", 2048Γ—2732px: Required if iPad supported

Take screenshots in Xcode Simulator with ⌘S. Set the simulator to each required device size.

Privacy policy

Required for all apps. Host it on your website (a simple HTML page is fine). Use the Privacy Policy Template in your downloads.

App Privacy (nutrition label)

Declare every category of data your app collects. Cross-references your actual app behavior. Getting it wrong is a violation of Guideline 5.1.1.

Description field strategy

The long description (4,000 chars max) is not searchable by the App Store algorithm, it's only shown to users who tap "More" on your product page. Most users don't read it. Put the strongest copy in the first 3 lines (what shows before "More"). Use short paragraphs and bullet points.

What works: specific feature list, concrete use cases ("Track workouts without opening the app with Siri shortcuts"), social proof once you have it ("Rated 4.8 by 2,000+ users"). What doesn't: marketing buzzwords, vague descriptions of "beautiful design".

Promotional text field

This 170-character field appears above your description and can be updated without a new app review. Use it for time-sensitive copy: launch announcements, limited-time pricing, new feature highlights. It's the only piece of App Store content you can change instantly.

Age rating and content advisory

App Store Connect walks you through a questionnaire. Answer every category honestly. Misrepresenting content is a violation and can get your developer account terminated. If your app has even one piece of user-generated content (comments, posts), you must declare it.

Pricing and territories

Set the price for your app in Pricing and Availability. You can exclude specific territories (e.g., exclude countries with currency instability or where your in-app payments aren't supported). Leave all territories checked unless you have a specific reason to exclude one.

App Preview video (optional but powerful)

A 15–30 second video recorded from the device. Specs: up to 500MB, H.264 or HEVC, recorded at device native resolution. Take the recording in Xcode Simulator or directly from your iPhone (Control Center β†’ Screen Recording). The first 3 seconds auto-play in search results without sound. Make them visual, not reliant on audio.

Review notes

If your app requires login, you MUST provide test credentials. If a feature needs specific conditions, explain them. A reviewer who can't test something will reject the app.

⚠ Submission checklist
  • At least one screenshot for iPhone 6.9" (1320Γ—2868)
  • App icon in Assets.xcassets (1024Γ—1024, no alpha channel)
  • Privacy policy URL: must be publicly accessible
  • App Privacy data declarations filled in
  • If app requires login: demo credentials in Review Notes
  • Build uploaded via Xcode and showing "Ready to Submit" in App Store Connect
  • Version number set (e.g. 1.0)
  • Age rating questionnaire completed
Module 1030 minutes

The Apple Review Process

What happens after you submit

1

Automated checks (minutes)

Apple's systems scan for crashes, missing metadata, and known policy violations.

2

Human review (24–48 hours)

A reviewer installs your app and tests it against the App Store Review Guidelines.

3

Approved or Rejected

Email either way. Rejections include the specific guideline(s) violated.

Resolution Center

App Store Connect β†’ your app β†’ App Review β†’ Resolution Center. Reply to the reviewer professionally and specifically. Explain the exact fix you made.

Expedited review

Request for critical bug fixes or time-sensitive events. App Store Connect β†’ Contact Us β†’ App Review β†’ Request Expedited Review. Usually answered within 24 hours.

Phased release

Roll out to 1% β†’ 2% β†’ 5% β†’ 10% β†’ 20% β†’ 50% β†’ 100% of users over 7 days. Lets you catch critical bugs before they reach everyone. Enable in App Store Connect β†’ Pricing and Availability.

Module 1145 minutes

Rejections & Errors Reference

πŸ“„ App Store Review Guidelines

developer.apple.com/app-store/review/guidelines

Rejection reference

GuidelineRejectionFix
2.1App Completeness, crashes, placeholder content, debug mode left onTest every flow end-to-end. Remove all TODOs and placeholder text. Provide a demo account in review notes.
4.3Spam, too similar to existing apps or minimal value over a websiteAdd genuine native features: camera, haptics, offline mode, widgets, push notifications. Use original design.
5.1.1Privacy, collecting data not disclosed in privacy policy or App Privacy sectionMatch App Privacy declarations to every SDK in your app. Update your privacy policy URL.
3.1.1In-App Purchases, missing Restore button, or external payment link for digital goodsAdd Restore Purchases button. Remove external payment links for digital content.
4.8Missing Sign in with Apple, offers Google/Facebook login but no Apple loginAdd SignInWithAppleButton alongside other social login options.
1.5Developer Info, missing or unreachable support URLAdd a real, publicly accessible support URL. Even a page with your email address works.
2.3.3Screenshots don't match app, screenshots show UI that doesn't existRetake screenshots from the actual submitted build. Don't use Figma mockups.
5.1.5Location without justification, requesting location with no clear reasonAdd a specific NSLocationWhenInUseUsageDescription string. "For app functionality" is not specific enough.
2.1No offline state, app shows blank screen with no connectionAdd offline detection. Show a clear error state, not a blank screen or crash.
3.2.1Web view wrapper, app is just a thin shell around a websiteAdd genuine native features. Apple rejects apps that are better served by a Mobile Safari bookmark.
5.2.5Misrepresentation, name or description implies features not presentRemove any claims about features not in the current version.
4.0Copycat design, icon or UI too closely resembles Apple's appsCreate an original icon. Don't use SF Symbols directly as your app icon.

Xcode build error reference

ErrorCauseFix
No account for team "XXXXXXXXXX"Team ID not matching signed-in accountXcode β†’ Settings (⌘,) β†’ Accounts β†’ add your Apple ID. Then in Signing & Capabilities, re-select your Team from the dropdown.
Provisioning profile doesn't include the entitlementCapability not enabled in App IDdeveloper.apple.com β†’ Certificates, IDs & Profiles β†’ your App ID β†’ enable the capability.
Bundle identifier is already in useAnother app uses this bundle IDIn Xcode, select the target β†’ General tab β†’ change Bundle Identifier to something unique (e.g. com.yourname.yourapp2).
Cannot find type 'X' in scopeMissing import or typoCheck import statements. Check for typos. Verify the file is in the Sources directory.
Value of type 'X' has no member 'Y'Calling a property that doesn't existUse Xcode autocomplete. Check you're calling the method on the correct type.
Archive failed, no signing certificateNo distribution certificate in keychainXcode β†’ Settings β†’ Accounts β†’ Manage Certificates β†’ + β†’ Apple Distribution.
AppIcon set did not have any applicable contentMissing 1024Γ—1024 PNG in AppIcon.appiconsetAdd a 1024Γ—1024 PNG (no alpha/transparency) to Assets.xcassets/AppIcon.appiconset.
Module 121 hour

Cloudflare Workers: Serverless Backend

A Cloudflare Worker is a JavaScript function that runs at Cloudflare's global edge network, 300+ locations worldwide. Free tier: 100,000 requests per day. No server to manage, no cold starts longer than a few milliseconds, and deployment is a single terminal command.

Why you need a backend

Your iOS app runs on the user's device. Any API key embedded in the binary can be extracted. A backend Worker acts as a proxy, your app calls your Worker, your Worker calls Stripe, Anthropic, or any third-party service with the key stored safely as a Cloudflare secret.

Your first Worker

// Modern ES module syntax (recommended)
export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    if (url.pathname === '/api/ping') {
      return Response.json({ ok: true, ts: Date.now() });
    }

    return new Response('Not found', { status: 404 });
  }
};

CORS: required for iOS calls

Your app's network request triggers a CORS preflight. Add these headers to every response:

function corsHeaders() {
  return {
    'Access-Control-Allow-Origin': '*',      // Lock to your domain in production
    'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
  };
}

export default {
  async fetch(request, env) {
    if (request.method === 'OPTIONS') {
      return new Response(null, { status: 204, headers: corsHeaders() });
    }
    const data = await handleRequest(request, env);
    return Response.json(data, { headers: corsHeaders() });
  }
};

KV storage: simple persistent data

KV is a key-value store that's always available to your Worker. No connection setup, no SQL.

// In wrangler.toml β€” bind the namespace
// [[kv_namespaces]]
// binding = "KV"
// id = "your-namespace-id"

// In your Worker
async function rateLimit(env, userId) {
  const key   = `rl:${userId}:${new Date().toISOString().slice(0, 13)}`; // per-hour key
  const count = parseInt(await env.KV.get(key) || '0');
  if (count >= 50) return false;
  await env.KV.put(key, String(count + 1), { expirationTtl: 3600 });
  return true;
}

D1: SQLite at the edge

// Create a table (run once with wrangler d1 execute)
// CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, created_at INTEGER);

// In your Worker
async function getUser(env, id) {
  const { results } = await env.DB.prepare(
    'SELECT * FROM users WHERE id = ?'
  ).bind(id).all();
  return results[0] ?? null;
}

async function createUser(env, id, email) {
  await env.DB.prepare(
    'INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)'
  ).bind(id, email, Date.now()).run();
}

Calling your Worker from iOS

struct WorkerClient {
  static let base = "https://your-worker.yourname.workers.dev"

  static func ping() async throws -> Bool {
    let url  = URL(string: "\(base)/api/ping")!
    let (data, _) = try await URLSession.shared.data(from: url)
    let json = try JSONDecoder().decode([String: Bool].self, from: data)
    return json["ok"] == true
  }
}

Deploy

1

Install Wrangler

npm install -g wrangler then wrangler login

2

Create project

wrangler init my-worker, generates the scaffold

3

Add secrets

wrangler secret put ANTHROPIC_API_KEY, never in code, always in secrets

4

Deploy globally

wrangler deploy, live at your workers.dev subdomain in seconds

Module 131.5 hours

Firebase: Real-Time Data, Auth & Storage

Firebase is Google's mobile backend platform. Spark plan (free) covers 50,000 reads and 20,000 writes per day, 5GB storage, and up to 50 simultaneous connections. Enough for most apps through early growth.

πŸ“„ App Store Review Guidelines Β§4.8

If your app offers Google Sign-In, Facebook Login, or any other third-party authentication, it MUST also offer Sign in with Apple. Reviewers enforce this strictly. Read Β§4.8

Setup (4 steps)

1

Create Firebase project

console.firebase.google.com β†’ Add project β†’ Add iOS app β†’ enter your bundle ID

2

Add GoogleService-Info.plist

Download from Firebase console and drag into your Xcode Sources folder. This file contains your project config (not a secret, it's designed to be in your binary).

3

Add Firebase SDK

Xcode β†’ File β†’ Add Package Dependencies β†’ paste the Firebase iOS SDK URL β†’ select FirebaseAuth and FirebaseFirestore

4

Configure app entry point

Import FirebaseCore and call FirebaseApp.configure() in your App struct's init.

Sign in with Apple

import AuthenticationServices
import FirebaseAuth

SignInWithAppleButton(.signIn) { request in
    request.requestedScopes = [.fullName, .email]
    request.nonce = generateNonce() // Required for Firebase
} onCompletion: { result in
    switch result {
    case .success(let auth):
        guard let cred = auth.credential as? ASAuthorizationAppleIDCredential,
              let tokenData = cred.identityToken,
              let token = String(data: tokenData, encoding: .utf8) else { return }
        let firebaseCred = OAuthProvider.appleCredential(
            withIDToken: token,
            rawNonce: currentNonce,
            fullName: cred.fullName
        )
        Auth.auth().signIn(with: firebaseCred) { result, error in
            // result.user is your Firebase user
        }
    case .failure(let error):
        print(error.localizedDescription)
    }
}
.signInWithAppleButtonStyle(.black)
.frame(height: 50)

Firestore: reading and writing data

import FirebaseFirestore

let db = Firestore.firestore()

// Write a document (creates or replaces)
try await db.collection("posts").document(postId).setData([
    "title":     "My first post",
    "body":      "Hello world",
    "authorId":  Auth.auth().currentUser!.uid,
    "createdAt": FieldValue.serverTimestamp(),
])

// Read once
let snapshot = try await db.collection("posts").document(postId).getDocument()
let title = snapshot["title"] as? String

// Real-time listener β€” SwiftUI view updates automatically
db.collection("posts")
  .whereField("authorId", isEqualTo: Auth.auth().currentUser!.uid)
  .order(by: "createdAt", descending: true)
  .addSnapshotListener { snapshot, error in
      posts = snapshot?.documents.compactMap { try? $0.data(as: Post.self) } ?? []
  }

Security Rules

Go to Firestore β†’ Rules. Replace the default with rules that protect your data:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    // Users can only read and write their own profile
    match /users/{userId} {
      allow read, write: if request.auth != null
                         && request.auth.uid == userId;
    }

    // Posts: anyone can read, only author can write
    match /posts/{postId} {
      allow read: if true;
      allow write: if request.auth != null
                   && request.auth.uid == resource.data.authorId;
    }
  }
}

Firebase Storage: file uploads

import FirebaseStorage

let storage = Storage.storage()
let imageRef = storage.reference().child("users/\(uid)/avatar.jpg")

// Upload with progress
let uploadTask = imageRef.putData(imageData, metadata: nil) { metadata, error in
    guard error == nil else { return }
    imageRef.downloadURL { url, error in
        // Save url.absoluteString to Firestore
    }
}

uploadTask.observe(.progress) { snapshot in
    let pct = Double(snapshot.progress!.completedUnitCount) /
              Double(snapshot.progress!.totalUnitCount)
    progress = pct
}

Cost checkpoints

  • βœ“Spark plan: 50,000 reads/day, 20,000 writes/day: free
  • βœ“Each addSnapshotListener counts as a read every time the document changes
  • βœ“Paginate queries: don't fetch entire collections at once
  • βœ“Set budget alerts in Google Cloud Console before enabling Blaze plan
Module 141 hour

AI Features with Claude

Claude is Anthropic's AI model. Integrating it into your iOS app gives you capabilities that are genuinely hard to build any other way: intent-aware search, content generation, automatic categorization, natural language inputs, and personalized recommendations.

Security requirement

Never call the Anthropic API directly from your iOS app. The API key can be extracted from any shipped binary. Always route requests through your Cloudflare Worker from Module 12, storing the key as a Cloudflare secret.

Architecture

iOS App  β†’  POST https://your-worker.workers.dev/api/ai
         ←  streamed response

Cloudflare Worker  β†’  POST https://api.anthropic.com/v1/messages
                    (ANTHROPIC_API_KEY stored as Worker secret)
                   ←  response forwarded to iOS app

The Cloudflare Worker AI proxy

// worker.js
export default {
  async fetch(request, env) {
    if (request.method === 'OPTIONS') {
      return new Response(null, {
        status: 204,
        headers: {
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Methods': 'POST, OPTIONS',
          'Access-Control-Allow-Headers': 'Content-Type',
        },
      });
    }

    const { prompt, systemPrompt } = await request.json();

    // Rate limit check (use KV from Module 12)
    // ...

    const response = await fetch('https://api.anthropic.com/v1/messages', {
      method: 'POST',
      headers: {
        'x-api-key':         env.ANTHROPIC_API_KEY,
        'anthropic-version': '2023-06-01',
        'content-type':      'application/json',
      },
      body: JSON.stringify({
        model:      'claude-haiku-4-5',
        max_tokens: 1024,
        system:     systemPrompt ?? 'You are a helpful assistant.',
        messages:   [{ role: 'user', content: prompt }],
      }),
    });

    const data = await response.json();

    return Response.json(
      { text: data.content[0].text },
      { headers: { 'Access-Control-Allow-Origin': '*' } }
    );
  }
};

Calling the proxy from iOS

struct AIClient {
  static let endpoint = URL(string: "https://your-worker.workers.dev/api/ai")!

  static func ask(_ prompt: String, system: String = "") async throws -> String {
    var req = URLRequest(url: endpoint)
    req.httpMethod = "POST"
    req.setValue("application/json", forHTTPHeaderField: "Content-Type")
    req.httpBody = try JSONEncoder().encode([
      "prompt":       prompt,
      "systemPrompt": system,
    ])

    let (data, _) = try await URLSession.shared.data(for: req)
    let result    = try JSONDecoder().decode([String: String].self, from: data)
    return result["text"] ?? ""
  }
}

// Usage
let summary = try await AIClient.ask(
  "Summarize this in 2 sentences: \(articleText)",
  system: "You are a concise summarizer. Never exceed 2 sentences."
)

System prompt design

The system prompt is the most important part. A bad system prompt produces inconsistent, off-brand responses. A good one makes Claude behave like a custom-tuned model.

  • Be specific about role and output format. "You are a fitness coach. Always respond with a numbered list of exactly 3 exercises." works far better than "You are helpful."
  • Include constraints. "Never recommend anything requiring gym equipment." prevents Claude from giving answers that don't fit your app.
  • Specify length. "Respond in under 50 words" keeps AI responses from overwhelming your UI.
  • Handle edge cases explicitly. "If the user asks about anything unrelated to fitness, reply: 'I can only help with workout planning.'"

Tool use: structured output

Instead of parsing prose, use tool use to get JSON back from Claude reliably:

// In your Worker β€” add tools to the request body
body: JSON.stringify({
  model:    'claude-haiku-4-5',
  max_tokens: 512,
  tools: [{
    name:        'categorize_expense',
    description: 'Categorize a user expense into a category',
    input_schema: {
      type: 'object',
      properties: {
        category: {
          type: 'string',
          enum: ['food', 'transport', 'housing', 'entertainment', 'health', 'other']
        },
        confidence: { type: 'number' }
      },
      required: ['category', 'confidence']
    }
  }],
  tool_choice: { type: 'tool', name: 'categorize_expense' },
  messages: [{ role: 'user', content: `Categorize this expense: ${description}` }]
})

// Response will have content[0].type === 'tool_use' with input.category

Features you can build with Claude in a weekend

  • βœ“Smart search, user types intent ("something healthy near me under $15"), Claude returns structured filters
  • βœ“Auto-categorization, drop any text/receipt into your app, Claude tags it
  • βœ“Personalized recommendations, feed Claude the user's history, get ranked suggestions
  • βœ“Support chatbot. Claude answers questions about your app with a system prompt trained on your docs
  • βœ“Natural language to structured data, "remind me to call John tomorrow at 3pm" β†’ parsed calendar event
  • βœ“Content generation, workout plans, meal suggestions, email drafts, any generative text feature

Pricing reference

ModelInput / 1M tokensOutput / 1M tokensTypical request cost
Claude Haiku 4.5$0.80$4.00~$0.0008
Claude Sonnet 4.6$3.00$15.00~$0.003
Claude Opus 4.8$15.00$75.00~$0.015

A "typical request" is ~500 input tokens + ~300 output tokens. Use Haiku for high-volume features (search, categorization). Use Sonnet or Opus for complex reasoning tasks.

Module 151.5 hours (optional)

Widgets & Push Notifications

Two features that dramatically improve retention, home screen widgets keep your app visible, push notifications bring users back. Both are optional for first submission, but powerful additions after launch.

Part 1: WidgetKit

WidgetKit lets you show a small, continuously updating view on the user's home screen or lock screen. Widgets use SwiftUI and are built as a separate target inside your Xcode project.

ⓘ Widget architecture

Widgets are not mini-apps. They can't respond to taps (except deep links), they can't run code continuously, and they don't share memory with your main app. They work through a timeline: you provide a set of entries with scheduled display times, and the system renders each one at the right moment.

Creating your Widget target

In Xcode: File > New > Target > Widget Extension. Give it a name like MyAppWidget. Make sure "Include Configuration Intent" is unchecked for a simple static widget.

The template creates three things:

  • Provider, your timeline provider. Generates an array of Entry objects for the system to display
  • Entry, a struct holding the data for one widget snapshot
  • EntryView, a SwiftUI view that renders each entry
struct SimpleEntry: TimelineEntry {
    let date: Date
    let taskCount: Int
}

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), taskCount: 3)
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) {
        completion(SimpleEntry(date: Date(), taskCount: 3))
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {
        // Refresh every hour
        let entry = SimpleEntry(date: Date(), taskCount: loadTaskCount())
        let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: Date())!
        let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
        completion(timeline)
    }
}

Sharing data with App Groups

Your widget runs in a separate process and cannot read your app's UserDefaults or files directly. You need an App Group, a shared container both targets can access.

  1. In Xcode, select your main app target > Signing & Capabilities > + Capability > App Groups
  2. Create a group: group.com.yourcompany.yourapp
  3. Do the same for your Widget Extension target
  4. Now use UserDefaults(suiteName: "group.com.yourcompany.yourapp") in both your app and widget
// In your main app, when data changes:
let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.yourapp")!
sharedDefaults.set(taskCount, forKey: "taskCount")
WidgetCenter.shared.reloadAllTimelines() // tell widgets to refresh

// In your widget's Provider:
func loadTaskCount() -> Int {
    let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.yourapp")!
    return sharedDefaults.integer(forKey: "taskCount")
}

Widget sizes

  • Small (2Γ—2): one key number or status. No scrolling, minimal text
  • Medium (4Γ—2): a list of 2–3 items, or a summary + action
  • Large (4Γ—4): full mini-dashboard with multiple data points
  • Lock screen: circular, rectangular, or inline: added in iOS 16

Declare supported sizes in your widget configuration:

@main
struct MyWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "MyWidget", provider: Provider()) { entry in
            EntryView(entry: entry)
        }
        .configurationDisplayName("My App")
        .description("Shows your task count.")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

Part 2: Push Notifications

Push notifications are sent from a server to a specific device via Apple Push Notification service (APNs). The flow: your backend sends a push to APNs → APNs delivers it to the device → iOS shows it to the user.

Step 1: Create an APNs key

  1. Go to developer.apple.com > Certificates, IDs & Profiles > Keys > + button
  2. Name it "APNs Key", enable Apple Push Notifications service (APNs)
  3. Download the .p8 key file, you only get one chance to download it
  4. Note your Key ID (10-character string) and your Team ID (top-right on developer.apple.com)
  5. Add these as Vercel environment variables: APNS_KEY_ID, APNS_TEAM_ID, APNS_KEY (the full content of the .p8 file)

Step 2: Register in your iOS app

import UserNotifications

// In your App init or AppDelegate:
func requestNotificationPermission() {
    UNUserNotificationCenter.current().requestAuthorization(
        options: [.alert, .badge, .sound]
    ) { granted, error in
        if granted {
            DispatchQueue.main.async {
                UIApplication.shared.registerForRemoteNotifications()
            }
        }
    }
}

// Receive the device token (add to AppDelegate or use UIApplicationDelegateAdaptor):
func application(_ application: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
    // Send tokenString to your backend to store against the user's account
    Task { await saveDeviceToken(tokenString) }
}
⚠ When to ask for permission

Never ask at app launch. Apple's guidelines (and user behavior) strongly favor asking after the user has experienced value, for example, after they complete their first task, or when they try to enable a notifications feature. A premature permission request that gets denied cannot be easily recovered.

Step 3: Send a push from your Cloudflare Worker (Firebase FCM)

The easiest way to send pushes without managing APNs JWT tokens yourself is to use Firebase Cloud Messaging (FCM). You already have Firebase set up from Module 13. FCM handles the APNs authentication for you.

// Cloudflare Worker β€” send push via FCM HTTP v1 API
export default {
  async fetch(request, env) {
    const { token, title, body } = await request.json();

    // Get FCM access token using your service account
    // (store your Firebase service account JSON as an env variable)
    const accessToken = await getFCMAccessToken(env.FIREBASE_SERVICE_ACCOUNT);

    const response = await fetch(
      `https://fcm.googleapis.com/v1/projects/${env.FIREBASE_PROJECT_ID}/messages:send`,
      {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${accessToken}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          message: {
            token: token,  // the device token you saved
            notification: { title, body },
            apns: {
              payload: { aps: { sound: 'default', badge: 1 } }
            }
          }
        })
      }
    );

    return new Response(JSON.stringify(await response.json()));
  }
};

Rich notifications

Send an image with your notification by including a mutable-content: 1 flag and creating a Notification Service Extension target in Xcode. The extension intercepts the notification before display and can download and attach media.

  • File > New > Target > Notification Service Extension
  • Override didReceive(_:withContentHandler:) to download and attach the image URL from the notification payload
  • Action buttons: declare UNNotificationCategory with actions in your app delegate, then specify the category in your push payload
ⓘ Testing push notifications

The iOS Simulator cannot receive real push notifications. Test on a physical iPhone connected to Xcode, or use the Xcode Simulator push notification tool (Xcode 14+) which can simulate local pushes. For full end-to-end testing (server → device), use a real device with your production app or a TestFlight build.

Module 161 hour (optional)

App Store Optimization (ASO)

Most apps fail not because the code is bad but because nobody finds them. The App Store gets 650 million visitors per week. ASO determines whether any of them see your app. This module covers how to rank, how to convert impressions to downloads, and how to keep iterating after launch.

ⓘ The ASO funnel

Impressions β†’ Product Page Views β†’ Downloads. You can't control impressions completely (that's ranking), but you fully control conversion rate: your icon, screenshots, title, and description determine what percentage of people who see your app actually tap and download. Even a 1% conversion improvement on 10,000 monthly impressions is 100 extra downloads.

App Name: your highest-ranking real estate

The app name carries significantly more algorithmic weight than any other metadata field. The first word matters most.

  • 30 character limit including spaces
  • Put your primary keyword as close to the start as possible
  • Make the name memorable and searchable, not clever and obscure
  • Don't keyword-stuff: "Best Task Manager - To Do List App Planner" reads as spam to both Apple and users
  • Example structure: [Primary Keyword] β€” [Brand Name] or [Brand Name]: [Primary Keyword]
⚠ Apple's Β§2.3.7

App names, subtitles, and keywords may not include pricing, terms like "free," "new," "#1," competitor names, or irrelevant keywords. Violations cause rejection at metadata review before a human reviewer even sees your app.

Subtitle: second keyword line, not a tagline

30 characters. Indexed by Apple's search algorithm. Most developers waste this on a tagline like "The app that changes everything." Use keywords instead.

  • Never repeat a word from your app name: Apple only counts each keyword once
  • Think of it as a second keyword slot, not a marketing line
  • Example: App name "Tempo: Workout Tracker" β†’ subtitle "Gym Log & Fitness Planner"

Keyword field: 100 characters, comma-separated

Only visible in App Store Connect, never shown to users. Apple's search uses it directly.

  • No spaces after commas: every character counts: fitness,workout,gym,exercise not fitness, workout, gym
  • Don't repeat words from your app name or subtitle: they're already indexed
  • Don't include competitor app names: rejection risk
  • Use singular or plural, not both: Apple handles stemming
  • Research what people actually search: use the App Store search bar autocomplete, look at competitor keywords using tools like AppFollow or Sensor Tower (free tiers exist)
// Bad (wasted characters):
"task,tasks,task manager,task list,todo,to-do,to do list"
// β†’ "task" repeated 4 times, wastes 30+ characters

// Good (100 chars, no repetition):
"productivity,planner,reminder,schedule,habit,routine,checklist,project,focus,agenda"

Screenshots: where most conversions are won or lost

Studies consistently show that 85% of users make a download decision from the first 1–2 screenshots without scrolling. The first screenshot is your most valuable marketing asset, not your description.

Screenshot strategy by position

  • Screenshot 1: The single most compelling moment or result your app delivers. Value proposition in large text. This is what users see in search results (portrait orientation shows ~2.5 screenshots before scrolling).
  • Screenshot 2: Core feature. Show the screen that users spend the most time in.
  • Screenshot 3: Second most important feature or a social proof moment (ratings, "used by X people").
  • Screenshots 4–10: Supporting features, differentiators, and edge case coverage. Most users won't see these, but people who are on the fence will scroll through them.

What converts vs. what kills conversion

WorksDoesn't work
Large benefit-focused text overlay on each screenshotRaw Xcode simulator screenshots with no context
Consistent brand color frame/background behind the deviceWhite background (blends into the App Store)
Showing the outcome, not the UI ("Track your runs" over a results screen)Showing a settings or onboarding screen first
Dark mode if your app looks better in dark modeWhichever mode Apple randomly renders
Device frames that match the current iPhone designiPhone X bezels in 2025

Required screenshot sizes (2025)

  • iPhone 6.9" (required), 1320Γ—2868px: Dynamic Island iPhone 16 Pro Max
  • iPhone 6.5" (strongly recommended), 1242Γ—2688px: iPhone 11 Pro Max / XS Max
  • iPhone 5.5" (recommended), 1242Γ—2208px: iPhone 8 Plus
  • iPad 12.9" (required if iPad supported): 2048Γ—2732px

If you only upload 6.9", Apple uses those for all iPhone sizes. Upload 6.9" at minimum. The metadata template includes all sizes pre-built in Figma.

App preview video

A 15–30 second video showing your app in action. Autoplay in the App Store with sound off.

⚠ Video can hurt conversion

A shaky screen recording, a loading spinner, or a generic stock music bed consistently performs worse than no video at all. Only add a video if you can produce something that feels polished. A clean video of your core feature loop with no sound, good transitions, and text callouts can lift conversion by 25–30%. A bad one will sink it.

  • Must be captured from a real device or simulator (not screen-recorded from a Mac)
  • 30 fps minimum, H.264 or HEVC, up to 500MB
  • The poster frame (first frame, shown before autoplay) matters: choose it carefully in App Store Connect

Ratings: SKStoreReviewRequest

Your average rating is one of the most visible ranking and conversion factors on your product page. Ask at the right moment and most users say yes.

import StoreKit

// Trigger the native iOS rating prompt
// Apple limits this to 3 times per 365 days β€” use it wisely
func requestReviewIfAppropriate() {
    // Only ask after the user has experienced value
    // Good triggers: completed a task, finished a session,
    //                achieved a goal, returned for the 5th time
    if sessionCount >= 3 && hasCompletedAction {
        if let scene = UIApplication.shared.connectedScenes
            .first(where: { $0.activationState == .foregroundActive })
            as? UIWindowScene {
            SKStoreReviewController.requestReview(in: scene)
        }
    }
}

// SwiftUI version:
import StoreKit
@Environment(\.requestReview) var requestReview
// Then: requestReview()
ⓘ Timing rules

Never ask immediately at app launch or after something went wrong. The best trigger is right after a success moment: the user just completed their first task, finished their first workout, or reached a milestone. Apps that ask at the right time see 4–5Γ— higher positive response rates than apps that ask on a timer.

Getting featured by Apple

Apple's editorial team curates apps for Today tab features, App of the Day, and themed collections. There's no algorithm, it's a human decision. Increase your chances by:

  • Supporting the latest iOS features (widgets, Live Activities, Dynamic Island, SharePlay)
  • Having a polished, visually distinctive UI that follows the Human Interface Guidelines
  • Submitting a feature request at apple.com/feedback β†’ App Store editorial, ideally 4–6 weeks before any planned launch or update
  • Tying your launch or update to a culturally relevant moment (new year, back to school, major iOS release)
  • Being an indie developer: Apple actively features independent developers in the "Indie App of the Week" slot

Localization as an ASO multiplier

Translating your metadata (not your app) into additional languages unlocks new country rankings with minimal work. You don't need to localize the entire app first.

// In App Store Connect, add a new localization:
// My Apps β†’ [Your App] β†’ App Information β†’ + button β†’ select language
// Translate: App Name, Subtitle, Keywords, Description, What's New
// Each localization gets its own keyword field β€” you get 100 more chars per language

// Top 5 markets by App Store revenue:
// United States, Japan, China, United Kingdom, Germany
// French, Spanish, Japanese, German, Chinese (Simplified) cover enormous reach

// Use the Claude API to translate your metadata:
// "Translate this App Store metadata to Japanese.
//  Keep keywords within 100 characters comma-separated.
//  App Name: [name], Subtitle: [subtitle], Keywords: [keywords]"

Product Page Optimization (A/B testing)

App Store Connect lets you A/B test your app icon, screenshots, and app preview video against a control. Up to 3 treatment variations. Apple shows each variation to a portion of your traffic and reports conversion rates.

  • Go to App Store Connect β†’ your app β†’ Product Page Optimization
  • Create a treatment: upload alternative screenshots or icon
  • Set the traffic split (50/50 or custom)
  • Run for at least 7–14 days to get statistically meaningful data
  • Apply the winner: it instantly becomes your default product page
ⓘ What to test first

Test your first screenshot before anything else, it has the highest leverage on conversion. An icon test comes second. Never test both at the same time or you won't know which change drove the result.

Post-Launch

Your First Month After Approval

Apple approved your app. It's live on the App Store. Most first-time developers have no idea what to do next. This is the exact action plan, day by day and week by week.

ⓘ What "live" actually means

Approval doesn't mean immediate visibility. A new app with no reviews, no ratings, and no downloads history starts with zero organic ranking. The first 72 hours of downloads disproportionately affect your initial ranking, this is your best window to drive traffic from non-App Store sources (social, friends, email) into the App Store, so Apple sees momentum and ranks you higher.

Day 1: Verify everything is working

  • βœ“Download your own app from the App Store (not Xcode) on a real device you've never tested with
  • βœ“Complete the full onboarding flow as a new user
  • βœ“If you have IAP: test a real purchase in production (you'll be charged: worth it)
  • βœ“Check your Firebase Crashlytics dashboard: are there any crashes in the first hour?
  • βœ“Check Firebase Analytics: are sessions showing up? Is the funnel tracking?
  • βœ“Verify your support email is working: send a test message to your support address
  • βœ“Share your App Store link to your personal network. The first 50–100 downloads from people who know you prime the ranking algorithm.

Day 1–3: Drive your first downloads

App Store ranking in the first week is heavily influenced by download velocity. Every download in the first 72 hours matters more than a download a month from now.

  • Text everyone you know. Not "check out my app": send the direct App Store link with one sentence about what it does. Personal messages convert, mass posts don't.
  • Post to Reddit. Find 2–3 subreddits where your target user hangs out. Be transparent that you built it. r/apple, r/productivity, and niche subreddits that match your app's purpose. Read the subreddit rules first: many allow app launches.
  • Post to X / Twitter. Tag @AppStore. Indie dev launch posts get engagement. Show a screenshot or short screen recording, not a marketing paragraph.
  • ProductHunt. Schedule a launch for a Tuesday or Wednesday morning (US Eastern) for maximum visibility. Prepare your tagline and screenshots in advance.
  • Your existing audience, newsletter, YouTube, Instagram, TikTok: whatever you have. Even 50 subscribers who trust you will drive downloads that matter.

Week 1: First reviews and first update

Responding to reviews

In App Store Connect β†’ Ratings and Reviews, you can respond to every review. Respond to every 1–3 star review within 24 hours. A good response to a negative review converts future readers better than no response at all. Template:

// Response template for a negative review:
"Thanks for the feedback β€” [specific acknowledgment of their issue].
This is something we're actively working on. Update coming [timeframe].
If you'd like to help us fix this faster, email us at support@[yourapp].com."

// After you ship the fix:
// Go back and update your response to note it's been resolved.
// Many users update their rating when they see a responsive developer.

Your first update

Ship an update in the first week if you can. Even a small one. An active app with recent updates ranks better than a dormant one. Apple's algorithm considers:

  • Time since last update (stale apps rank lower)
  • Release cadence (consistent updates signal an active app)
  • What's New text: write it for users, not engineers. "Fixed bugs" tells no story. "Added dark mode and fixed the crash on iPhone 14" is specific and human.
// Good "What's New" text:
"β€” Dark mode support (finally!)
β€” Faster loading on older devices
β€” Fixed the crash some of you saw when adding a second item
β€” You can now swipe to delete from the main list

Thanks to everyone who emailed feedback β€” keep it coming."

// The conversational tone, specific fixes, and gratitude increase
// the chance users update and leave a review.

Week 2–4: Monitor, iterate, optimize

App Store Connect Analytics, what to watch

MetricWhat it tells youGood benchmark
ImpressionsHow often your app appeared in search/browseGrowing week over week
Product Page ViewsPeople who tapped to see your full page10–20% of impressions
Conversion Rate% of page views that downloaded30–60% (well-optimized)
Retention D1/D7/D30% of users still active after 1/7/30 daysD1: 25%+, D7: 10%+, D30: 5%+
ProceedsRevenue after Apple's cutTrack trend, not absolute
Crashes per Active DeviceCrash rate<1% is the target

Reading your keyword rankings

App Store Connect shows you which search terms are driving impressions under Analytics β†’ Acquisition β†’ App Store Search. Use this to:

  • Find keywords where you're ranking on page 1 but not position 1: small ASO changes can move you up
  • Find unexpected keywords where you're already getting impressions: lean into them in your next metadata update
  • Identify keywords where impressions are high but conversion is low: your screenshots may not match user intent for that keyword

Metadata updates (ASO iteration)

You can update your app name, subtitle, keywords, screenshots, and description at any time without a new build. Go to App Store Connect β†’ your app β†’ [version] β†’ edit fields β†’ Submit for Review. Metadata-only updates typically review in 24 hours.

  • Change one thing at a time so you know what moved the needle
  • Wait at least 1–2 weeks between keyword changes to see ranking impact
  • Use Product Page Optimization (Module 16) to A/B test screenshot changes before committing

Month 1: Establish your release cadence

Apps that ship consistently outperform apps that ship in bursts. Aim for a predictable update rhythm: every 2–4 weeks for a consumer app, every 4–6 weeks if you have a smaller user base. Here's how to think about what to build:

User feedback triage

// Categorize every piece of feedback into one of three buckets:

// 1. BUGS β€” crashes, broken features, wrong behavior
//    β†’ Fix immediately. Ship in the next update.

// 2. FRICTION β€” "I couldn't figure out how to..."
//    β†’ UX improvements. Batch into the next update.

// 3. FEATURES β€” "I wish it could..."
//    β†’ Log in a backlog. Look for patterns across multiple requests
//       before building. 10 users asking for the same thing
//       is signal; 1 user asking is noise.

Retention over acquisition

A new download you immediately lose is worse than no download at all, it lowers your retention metrics, which hurts ranking. Before spending on paid acquisition or influencer placements, get your D7 retention to 10%+. If users aren't coming back in the first week, adding more new users just accelerates churn.

Crash reporting workflow

// Firebase Crashlytics sends you an email when a new crash type
// is detected. For every crash:

// 1. Open Crashlytics β†’ find the crash
// 2. Look at the stack trace β€” it shows the exact line that crashed
// 3. Check "Affected users" β€” is this affecting 1 user or 1,000?
// 4. Fix it, add a comment in the code explaining why the crash
//    happened, and ship the fix in the next release
// 5. In Crashlytics, mark the issue as "Closed" so you know it's fixed

Applying for App Store featuring

Apple's editorial team features apps in the Today tab, App of the Day, and themed collections. This can drive tens of thousands of downloads in a single day for a small app. It's not guaranteed, but it costs nothing to apply and the editorial team does read submissions.

  1. Go to developer.apple.com/contact/app-store/promote
  2. Submit at least 4–6 weeks before your desired featuring window
  3. Mention: your app's unique value, target audience, iOS features you support, any press coverage, any milestones (downloads, ratings)
  4. Tie your request to a cultural or seasonal moment: "Back to School" in July, "New Year" in December, "WWDC" releases if you support new iOS features
  5. If your app is being featured in press (TechCrunch, The Verge, 9to5Mac), mention this: Apple notices media momentum

Checklist: End of month 1

  • βœ“At least one update shipped
  • βœ“All 1–3 star reviews responded to
  • βœ“Analytics review: retention D1/D7 benchmarked
  • βœ“Keyword rankings checked: at least one ASO change made
  • βœ“Crashlytics checked: no open crash affecting >1% of users
  • βœ“Next update scope defined: what are you building next?
  • βœ“App Store featuring submission sent (if you support new iOS features)
  • βœ“Revenue baseline established: you know what a "normal" week looks like
Template

App Store Metadata Template

Fill this out before Module 09. Download the full interactive version from your Downloads.

App Name 30 characters max

No keyword stuffing. Must match what's in your app UI and app icon.

Subtitle 30 characters max

Strongest benefit statement. Don't repeat the app name.

Keywords 100 characters, comma-separated

Don't repeat words in your name or subtitle. No competitor names, "best", "free", "#1".

Required screenshot sizes

  • βœ“iPhone 6.9", 1320Γ—2868px: Required
  • βœ“iPhone 6.5", 1242Γ—2688px: Strongly recommended
  • βœ“iPhone 5.5", 1242Γ—2208px: Recommended
  • βœ“iPad 12.9", 2048Γ—2732px: Required if iPad supported

Pre-submission checklist

  • βœ“App tested on real iPhone via TestFlight
  • βœ“All required screenshots uploaded
  • βœ“App icon 1024Γ—1024 PNG (no alpha)
  • βœ“Support URL live and publicly accessible
  • βœ“Privacy policy URL live and publicly accessible
  • βœ“App Privacy section matches actual data collected
  • βœ“Age rating completed honestly
  • βœ“No placeholder text anywhere in the app
  • βœ“All links in the app are live
  • βœ“Restore Purchases button exists (if IAP)
  • βœ“Sign in with Apple offered (if social login)
  • βœ“Review notes include test credentials (if login required)
Template

Privacy Policy Template

Download the full editable version from your Downloads. Instructions: replace [Your App Name], [Your Company Name], [your@email.com], and [yoursite.com] throughout. Delete sections that don't apply. Host at a public URL before submitting.

⚠️ Required for all apps

Apple requires a publicly accessible privacy policy URL for every app. It cannot require a login to view. A simple HTML page hosted on your website is sufficient.

Must cover:

  • What data your app collects (or a statement that it collects none)
  • How that data is used
  • Whether it's shared with third parties (list all SDKs that collect data)
  • How users can request deletion of their data
  • Contact information