ShopKit — Implementation Guide

Total estimated time: 40–60 hours, plus 1–2 weeks of App Store Review iteration.

Day 1 — Project + Sign in with Apple

Step 1. Create project

Standard SwiftUI app, iOS 17 minimum.

Step 2. Capabilities

  • Sign in with Apple
  • In-App Purchase
  • Keychain Sharing (with your team identifier)

Step 3. Sign in with Apple integration

import AuthenticationServices

struct SignInView: View {
    var body: some View {
        SignInWithAppleButton(.signIn) { request in
            request.requestedScopes = [.email]
        } onCompletion: { result in
            switch result {
            case .success(let auth):
                handle(auth)
            case .failure(let error):
                // log
                break
            }
        }
        .signInWithAppleButtonStyle(.black)
        .frame(height: 50)
    }

    func handle(_ auth: ASAuthorization) {
        guard let cred = auth.credential as? ASAuthorizationAppleIDCredential,
              let tokenData = cred.identityToken,
              let tokenString = String(data: tokenData, encoding: .utf8) else { return }
        // Send tokenString to your backend; backend verifies and returns your own session token
    }
}

Checkpoint: tap Sign In, complete the Apple flow, receive an identity token.

Day 2 — Keychain auth storage

Step 4. KeychainStore

public actor KeychainStore {
    private let service: String
    public init(service: String) { self.service = service }

    public func save(_ data: Data, for account: String, biometric: Bool = false) throws {
        let access = biometric
            ? SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .userPresence, nil)!
            : SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, [], nil)!
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecValueData as String: data,
            kSecAttrAccessControl as String: access
        ]
        SecItemDelete(query as CFDictionary)
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
    }

    public func read(account: String) throws -> Data? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        if status == errSecItemNotFound { return nil }
        guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
        return result as? Data
    }

    public func delete(account: String) throws { /* ... */ }
}

Checkpoint: save a token, read it back, delete it, verify deletion.

Day 3–4 — Networking layer

Step 5. Implement APIClient per architecture.md

Step 6. Define request types

struct ArticleListRequest: APIRequest {
    typealias Response = [Article]
    let path = "/articles"
    let method = HTTPMethod.get
    let query: [URLQueryItem] = []
    let body: Data? = nil
    let requiresAuth = true
}

Step 7. URLProtocol stub for tests

final class StubProtocol: URLProtocol {
    static var handler: ((URLRequest) -> (HTTPURLResponse, Data))?

    override class func canInit(with request: URLRequest) -> Bool { true }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
    override func startLoading() {
        guard let handler = Self.handler else { return }
        let (response, data) = handler(self.request)
        client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        client?.urlProtocol(self, didLoad: data)
        client?.urlProtocolDidFinishLoading(self)
    }
    override func stopLoading() {}
}

Use it in tests by configuring a URLSession with URLSessionConfiguration.ephemeral and protocolClasses = [StubProtocol.self].

Checkpoint: write tests for APIClient covering 200, 401-with-refresh, 404, 500, and URLError.notConnectedToInternet. All pass.

Day 5 — Article browsing

Step 8. Article model + HomeView

struct Article: Codable, Identifiable {
    let id: String
    let title: String
    let author: String
    let isPro: Bool
    let publishedAt: Date
    let readTimeMinutes: Int
}

HomeView calls APIClient.send(ArticleListRequest()) in .task, shows a List of cards. Pro articles show a “Pro” badge.

Step 9. Article detail

Tapping a free article opens the detail view. Tapping a Pro article checks SubscriptionStatus.allowsProAccess; if no, push the paywall.

Checkpoint: list renders; Pro gating works (paywall pushes on Pro tap when not subscribed).

Day 6–7 — StoreKit 2 + paywall

Step 10. Create products in App Store Connect

  • com.yourorg.shopkit.pro.monthly — Auto-Renewing Subscription, $4.99/mo
  • com.yourorg.shopkit.pro.yearly — Auto-Renewing Subscription, $39.99/yr
  • Subscription group: “ShopKit Pro”
  • Introductory offer (7-day free trial) on both, eligibility: New Subscribers

Step 11. StoreKit Configuration file for local testing

Xcode → File → New → StoreKit Configuration → “Sync with App Store Connect.” Use as the StoreKit Configuration in your scheme.

