●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Master in-app purchases and subscriptions with Claude Code, SwiftUI, and StoreKit 2. From transaction handling and server-side validation to paywall UI design — production-quality code that passes App Store review.
Why StoreKit 2 Trips Up Even Experienced Developers
The frustration with StoreKit 2 implementation isn't usually the API itself — it's not knowing what "correct" looks like. Stack Overflow answers reference the old StoreKit 1 APIs. Apple's documentation prioritizes conceptual explanations over showing you the complete picture. And the Sandbox environment's quirks produce bugs that don't appear until production.
StoreKit 2, released in 2022, is a full rewrite around async/await. The shift away from SKPaymentQueue, SKProductsRequest, and completion-handler-based code is welcome — but developers who learned StoreKit 1 often hit unexpected roadblocks when the new Transaction and Product types behave differently from what they expect.
This guide builds a complete StoreKit 2 × SwiftUI implementation from scratch, using Claude Code as a design partner throughout. We'll cover consumable products, auto-renewable subscriptions, server-side validation, and paywall UI — all to the standard required for App Store approval.
Why Claude Code Is an Effective StoreKit 2 Partner
Claude Code brings three specific advantages to StoreKit 2 work:
Full project context for architecture decisions. Claude Code reads your entire project before making suggestions. When you say "I want to manage subscription state in this ViewModel," it proposes solutions that fit your existing architecture rather than generic sample code.
Comprehensive error handling in one pass. StoreKit 2 error types span StoreKitError, Product.PurchaseError, and VerificationResult.VerificationError. Asking Claude Code to "handle all StoreKit 2 error cases" produces exhaustive coverage that would take significant research time to assemble manually.
Sandbox debugging support. When Transaction.currentEntitlements returns empty after a successful Sandbox purchase, or when entitlements don't refresh after subscription renewal, Claude Code can diagnose the issue and provide corrected code immediately.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Subtle Transaction.updates timing gaps that Apple docs never mention, and a cache-first design that eliminates the flash-of-paywall problem
✦How StoreKit 2 differs from Stripe in Source of Truth, refund handling, and price changes — lessons from running both side by side
✦Twelve years of indie iOS development distilled into four decision criteria for mixing subscriptions, one-time IAPs, and ads
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Step 1: Product Design and App Store Connect Configuration
Before writing code, design your product catalog with Claude Code's help.
Example prompt
My app is a photo editing app. I want to implement:
- Premium filters (consumable, one-time purchase $0.99)
- Pro monthly subscription ($3.99/month with 7-day free trial)
- Pro annual subscription ($29.99/year)
What Product IDs and App Store Connect configuration should I use?
Claude Code's suggested structure:
Product ID naming convention (recommended):
com.yourcompany.appname.premium_filter ← consumable
com.yourcompany.appname.pro_monthly ← auto-renewable monthly
com.yourcompany.appname.pro_yearly ← auto-renewable annual
App Store Connect checklist:
- Subscription Group name: "Pro Features"
- Annual price: ~10x monthly (creates clear savings perception)
- Free trial: 7 days on monthly only (keeps annual clean)
- Grade: monthly = 1, annual = 2
Add these Product IDs to a .storekit configuration file in Xcode. This lets you test the full purchase flow in Simulator before registering products in App Store Connect.
Step 2: Building the PurchaseManager
The heart of StoreKit 2 integration is a centralized manager. Ask Claude Code:
Create a PurchaseManager for SwiftUI using StoreKit 2.
Requirements:
- Use @Observable macro (iOS 17+)
- Support both consumable and auto-renewable subscriptions
- Start Transaction.updates listener on init
- Track isSubscribed state
- Handle all error cases comprehensively
Here's the core implementation Claude Code produces:
import StoreKitimport Observation@Observablefinal class PurchaseManager { // MARK: - Available products private(set) var products: [Product] = [] // MARK: - Subscription state private(set) var isSubscribed: Bool = false // MARK: - Purchased consumable IDs private(set) var purchasedConsumables: Set<String> = [] // MARK: - Error state private(set) var purchaseError: String? // MARK: - Loading state private(set) var isPurchasing: Bool = false // MARK: - Product IDs private let productIDs: Set<String> = [ "com.yourcompany.appname.premium_filter", "com.yourcompany.appname.pro_monthly", "com.yourcompany.appname.pro_yearly" ] private var updateListenerTask: Task<Void, Error>? init() { // Start listening for transactions immediately — before user initiates any purchase updateListenerTask = listenForTransactions() Task { await loadProducts() await updateSubscriptionStatus() } } deinit { updateListenerTask?.cancel() } // MARK: - Load products from App Store @MainActor func loadProducts() async { do { let fetchedProducts = try await Product.products(for: productIDs) products = fetchedProducts.sorted { $0.price < $1.price } } catch { purchaseError = "Failed to load products: \(error.localizedDescription)" } } // MARK: - Purchase a product @MainActor func purchase(_ product: Product) async { isPurchasing = true purchaseError = nil do { let result = try await product.purchase() switch result { case .success(let verificationResult): let transaction = try checkVerified(verificationResult) await updateSubscriptionStatus() if product.type == .consumable { purchasedConsumables.insert(product.id) // Consumables must be finished to avoid re-processing await transaction.finish() } else { await transaction.finish() } case .userCancelled: // Normal user behavior — not an error break case .pending: // Ask to Buy scenario — waiting for parental approval purchaseError = "Purchase is pending approval. Please check with the account holder." @unknown default: purchaseError = "Unexpected purchase result. Please try again." } } catch StoreKitError.userCancelled { break } catch StoreKitError.notAvailableInStorefront { purchaseError = "This product is not available in your region." } catch StoreKitError.networkError(let urlError) { purchaseError = "Network error: \(urlError.localizedDescription)" } catch { purchaseError = "Purchase failed: \(error.localizedDescription)" } isPurchasing = false } // MARK: - Verify transaction signature private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T { switch result { case .unverified(_, let verificationError): // Apple's signature check failed — possible tampering throw verificationError case .verified(let signedType): return signedType } } // MARK: - Refresh subscription status @MainActor func updateSubscriptionStatus() async { var hasActiveSubscription = false for await result in Transaction.currentEntitlements { do { let transaction = try checkVerified(result) if transaction.productType == .autoRenewable && transaction.revocationDate == nil { hasActiveSubscription = true } } catch { // Skip transactions that fail verification continue } } isSubscribed = hasActiveSubscription } // MARK: - Background transaction listener private func listenForTransactions() -> Task<Void, Error> { return Task.detached { // Catches subscription renewals, refunds, and cross-device purchases for await result in Transaction.updates { do { let transaction = try self.checkVerified(result) await self.updateSubscriptionStatus() await transaction.finish() } catch { // Don't finish transactions that fail verification print("Transaction verification failed: \(error)") } } } } // MARK: - Restore purchases @MainActor func restorePurchases() async { isPurchasing = true do { try await AppStore.sync() await updateSubscriptionStatus() } catch { purchaseError = "Restore failed: \(error.localizedDescription)" } isPurchasing = false }}
Two details worth understanding here:
The Transaction.updates listener must start at app initialization, not in a View's .task modifier. Transactions that arrive during app launch — such as subscription renewals in the background — need to be processed immediately. Delaying the listener means you miss them until the next app restart.
The checkVerified() method is not optional. StoreKit 2 performs local receipt verification automatically, but the result is wrapped in VerificationResult. If you unwrap without checking the unverified case, you may process tampered transactions.
Step 3: Subscription Status — Handling Upgrades, Cancellations, and Grace Periods
Subscription state management is where most implementations go wrong. Users upgrade mid-cycle, cancel and resubscribe, and trigger refunds. Ask Claude Code to model these states explicitly:
enum SubscriptionStatus: Equatable { case notSubscribed case inTrial(expiresDate: Date) case active(plan: SubscriptionPlan, expiresDate: Date) case gracePeriod(expiresDate: Date) // payment failed, Apple retrying case billingRetry // subscription lapsed, user can recover case expired case revoked // refunded var isEntitled: Bool { switch self { case .inTrial, .active, .gracePeriod: return true default: return false } }}enum SubscriptionPlan: String { case monthly = "com.yourcompany.appname.pro_monthly" case yearly = "com.yourcompany.appname.pro_yearly"}
Add a detailed status fetcher to PurchaseManager:
@MainActorfunc fetchSubscriptionStatus() async -> SubscriptionStatus { let subscriptionIDs: [String] = [ SubscriptionPlan.monthly.rawValue, SubscriptionPlan.yearly.rawValue ] guard let product = products.first(where: { subscriptionIDs.contains($0.id) }), let statuses = try? await product.subscription?.status else { return .notSubscribed } for status in statuses { guard let transaction = try? status.transaction.payloadValue else { continue } switch status.state { case .subscribed: let plan = SubscriptionPlan(rawValue: transaction.productID) ?? .monthly let expiresDate = transaction.expirationDate ?? Date() if transaction.offerType == .introductoryOffer { return .inTrial(expiresDate: expiresDate) } return .active(plan: plan, expiresDate: expiresDate) case .inGracePeriod: return .gracePeriod(expiresDate: transaction.expirationDate ?? Date()) case .inBillingRetryPeriod: return .billingRetry case .revoked: return .revoked case .expired: // Cancelled but still within paid period if let expiresDate = transaction.expirationDate, expiresDate > Date() { let plan = SubscriptionPlan(rawValue: transaction.productID) ?? .monthly return .active(plan: plan, expiresDate: expiresDate) } return .expired @unknown default: continue } } return .notSubscribed}
Step 4: Server-Side Validation with App Store Server API
For high-value content or multi-device state sync, client-side verification alone isn't enough. Ask Claude Code to implement server-side validation:
Write a TypeScript class for App Store Server API that:
- Generates JWT tokens with ES256 signing
- Fetches transaction details by transaction ID
- Gets subscription status for a user
- Handles both production and Sandbox environments
A well-designed paywall meaningfully affects conversion. Claude Code can generate the structure and you refine the copy and visual design:
struct PaywallView: View { @Environment(PurchaseManager.self) private var purchaseManager @State private var selectedPlan: Product? @State private var showError: Bool = false @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { ScrollView { VStack(spacing: 24) { paywallHeader featureList planSelector purchaseButton restoreButton legalLinks } .padding() } .navigationTitle("Upgrade to Pro") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Close") { dismiss() } } } .alert("Error", isPresented: $showError) { Button("OK") { purchaseManager.purchaseError = nil } } message: { Text(purchaseManager.purchaseError ?? "") } .onChange(of: purchaseManager.purchaseError) { _, error in showError = error \!= nil } .task { await purchaseManager.loadProducts() // Default to annual for the strongest value message selectedPlan = purchaseManager.products.first { $0.id == SubscriptionPlan.yearly.rawValue } } } } private var planSelector: some View { VStack(spacing: 12) { ForEach(purchaseManager.products.filter { $0.type == .autoRenewable }) { product in PlanCard( product: product, isSelected: selectedPlan?.id == product.id, isYearly: product.id == SubscriptionPlan.yearly.rawValue ) .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { selectedPlan = product } } } } } private var purchaseButton: some View { Button { guard let plan = selectedPlan else { return } Task { await purchaseManager.purchase(plan) if purchaseManager.isSubscribed { dismiss() } } } label: { Group { if purchaseManager.isPurchasing { ProgressView().tint(.white) } else { Text(selectedPlan.map { "Start — \($0.displayPrice)" } ?? "Select a plan") .fontWeight(.semibold) } } .frame(maxWidth: .infinity) .frame(height: 56) } .buttonStyle(.borderedProminent) .disabled(selectedPlan == nil || purchaseManager.isPurchasing) } private var restoreButton: some View { Button("Restore previous purchases") { Task { await purchaseManager.restorePurchases() if purchaseManager.isSubscribed { dismiss() } } } .font(.footnote) .foregroundStyle(.secondary) } private var legalLinks: some View { HStack(spacing: 8) { Link("Terms of Use", destination: URL(string: "https://yourapp.com/terms")\!) Text("·") Link("Privacy Policy", destination: URL(string: "https://yourapp.com/privacy")\!) } .font(.caption) .foregroundStyle(.secondary) }}
Step 6: Testing Strategy
StoreKit 2 testing works in three layers.
Layer 1: Xcode StoreKit Testing. The .storekit configuration file lets you run the full purchase flow in Simulator without App Store connectivity. Add a StoreKit Configuration file to your project in Xcode (File → New → File → StoreKit Configuration File), then set it as the active configuration in your scheme's Run settings.
Layer 2: Sandbox testing. Sandbox accelerates subscription renewal intervals (1 month → 5 minutes), making it practical to test the full renewal lifecycle in minutes. Create Sandbox test accounts in App Store Connect under Users and Access → Sandbox → Testers.
Layer 3: TestFlight with real payment. Before production release, run one complete purchase flow through TestFlight to verify receipt validation behaves identically to production.
Step 7: Three Common Mistakes to Avoid
Ask Claude Code "Does this PurchaseManager follow StoreKit 2 best practices?" and it will catch these issues. Understanding why they're problems helps you avoid reintroducing them.
Mistake 1: Starting Transaction.updates too late
// ❌ Wrong: starting the listener in a Viewstruct ContentView: View { var body: some View { Text("Hello") .task { // Misses transactions that arrive during app launch for await result in Transaction.updates { ... } } }}// ✅ Correct: start in PurchaseManager.init()init() { updateListenerTask = listenForTransactions() // starts immediately Task { await loadProducts() }}
Mistake 2: Forgetting transaction.finish() on consumables
// ❌ Wrong: item granted but transaction not finishedcase .success(let result): let transaction = try checkVerified(result) grantItem() // user gets the item // Missing finish() — next purchase attempt sees the same pending transaction// ✅ Correct: finish after granting the itemcase .success(let result): let transaction = try checkVerified(result) grantItem() await transaction.finish() // required for consumables
Mistake 3: Treating all currentEntitlements as subscriptions
// ❌ Wrong: consumable purchases appear here toofor await result in Transaction.currentEntitlements { isSubscribed = true // incorrect for consumables}// ✅ Correct: check productType and revocationDatefor await result in Transaction.currentEntitlements { let transaction = try checkVerified(result) if transaction.productType == .autoRenewable && transaction.revocationDate == nil { isSubscribed = true }}
Step 8: App Store Review Checklist
Subscription apps face specific App Store review requirements. Ask Claude Code: "Review this implementation against App Store Review Guidelines 3.1.1 and 3.1.2."
Key requirements for approval:
Restore purchases button (required): Any app with subscriptions must include a visible restore button. Missing it is a rejection reason.
Terms and privacy policy links (required): Must appear on the paywall screen before purchase.
Clear trial disclosure: If you offer a free trial, the paywall must state when billing begins and at what price.
Subscription management link: Apple recommends including "Manage your subscription in Settings" text or a link to itms-apps://apps.apple.com/account/subscriptions.
Pricing must include billing period: "$3.99/month" not just "$3.99."
You now have a production-ready StoreKit 2 foundation: a PurchaseManager with full error handling, detailed subscription state modeling, server-side validation, and a tested paywall UI.
The natural next step is App Store Server Notifications v2. These webhooks deliver real-time events — subscription cancellations, refunds, billing failures — to your server, letting you update user state instantly rather than waiting for the next app launch. Ask Claude Code to write "an Express.js handler for App Store Server Notifications v2 with JWS signature verification" and you'll have a complete implementation in minutes.
The implementation above handles the core scenarios. Here are additional patterns Claude Code helps with as your app scales.
Introductory Offer Eligibility Checking
Before showing a free trial in your paywall, verify the user is actually eligible. StoreKit 2 makes this straightforward:
// Check if user is eligible for an introductory offerfunc checkIntroductoryOfferEligibility(for productID: String) async -> Bool { guard let product = products.first(where: { $0.id == productID }), let subscription = product.subscription else { return false } // introductoryOfferEligibility returns a Bool for StoreKit 2 // It returns false if the user has already used a trial let eligibility = await subscription.isEligibleForIntroOffer return eligibility}
Then adapt your paywall UI to reflect eligibility:
// In PaywallView.task { await purchaseManager.loadProducts() // Check trial eligibility before defaulting to monthly let isEligible = await purchaseManager.checkIntroductoryOfferEligibility( for: SubscriptionPlan.monthly.rawValue ) if isEligible { selectedPlan = purchaseManager.products.first { $0.id == SubscriptionPlan.monthly.rawValue } } else { // User already used their trial — default to annual for best value selectedPlan = purchaseManager.products.first { $0.id == SubscriptionPlan.yearly.rawValue } }}
Ask Claude Code: "How should I update my paywall UI when the user is not eligible for a free trial?" It will suggest copy changes (removing "Try free for 7 days" wording) and layout adjustments that keep the conversion rate healthy even when the trial isn't available.
Promotional Offer Implementation
Promotional offers — discounts for lapsed subscribers — require a server-side signature. This is where Claude Code's server-side code generation saves significant time.
// Request a promotional offer signature from your serverfunc fetchPromotionalOfferSignature( productID: String, offerID: String, userID: String) async throws -> Product.PurchaseOption { // Your server generates the signature using the Promotional Offer key let signatureResponse = try await yourAPIClient.post("/subscription/promo-signature", body: [ "productID": productID, "offerID": offerID, "userID": userID ]) guard let nonce = UUID(uuidString: signatureResponse.nonce), let signatureData = Data(base64Encoded: signatureResponse.signature) else { throw PurchaseError.invalidSignature } return try .promotionalOffer( offerID: offerID, keyID: signatureResponse.keyID, nonce: nonce, signature: signatureData, timestamp: signatureResponse.timestamp )}// Use the promotional offer in a purchasefunc purchaseWithPromotionalOffer(_ product: Product, offerID: String, userID: String) async { do { let offerOption = try await fetchPromotionalOfferSignature( productID: product.id, offerID: offerID, userID: userID ) let result = try await product.purchase(options: [offerOption]) // Handle result as normal } catch { purchaseError = "Promotional offer failed: \(error.localizedDescription)" }}
The server-side signature generation is the tricky part — ask Claude Code for "Node.js implementation of App Store promotional offer signature generation using ES256" and it produces the complete signing logic.
Family Sharing Support
StoreKit 2 supports Family Sharing for subscriptions and non-consumable purchases. To check if a transaction benefits from Family Sharing:
// Detect Family Sharing in transaction handlingfunc handleTransaction(_ transaction: Transaction) async { if transaction.ownershipType == .familyShared { // This entitlement comes through Family Sharing // The user didn't purchase directly — grant access but track the source grantFamilySharedAccess() } else { // Direct purchaser grantDirectPurchaseAccess() } await transaction.finish()}
Family Sharing must be enabled per-product in App Store Connect. Claude Code can help you design a permission matrix for which products support sharing and which require individual purchase.
Subscription Upgrade and Downgrade Paths
When a user on a monthly plan wants to upgrade to annual mid-cycle, StoreKit 2 handles this through the standard purchase() flow — but the timing matters.
// Upgrade from monthly to annualfunc upgradeToAnnual() async { guard let annualProduct = products.first(where: { $0.id == SubscriptionPlan.yearly.rawValue }) else { return } // StoreKit 2 handles proration automatically for upgrades // in the same subscription group await purchase(annualProduct) // After upgrade, the monthly subscription is automatically cancelled // No manual cancellation needed}
For downgrades (annual to monthly), the change takes effect at the next renewal date rather than immediately. Your UI should communicate this clearly. Ask Claude Code: "Write a SwiftUI view that shows the user the effective date of their subscription plan change."
Working Effectively with Claude Code on StoreKit Problems
After implementing StoreKit 2 in several apps, here are the prompt patterns that consistently produce the best results.
For debugging: Paste the exact error or unexpected behavior, include the relevant code, and specify whether it's happening in Simulator, Sandbox, or production. "In Sandbox, after cancelling and resubscribing, isSubscribed stays false until app restart. Here's my updateSubscriptionStatus() implementation: [code]." Claude Code will identify the missing Transaction.updates trigger.
For code review: Ask specifically about the patterns you're uncertain about. "Does this PurchaseManager correctly handle the case where a subscription renewal arrives while the app is in the background?" is more productive than "Is this code correct?"
For architecture decisions: Describe your app's specific needs, not generic requirements. "My app has two subscription tiers and three consumable products. Users can mix consumables with subscriptions. Should subscription state live in a singleton or per-scene?" Claude Code will weigh the tradeoffs in your specific context.
For App Store review concerns: After implementation, ask Claude Code to review your implementation against the specific guideline numbers that apply. Guidelines 3.1.1 (in-app purchase), 3.1.2 (subscriptions), and 3.2.1 (reader apps, if applicable) each have distinct requirements.
Handling Edge Cases That Surprise Developers
These scenarios don't appear in most StoreKit tutorials, but they appear in production.
Device transfer: When a user gets a new phone and signs in with their Apple ID, Transaction.currentEntitlements returns their active subscriptions automatically. No action required — StoreKit 2 handles this. Your updateSubscriptionStatus() on launch covers it.
App deletion and reinstall: Same behavior as device transfer. AppStore.sync() is available for explicit restore, but in practice, currentEntitlements handles reinstalls without user action.
Multiple Apple IDs on one device: If a user switches Apple IDs on the device, their entitlements switch accordingly. Your Transaction.updates listener will receive appropriate updates. This is an edge case to document in your test plan.
Subscription pause (Android parity): Apple doesn't support subscription pause the way Google Play does. If a user wants to pause, the options are: cancel and hope they resubscribe, or implement a feature flag in your app that doesn't remove access immediately. Claude Code can help design a "pause" UX that works within Apple's constraints.
Refund detection: When Apple issues a refund, the transaction's revocationDate becomes non-nil and revocationReason is set. Your Transaction.updates listener will receive this event. Implement access revocation accordingly:
// Handle refund in Transaction.updates listenerfor await result in Transaction.updates { do { let transaction = try checkVerified(result) if let revocationDate = transaction.revocationDate { // Refund received — revoke access await revokeAccess(for: transaction.productID, at: revocationDate) } else { await updateSubscriptionStatus() } await transaction.finish() } catch { print("Transaction verification failed: \(error)") }}
Putting It Together: A Production Readiness Checklist
Before submitting to App Store review, Claude Code can validate against this complete checklist. Ask: "Review my StoreKit 2 implementation against this checklist: [paste checklist]."
Transaction handling:
Transaction.updates listener starts at app initialization
All transaction results call transaction.finish() after processing
checkVerified() is called on all VerificationResult values
Refund detection (revocationDate) is handled in the updates listener
Subscription state:
updateSubscriptionStatus() is called on launch and after every transaction
Both productType == .autoRenewable and revocationDate == nil are checked
Grace period and billing retry states display appropriate UI (not just "access revoked")
UI requirements:
Restore purchases button is visible without scrolling on the paywall
Terms of Use and Privacy Policy links are present before purchase
Free trial terms state when billing begins and at what price
Price includes billing period ("$3.99/month" format)
Error handling:
StoreKitError.userCancelled is handled silently (not shown as an error)
StoreKitError.notAvailableInStorefront shows a region-appropriate message
Network errors are retryable from the UI
Testing coverage:
Consumable purchase and grant verified
Subscription purchase and isSubscribed confirmation
Restore purchases flow tested
Free trial eligibility check for eligible and ineligible users
Sandbox renewal tested (accelerated intervals)
Operational Gotchas That Apple's Docs Don't Mention
Hirokawa has been an indie developer since 2014, with around 50 million cumulative downloads across a portfolio of wallpaper and ambient apps on the App Store. By the time you've shipped StoreKit code across that many apps, you start noticing patterns that Apple's documentation simply doesn't cover. Here are three that have cost the most production debugging time.
Transaction.updates Arrives at Unpredictable Moments
The official guide tells you to subscribe to Transaction.updates right after launch. In production, transactions can actually arrive at any of these moments:
0.3 seconds after launch (when entitlements are already in sync)
3-5 seconds after launch (after a background fetch finishes)
The instant the app returns to the foreground
Minutes later, when a refund is processed server-side and the device hasn't been notified yet
If your paywall checks purchaseManager.purchasedProductIDs.isEmpty and shows the paywall when true, paying users will see a paywall flash on cold start because currentEntitlements hasn't been awaited yet. The fix is to cache the last-known purchased product IDs in UserDefaults and trust the cache until the first entitlement sync completes:
@MainActorfinal class PurchaseManager: ObservableObject { @Published private(set) var isInitialFetchComplete = false @Published private(set) var purchasedProductIDs: Set<String> = [] private let cacheKey = "cached_purchased_product_ids" init() { // 1. Restore from cache immediately if let cached = UserDefaults.standard.array(forKey: cacheKey) as? [String] { purchasedProductIDs = Set(cached) } // 2. Start listening in the background Task { await observeTransactions() } } private func observeTransactions() async { await syncEntitlements() isInitialFetchComplete = true for await update in Transaction.updates { await handleTransaction(update) } } private func syncEntitlements() async { var ids: Set<String> = [] for await result in Transaction.currentEntitlements { if case .verified(let tx) = result { ids.insert(tx.productID) } } purchasedProductIDs = ids UserDefaults.standard.set(Array(ids), forKey: cacheKey) }}
The paywall view then waits for isInitialFetchComplete and trusts the cache while it's still false. This single change eliminated almost all of Hirokawa's support tickets about "paywall flashing on launch for paying users."
Sandbox Subscription Renewals Are Too Fast for Realistic Testing
In App Store Connect's Sandbox, monthly subscriptions renew every five wall-clock minutes. That's convenient — until your test session ends up with a state log polluted by twelve unintended renewals. The trick is to maintain a dedicated Sandbox tester for each lifecycle state you care about: a "fresh purchase" account, a "renewed at least once" account, a "voluntarily cancelled" account, a "expired" account, and a "refunded" account. App Store Connect's Sandbox Testers panel lets you inspect the per-account state, even though Apple's docs don't really highlight this workflow.
Always Pass appAccountToken
Product.purchase() accepts an appAccountToken: UUID option. Apple's documentation lists it as "use if you need to identify the user," which makes it sound optional. It isn't. Without it, refund handling becomes painful.
let result = try await product.purchase(options: [ .appAccountToken(userUUID) // stable ID issued by your own server])
When App Store Server Notifications v2 delivers a refund notification, signedTransactionInfo will carry the appAccountToken you sent. That lets your backend revoke entitlements for the right user immediately. Without it, you'd need a separate map from originalTransactionId to your internal user ID — fine to add later, but much easier to set up from day one.
Lessons From Running StoreKit 2 and Stripe Side by Side
Hirokawa runs Dolice Labs — four developer-focused sites (Claude Lab, Gemini Lab, Antigravity Lab, Rork Lab) — alongside the iOS app business. The web sites use Stripe for memberships, single-article purchases, and tips. Running both Stripe and StoreKit 2 in production simultaneously has surfaced design contrasts worth noting.
Source of Truth. Stripe assumes everything important happens server-side; webhooks like customer.subscription.updated are the authoritative signal. StoreKit 2 treats the device as a co-equal source of truth via Transaction.updates. App Store Server Notifications v2 closes most of the gap, but you still need both sides to converge. The practical lesson: even when using StoreKit 2, persist purchase records on your own server (or on-device, in a verified store like Realm). Don't let Transaction results be your only source.
Identity. Stripe's Customer ID gives you a stable 1:1 link between user and subscription from the start. StoreKit doesn't, unless you pass appAccountToken on every purchase. Treat appAccountToken as non-negotiable.
Testing. Stripe's CLI lets you fire any webhook event at any time. StoreKit Sandbox forces you to wait for Apple's timing. To stay productive, lean on StoreKit Configuration files in Xcode for unit-level testing and reserve Sandbox for end-to-end runs.
Price changes. Here StoreKit actually wins. Changing a Price Tier in App Store Connect updates all new purchases instantly while grandfathering existing subscribers automatically. In Stripe you'd manage multiple Price objects to achieve the same effect. When Hirokawa moved one wallpaper app from ¥120 to ¥150 per month, existing subscribers kept the old price with zero engineering work. The Stripe equivalent would have required a migration plan.
Cancellation flow. Stripe lets you build your own cancel UI (or use Customer Portal). StoreKit forces users into iOS Settings → Subscriptions. That's correct from a user-protection standpoint, but it means you have very little surface area to ask for cancellation reasons. Hirokawa once asked Claude Code whether some StoreKit hook could intercept the cancel flow for a quick survey. Claude Code's response was immediate and useful: it would likely violate Apple's guidelines and get flagged in review. Instead, it suggested showing a single-question "What didn't work for you?" prompt only when a previously-cancelled user revisits the paywall — a pattern that stays inside the guidelines and still surfaces useful insight.
Twelve Years of Indie Development: A Subscription Design Framework
After twelve years of releasing apps as a solo developer — and watching AdMob revenue climb past ¥1M/month before subscriptions even existed for the catalog — Hirokawa eventually settled on a hybrid revenue model. The reasoning is worth sharing because it cost a lot of A/B testing to arrive at.
Decision 1: ARPU vs. MAU
Subscriptions raise ARPU (revenue per user). Ads monetize MAU (monthly active users). They look orthogonal but collide at the UI level. Leave the ad slots and your paywall loses urgency. Make the paywall aggressive and churn rises, dragging ad revenue down too.
For the wallpaper apps, Hirokawa settled on free users see ads + all features; paid users see no ads + exclusive wallpapers. The reason for keeping ads even for free users was empirical: the conversion rate to paid was the industry-typical 2-3%, and giving up that AdMob baseline was a worse risk for a solo developer than slightly reducing the perceived value of paying.
Decision 2: Monthly vs. Lifetime
This one took years to resolve. The final answer: offer both. Dolice Labs sells Pro (monthly) alongside Premium (lifetime). The reasoning isn't about extracting more money — it's about minimizing the reader's mental cost. Monthly is cheap but carries an ongoing "should I cancel?" tax. Lifetime is more expensive but stops the question forever. Different readers prefer different trade-offs, and the right answer is to let them choose.
Inside iOS apps, Hirokawa tested a three-tier model: consumable IAP (buy individual wallpapers) + non-consumable IAP (one-time unlock for pro features) + monthly subscription. When asked how to explain the three on a single screen, Claude Code proposed arranging them left-to-right as "try this," "medium-term," and "long-term" — a mental model framing rather than a feature comparison. After shipping, long-term-intent users picked the lifetime option more often than they had under a flat feature comparison.
Decision 3: Is the Price "Fair" to the Reader?
Pricing is a quiet form of self-assertion about value. Too cheap and people wonder if the work is any good. Too expensive and you signal greed. For Dolice Labs, Hirokawa lands at ¥580/month and ¥2,480/lifetime — roughly "a casual-restaurant lunch per month" and "two paperback books, forever." iOS apps stay in the ¥150-¥250/month band. Hirokawa deliberately avoids ".99" pricing (¥0.99/¥1.99/¥2.99) because it reads as slightly manipulative.
Decision 4: Don't Spend Engineering Time on Improvements You Can't Measure
This last one is the most important — and the easiest to forget. Subscription code gets longer and buggier the more "edge cases" you handle. Some of those edge cases are genuinely worth it (refunds, billing retry). Many are not. The "show a colored warning in the paywall when the user's subscription expires in three days" type of improvement looks helpful, but in Hirokawa's A/B tests it never moved conversion in either direction.
What's changed with Claude Code in the toolkit isn't which improvements are worth building — it's that the cost of trying them has dropped enough to test them properly. An idea that used to take a weekend now takes an hour. So you run the test, look at a week of data, and accept "no effect" without it feeling wasteful.
Looking back
StoreKit 2 is a well-designed API once you understand a few non-obvious patterns: the listener must start at init, checkVerified() is never optional, transaction.finish() is required even on error paths that you want to retry, and subscription state requires checking both productType and revocationDate.
Claude Code makes this implementation process faster in two ways: generating the comprehensive boilerplate correctly the first time, and catching the edge cases that trip up manual implementations when you ask it to review your code. The combination of a centralized PurchaseManager, explicit subscription status modeling, and a layered test strategy gives you a foundation that handles the full lifecycle of in-app purchases in production.
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.