●PRICING — September 1 was the scheduled date for the Sonnet 5 price increase, and it did not happen. The introductory $2/$10 per MTok now stands as the regular price●PARTNER — Salesforce and Anthropic announced Claudeforce, an expanded partnership. The Salesforce in Claude plugin ships with 37 prebuilt sales skills, from meeting prep to pipeline management●TRUST — Claudeforce serves Claude through Amazon Bedrock inside the Salesforce Trust Boundary, so data and inference never leave the security perimeter — an answer aimed squarely at regulated industries●BETA — Salesforce in Claude is with select pilot customers for now, with an open beta expected during September●LIMITS — The 50% weekly-limit boost runs through September 13. From September 14 the permanent level is 25% above the pre-promotion baseline, roughly a 17% cut from today●RELEASE — Claude Code has shipped nothing since v2.1.251 on August 28. Against a pace of one release every 0.8 days, a four-day gap is among the longest yet●PRICING — September 1 was the scheduled date for the Sonnet 5 price increase, and it did not happen. The introductory $2/$10 per MTok now stands as the regular price●PARTNER — Salesforce and Anthropic announced Claudeforce, an expanded partnership. The Salesforce in Claude plugin ships with 37 prebuilt sales skills, from meeting prep to pipeline management●TRUST — Claudeforce serves Claude through Amazon Bedrock inside the Salesforce Trust Boundary, so data and inference never leave the security perimeter — an answer aimed squarely at regulated industries●BETA — Salesforce in Claude is with select pilot customers for now, with an open beta expected during September●LIMITS — The 50% weekly-limit boost runs through September 13. From September 14 the permanent level is 25% above the pre-promotion baseline, roughly a 17% cut from today●RELEASE — Claude Code has shipped nothing since v2.1.251 on August 28. Against a pace of one release every 0.8 days, a four-day gap is among the longest yet
Building StoreKit 2 × SwiftUI Purchases with Claude Code — The Minimum That Passes Review
Build a StoreKit 2 × SwiftUI purchase flow end to end with Claude Code as your design partner — PurchaseManager, server-side validation, and paywall UI — plus the operational traps Apple's docs skip, like Ask to Buy and late-arriving transactions.
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
✦A measured comparison showing a decode-only JWS parser accepting both a tampered payload and an alg:none token, plus why NODE_ENV must not decide the API environment
✦Why treating an Ask to Buy pending state as a failure gets flagged in review, and how to reproduce it in Sandbox
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
Client-side VerificationResult is checked by Apple itself, so if a feature lives entirely inside the app, that is enough. You need a server when subscription state has to be consistent across devices, when the same entitlement unlocks something on the web, or when refunds should be reflected server-side.
There is one thing worth pausing on before writing any code.
What the App Store Server API returns looks like JSON, but the part that matters — signedTransactionInfo — is a JWS: a signed token. If you pull the payload out without checking the signature and grant an entitlement from it, the function you named "server-side validation" validates nothing at all.
I ran the comparison rather than assuming. I signed a JWS with ES256, then produced two variants: one with the payload swapped to a different productId, and one with the header rewritten to alg: none. Both a decode-only parser and a signature-verifying parser were fed all three.
Input
Decode only (no signature check)
With signature verification
Legitimate token
Accepted → pro.monthly
Accepted → pro.monthly
Payload swapped to pro.lifetime
Accepted → pro.lifetime
Rejected (JWSSignatureVerificationFailed)
Hand-rolled alg: none token
Accepted → pro.lifetime
Rejected (JOSENotSupported)
The decode-only implementation accepted all three. A JWS payload is Base64URL-encoded, not encrypted, so skipping verification leaves you with a server that trusts a string the client handed it — including one claiming a lifetime license valid for the next hundred years.
Apple ships @apple/app-store-server-library, which returns already-verified objects and handles the certificate chain (leaf → intermediate → Apple Root CA G3) plus online revocation checks. Rolling your own with jose is possible, but the order matters: validate the chain in the JWS x5c header first, then verify the signature with the public key from the validated leaf certificate. Never trust a key that arrived inside the header you have not validated.
Don't decide the environment from NODE_ENV
The other trap is routing between production and Sandbox based on your own environment variable:
// ❌ Your server's environment and the transaction's environment are unrelatedconst baseUrl = process.env.NODE_ENV === 'production' ? 'https://api.storekit.itunes.apple.com' : 'https://api.storekit-sandbox.itunes.apple.com';
Transaction IDs live in separate namespaces per environment. A validation request arriving at your production server is not necessarily a production purchase: TestFlight purchases are Sandbox, and so is anything an App Review engineer buys while testing your app. Send a Sandbox transaction ID to the production endpoint and Apple answers with 4040010 TransactionIdNotFoundError.
Apple's documented approach is to let the response tell you the environment. Query production first; on 4040010, retry against Sandbox. If both return the same error, that transaction ID exists in neither.
Here is the implementation that follows it:
import { SignJWT, importPKCS8 } from 'jose';import { SignedDataVerifier, Environment } from '@apple/app-store-server-library';const PRODUCTION_URL = 'https://api.storekit.itunes.apple.com';const SANDBOX_URL = 'https://api.storekit-sandbox.itunes.apple.com';class AppStoreServerAPI { private readonly bundleId: string; private readonly issuerId: string; private readonly keyId: string; private readonly privateKey: string; private readonly appAppleId: number; // Apple Root CA G3 and friends, loaded as DER buffers private readonly rootCerts: Buffer[]; constructor(config: { bundleId: string; issuerId: string; keyId: string; privateKey: string; // YOUR_PRIVATE_KEY (contents of the .p8 file) appAppleId: number; rootCerts: Buffer[]; }) { this.bundleId = config.bundleId; this.issuerId = config.issuerId; this.keyId = config.keyId; this.privateKey = config.privateKey; this.appAppleId = config.appAppleId; this.rootCerts = config.rootCerts; } // JWT generation (60 minutes maximum lifetime) private async generateToken(): Promise<string> { const privateKey = await importPKCS8(this.privateKey, 'ES256'); return new SignJWT({ bid: this.bundleId }) .setProtectedHeader({ alg: 'ES256', kid: this.keyId, typ: 'JWT' }) .setIssuer(this.issuerId) .setIssuedAt() .setAudience('appstoreconnect-v1') .setExpirationTime('20m') // leave headroom if you cache and reuse the token .sign(privateKey); } private async request(path: string, baseUrl: string): Promise<Response> { const token = await this.generateToken(); return fetch(`${baseUrl}${path}`, { headers: { Authorization: `Bearer ${token}` }, }); } // Production first, Sandbox second — and report which one answered private async requestWithEnvironmentFallback( path: string ): Promise<{ body: unknown; environment: Environment }> { const production = await this.request(path, PRODUCTION_URL); if (production.ok) { return { body: await production.json(), environment: Environment.PRODUCTION }; } if (production.status === 404) { const { errorCode } = (await production.json().catch(() => ({}))) as { errorCode?: number; }; // 4040010 = TransactionIdNotFoundError — check the Sandbox side if (errorCode === 4040010) { const sandbox = await this.request(path, SANDBOX_URL); if (sandbox.ok) { return { body: await sandbox.json(), environment: Environment.SANDBOX }; } throw new Error(`transactionId not found in either environment: ${path}`); } } throw new Error(`App Store Server API error: ${production.status}`); } // Fetch the transaction and verify the JWS before handing it back async getVerifiedTransaction(transactionId: string) { const { body, environment } = await this.requestWithEnvironmentFallback( `/inApps/v1/transactions/${transactionId}` ); const { signedTransactionInfo } = body as { signedTransactionInfo: string }; // Includes certificate chain validation. Skipping this is not validation const verifier = new SignedDataVerifier( this.rootCerts, true, // enable online certificate revocation checking environment, this.bundleId, this.appAppleId ); return verifier.verifyAndDecodeTransaction(signedTransactionInfo); } async getSubscriptionStatuses(transactionId: string) { const { body } = await this.requestWithEnvironmentFallback( `/inApps/v1/subscriptions/${transactionId}` ); return body; }}export { AppStoreServerAPI };
verifyAndDecodeTransaction() throws on failure, which means that receiving a return value already establishes what you need: Apple signed this, it is addressed to your bundle ID, and it belongs to the environment you queried. Entitlement logic starts after that line, not before it.
Decide explicitly what a SANDBOX result should do. Unlock the feature for TestFlight testers, or treat it as a test and grant nothing? Leaving that undefined is how a free lifetime license ends up being distributed through TestFlight.
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
Apple's documentation explains the correct usage carefully, but says very little about what actually happens once real money starts moving. What follows are the behaviors that only surfaced in production, ordered roughly by how much debugging time each one cost.
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 my 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.
Don't Treat an Ask to Buy Pending State as a Failure
On devices where Family Sharing's Ask to Buy is enabled, tapping Buy doesn't complete anything. product.purchase() returns .pending and the purchase hangs there until a parent approves it. If you handle that branch with an error alert and nothing else, the UI stays silent when approval finally arrives — from the user's side, the money simply vanished. App Store review flags this.
The fix is to model pending as a distinct third state, and to assume approval arrives later through Transaction.updates.
enum PurchasePhase: Equatable { case idle case purchasing case awaitingApproval // Ask to Buy — waiting on the account holder case completed case failed(String)}@MainActorfunc purchase(_ product: Product) async { phase = .purchasing do { let result = try await product.purchase() switch result { case .success(let verification): let transaction = try checkVerified(verification) await updatePurchasedProducts() await transaction.finish() phase = .completed case .pending: // No Transaction exists yet — there is nothing to finish here phase = .awaitingApproval case .userCancelled: phase = .idle @unknown default: phase = .idle } } catch { phase = .failed(error.localizedDescription) }}
I once wrote code that tried to call Transaction.finish() inside the .pending branch, which crashes for the obvious reason that no transaction exists there. The approved purchase only ever reaches the Transaction.updates listener. Which means: if that listener isn't started in PurchaseManager.init, Ask to Buy purchases never land at all.
Reproducing it in Sandbox:
Create two Sandbox testers in App Store Connect and treat one as the "child" account
On a physical device, go to Settings → Developer → Sandbox Apple Account and sign in as the child tester
Turn on Ask To Buy on that same screen
Buy something in the app and confirm the flow lands in .pending
Approval requests appear in the Sandbox Apple Account screen — approve from there
Return the app to the foreground and confirm the entitlement arrives via Transaction.updates
Those six steps live permanently in my QA checklist. Approval latency varies from a few seconds to nearly a minute even in Sandbox, so the awaitingApproval screen says the purchase will activate automatically once approved — otherwise people tap Buy repeatedly.
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
Dolice Labs — four developer-focused sites (Claude Lab, Gemini Lab, Antigravity Lab, Rork Lab) — runs alongside the iOS app work. 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 I 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. I 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.
Splitting Revenue Between Ads and Subscriptions
Designing a subscription takes far more time in deliberation than in code. Back when ad revenue carried the whole catalog, I kept revisiting the idea of dropping ads entirely and going subscription-only. The hybrid model won in the end, and the reasoning is worth sharing because it took 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, I settled on free users see ads + all features; paid users see no ads + exclusive wallpapers. Keeping ads for free users was an empirical call: conversion to paid landed at 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, I 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, the numbers are ¥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. I deliberately avoid ".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 across my 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.
The server side has its own short list: verify the JWS signature and certificate chain before granting anything, and let the 4040010 response — not NODE_ENV — tell you which environment a transaction came from.
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.