Step 12. SubscriptionWatcher actor

@MainActor
@Observable
public final class SubscriptionStatusStore {
    public private(set) var status: SubscriptionStatus = .notSubscribed
    private var updateTask: Task<Void, Never>?

    public func start() {
        updateTask = Task { [weak self] in
            for await update in Transaction.updates {
                if case .verified(let transaction) = update {
                    await self?.refreshFromCurrentEntitlements()
                    await transaction.finish()
                }
            }
        }
        Task { await refreshFromCurrentEntitlements() }
    }

    public func refreshFromCurrentEntitlements() async {
        var newStatus: SubscriptionStatus = .notSubscribed
        for await result in Transaction.currentEntitlements {
            guard case .verified(let txn) = result,
                  txn.productType == .autoRenewable else { continue }
            // Map txn state to SubscriptionStatus
            if let expires = txn.expirationDate {
                newStatus = txn.offerType == .introductory
                    ? .inFreeTrial(expires: expires)
                    : .active(expires: expires)
            }
        }
        self.status = newStatus
    }
}

Step 13. Paywall view

Product.products(for:) fetches the two products. Display them with Product.displayPrice. Purchase via Product.purchase().

struct PaywallView: View {
    @State private var products: [Product] = []
    @State private var selected: Product?

    var body: some View {
        VStack {
            // tier cards
            Button("Start 7-day free trial") {
                Task { await purchase(selected!) }
            }
            Button("Restore Purchases") {
                Task { try? await AppStore.sync() }
            }
        }
        .task {
            products = try await Product.products(for: [
                "com.yourorg.shopkit.pro.monthly",
                "com.yourorg.shopkit.pro.yearly"
            ])
            selected = products.first { $0.id.hasSuffix("yearly") }
        }
    }

    func purchase(_ product: Product) async {
        do {
            let result = try await product.purchase()
            switch result {
            case .success(let verification):
                if case .verified(let txn) = verification {
                    await txn.finish()
                }
            case .userCancelled, .pending:
                break
            @unknown default:
                break
            }
        } catch {
            // log
        }
    }
}

Checkpoint: launch with StoreKit Configuration, complete a sandbox purchase, see SubscriptionStatus flip to .inFreeTrial(...).

Day 8 — Cert pinning

Step 14. Add PinnedSessionDelegate from Phase 9 lab 9.2

Configure the URLSession used by APIClient with this delegate. Hardcode the SPKI hashes of your backend’s leaf + backup intermediate.

Checkpoint: verify against your real backend that requests succeed. Then with mitmproxy interposing, verify they fail.

Day 9–10 — GitHub Actions CI/CD

Step 15. .github/workflows/ci.yml

name: CI
on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - uses: maxim-lobanov/setup-xcode@v1
        with:
          xcode-version: '16.0'
      - name: Test
        run: |
          xcodebuild test \
            -scheme ShopKit \
            -destination 'platform=iOS Simulator,name=iPhone 15,OS=17.0' \
            -enableCodeCoverage YES \
          | xcbeautify

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - uses: maxim-lobanov/setup-xcode@v1
        with:
          xcode-version: '16.0'
      - name: Install Fastlane
        run: bundle install
      - name: Beta lane
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }}
        run: bundle exec fastlane beta

Step 16. Fastlane setup per Phase 10 chapter 5

Step 17. Secrets

  • MATCH_PASSWORD — passphrase for the encrypted certs repo
  • ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_CONTENT — App Store Connect API key (base64 of the .p8 file)

Checkpoint: open a PR with a test change. CI runs, build + tests pass. Merge it. The deploy job runs and a TestFlight build appears about 25 minutes later.

Day 11–14 — App Store Review

Step 18. Submit for Review

  • Compelling screenshots (use Fastlane snapshot).
  • Honest Privacy Nutrition Label.
  • A demo account for App Review if your backend requires login.
  • Review notes explaining how to use the subscription with sandbox testing.

Step 19. Expect 1–3 rejection rounds

Common rejections:

  • Unclear paywall fine print → add “Auto-renews unless cancelled 24h before” copy.
  • Missing Restore Purchases button → add it.
  • Vague Sign in with Apple usage → clarify in App Review Notes.

Iterate, resubmit, ship.


Next: Hardening checklist