●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Implementation Notes: Adding StoreKit 2 In-App Purchases to a Rork iOS App
Notes from grafting StoreKit 2 in-app purchases onto Swift/SwiftUI code generated by Rork, drawing on the StoreKit 1-to-2 migration done across a 50M-cumulative-download wallpaper-app portfolio. Covers ProductID design, transaction verification, paywall UI, and production gotchas.
A Rork-generated iOS app boots, navigates, and renders cleanly — but the paid feature gate is often left as a placeholder with a comment that says "implement purchase here." Apps that need IAP to ship will sit at this exact line until somebody writes the StoreKit code, and I have watched more than one indie developer abandon a near-finished app at this step.
I am Masaki Hirokawa. I have been a solo iOS/Android developer since 2014, with roughly 50 million cumulative downloads across a wallpaper-app portfolio (Beautiful Wallpapers, Ukiyo-e Wallpapers, Cool Wallpapers, Illustration Wallpapers, Dolice Wallpapers), and I also run four Lab sites and two blog sites in parallel. I migrated all five apps from StoreKit 1 to StoreKit 2 in 2025, so when I started adding IAP to Rork-generated apps the work was familiar.
These are notes from that grafting work. Aimed at developers with a working Rork-generated app blocked on IAP, or those looking for a StoreKit 2 example that matches Rork's code shape (Apple's official samples assume a different file structure).
Why You End Up Bolting StoreKit 2 onto Rork Code
Rork scaffolds the SwiftUI skeleton of an app, but it deliberately leaves IAP as placeholders. The reason is structural: IAP requires App Store Connect setup (ProductID creation, tax form submission, banking, contract acceptance) to even test, and Rork cannot validate against that side.
PurchaseManager.swift is empty, and your job is to fill it with StoreKit 2 logic. The SwiftUI base makes this pleasant — StoreKit 2 integrates naturally without the StoreKit 1 ceremony of forcing SKProductsRequestDelegate through SwiftUI's lifecycle.
Having just migrated five wallpaper apps from StoreKit 1 to 2, the Rork grafting felt like rerunning that migration on a clean canvas. If you are coming to StoreKit 2 fresh, expect a small learning curve. This guide is written for that audience and covers the whole loop end to end.
App Store Connect Prep — The Real Bottleneck
Before any code, App Store Connect needs setup. This is where I see most indie developers stall.
First, complete the IAP contract and tax forms in App Store Connect. Without them IAP does not work in sandbox or production. Fill the entire "Tax, Banking, and Trade Representative Contact Information" section.
Second, decide a ProductID naming convention and create the products. My convention:
Pick a convention before you create anything. If app #1 has premium_full and app #2 has unlock_all, your shared code grows branches. Standardizing upfront is a practical recommendation for indie operations.
Third, the same ProductID is used in sandbox and production. Apple sandbox testing and production purchases share the ID, so changing it later is structurally hard. Choose carefully on day one.
✦
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
✦Concrete implementation steps and a folder layout for grafting StoreKit 2 onto the Swift/SwiftUI code Rork generates — full working code included.
✦ProductID design and transaction-verification pitfalls learned from migrating a 50M-download wallpaper-app portfolio from StoreKit 1 to StoreKit 2.
✦A practical decision matrix for choosing between plain StoreKit 2 and RevenueCat at indie scale, with the financial and operational cutover points spelled out.
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.
StoreKit 2 is built around Swift Concurrency. The delegate burden is gone, and the natural integration shape with SwiftUI is a @MainActorObservableObject.
// PurchaseManager.swiftimport StoreKitimport SwiftUI@MainActorfinal class PurchaseManager: ObservableObject { @Published private(set) var products: [Product] = [] @Published private(set) var purchasedProductIDs: Set<String> = [] private var updates: Task<Void, Never>? = nil static let productIDs = [ "com.dolice.wallpaper.nonconsumable.premium", "com.dolice.wallpaper.consumable.pack_50", ] init() { // Listen for Transaction updates in background updates = observeTransactionUpdates() } deinit { updates?.cancel() } // Fetch product info from App Store func loadProducts() async throws { products = try await Product.products(for: Self.productIDs) } // Execute purchase func purchase(_ product: Product) async throws -> Bool { let result = try await product.purchase() switch result { case .success(let verification): let transaction = try checkVerified(verification) await transaction.finish() await updatePurchasedProducts() return true case .userCancelled: return false case .pending: return false @unknown default: return false } } // JWS-based verification handled by StoreKit 2 func checkVerified<T>(_ result: VerificationResult<T>) throws -> T { switch result { case .verified(let safe): return safe case .unverified: throw PurchaseError.verificationFailed } } // Refresh current entitlements func updatePurchasedProducts() async { var purchased: Set<String> = [] for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { continue } if transaction.revocationDate == nil { purchased.insert(transaction.productID) } } purchasedProductIDs = purchased } // Continuous Transaction observation private func observeTransactionUpdates() -> Task<Void, Never> { Task { [weak self] in for await result in Transaction.updates { guard let self else { return } guard case .verified(let transaction) = result else { continue } await transaction.finish() await self.updatePurchasedProducts() } } } enum PurchaseError: Error { case verificationFailed }}
Three points matter. (1) @MainActor keeps SwiftUI's @Published updates on the main thread, avoiding UI race conditions. (2) Transaction.updates observation starts in init — without it, promotional offers and family-shared purchases will be missed. (3) Always call transaction.finish() — skipping it makes the same prompt re-appear on next launch.
The .task modifier is the SwiftUI-native way to fire loadProducts() — it auto-cancels when the View unmounts, unlike onAppear.
@EnvironmentObject requires injection at the app entry point:
// MyAppApp.swift@mainstruct MyAppApp: App { @StateObject private var purchaseManager = PurchaseManager() var body: some Scene { WindowGroup { ContentView() .environmentObject(purchaseManager) } }}
Gating "Premium" Features
Use PurchaseManager.purchasedProductIDs to gate features app-wide. In a Rork wallpaper app there are usually several places that need a "Premium only" check.
// WallpaperDetailView.swiftstruct WallpaperDetailView: View { let wallpaper: WallpaperItem @EnvironmentObject var purchaseManager: PurchaseManager @State private var showPaywall = false var isPremiumUnlocked: Bool { purchaseManager.purchasedProductIDs.contains( "com.dolice.wallpaper.nonconsumable.premium" ) } var body: some View { VStack { if wallpaper.isPremium && !isPremiumUnlocked { // blur preview + paywall button Image(wallpaper.imageName) .resizable() .blur(radius: 20) Button("Unlock with Premium") { showPaywall = true } .buttonStyle(.borderedProminent) } else { Image(wallpaper.imageName) .resizable() Button("Save wallpaper") { saveWallpaper() } } } .sheet(isPresented: $showPaywall) { PremiumGateView() } }}
Blurred previews convert better than hidden content. Hiding it entirely leaves users unsure what they would buy; blurring makes the value intuitive at a glance. In my own portfolio, introducing blurred previews raised IAP conversion by roughly 18%.
Choosing Between Plain StoreKit 2 and RevenueCat
StoreKit 2 can be used directly, but IAP management SaaS like RevenueCat exists. At indie scale, the trade-offs:
Factor
Plain StoreKit 2
RevenueCat
Initial implementation time
half day to one day
half day (SDK setup)
ProductID management
App Store Connect only
App Store Connect + RevenueCat dashboard
Receipt verification
on-device
RevenueCat server
Monthly cost
$0
Free up to $2,500 MTR, then 1% take
Churn / cancellation analytics
none
available
Server integration
write your own receipt verifier
webhook-based
Cross-platform
iOS/Android separately
unified
My wallpaper apps are iOS-only at ~$1,000/month, so I run plain StoreKit 2 with no SaaS. Monthly cost stays at $0.
For Lab sites I use Stripe heavily for membership analytics and price A/B tests. If the apps reach the point where I need the same depth on iOS, RevenueCat is the natural migration.
The practical path for most indie developers is "start with plain StoreKit 2; migrate to RevenueCat when analytics needs justify it." RevenueCat migration preserves ProductIDs and is essentially an SDK swap, so doing it later does not paint you into a corner.
Five Gotchas Worth Knowing for Production
Things to watch for before shipping.
First, use a separate Apple ID for sandbox testing. Logging into sandbox with your production Apple ID can corrupt your real transaction history. Create a Sandbox Apple ID in App Store Connect under Users and Access, and sign into it on-device under Settings > App Store > Sandbox Account.
Second, start Transaction.updates observation at launch, always. Promotional offers and family-shared purchases arrive that way. Skipping this produces "I bought it but cannot restore" support tickets.
Third, .task is preferred over .onAppear for loadProducts()..task auto-cancels with the View; .onAppear will re-fire the API call on every re-render.
Fourth, always use product.displayPrice for price display. Hand-coding "¥250" produces currency mismatches across regions and earns one-star reviews. displayPrice localizes currency symbols and formatting automatically.
Fifth, check Transaction.revocationDate. Family-shared purchases can be revoked by the parent Apple ID and still appear in currentEntitlements, just with a revocationDate set. Missing this check leaves Premium unlocked for revoked purchases.
Numbers from Production
Comparison after migrating five apps from StoreKit 1 to StoreKit 2:
IAP failure rate: ~2.5% → 0.4% (-84%)
Receipt-verification support tickets: 8/month → 1/month
Restore-purchase success rate: ~92% → 99%
IAP-related lines of code: ~600 → ~280
The line-count drop is small in absolute terms but materially easier to maintain. Losing SKProductsRequestDelegate and SKPaymentTransactionObserver and writing everything in async/await produces drastically more readable code.
The fit with Rork-generated code is the operational bonus. Rork emits SwiftUI-first code, so the division of labor becomes "Rork builds the skeleton, you hand-write the StoreKit 2 portion." Both pieces are SwiftUI-native and stitch cleanly.
What I Plan to Add Next
Three open items.
First, server-side receipt verification. Plain StoreKit 2 verifies JWS on-device, which is theoretically bypassable on jailbroken devices. Low impact for a freemium wallpaper app, but apps with higher per-item revenue should consider it. The App Store Server API (formerly Server-to-Server Notifications V2) is the path.
Second, SwiftUI paywall A/B testing. The same Antigravity parallel-agent loop I use for AdMob optimization should work for paywall variants — switch UI via Remote Config, evaluate conversion rate via Evaluator.
Third, rehearsing the RevenueCat migration. When revenue grows enough to justify the analytics, I want to be ready to move quickly. Writing the migration steps once gives an option I can exercise instantly.
If you are building an iOS app in Rork and stuck on the purchase layer, I hope these notes help. From experience, Rork's iteration speed combined with StoreKit 2's async/await produces a step-change in indie productivity. Notes for fellow creators making things on Rork.
Share
Thank You for Reading
Rork 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.