ANTIGRAVITY LABJP
Articles/App Development
App Development/2026-06-12Intermediate

Replacing a Decade-Old SKPaymentQueue with StoreKit 2 — Migrating 4 Wallpaper Apps with an Antigravity Agent

A field report on migrating SKPaymentQueue-based purchase code to StoreKit 2's Transaction.currentEntitlements across four wallpaper apps — what I delegated to an Antigravity agent, and the traps I only found on real devices.

storekit2ios26in-app-purchaseantigravity340swift6

In May 2026, while working through the annual update of four iOS wallpaper apps, I noticed that the StoreKit deprecation warnings scrolling past in Xcode 26's build log had grown too numerous to ignore. The purchase code in those apps was still the SKPaymentQueue implementation I wrote around 2015 — running nearly untouched for a decade.

Since starting out as an indie developer in 2014, I've slowly grown my apps to over 50 million cumulative downloads, and through all those years my unspoken rule was simple: never touch billing code that works. Payments are a zero-mistake zone, and the risk of a rewrite never seemed worth the reward. What changed my mind was timing. As I described in Firebase Apple SDK Migration from CocoaPods to SPM: 3 Pitfalls from 4 Real Apps, I was already overhauling the build system — and a build-system overhaul is a once-in-several-years window. Moving the billing layer inside that same window turned out to be the safest option, not the riskiest.

I hope this record helps anyone else staring at a pile of ten-year-old purchase code and wondering whether it's finally time.

Why Replace Code That Ran for Ten Years

Three things tipped the decision.

First, the restore experience had quietly degraded. The legacy restoreCompletedTransactions() can prompt for the Apple ID password, and every month brought support emails from users who had switched phones and "lost" their ad-free purchase. StoreKit 2's Transaction.currentEntitlements enumerates currently valid entitlements directly, which makes the whole concept of a "Restore" button nearly obsolete.

Second, receipt validation. The old world forced a choice between running a server that validates receipts with openssl, or doing client-side validation and accepting the risk. In StoreKit 2, every transaction arrives as a JWS-signed, already-verified value from the API. For a solo developer, not having to maintain a validation server is reason enough on its own.

Third, async/await. The rest of my codebase had steadily moved to Swift Concurrency, leaving the delegate-based SKPaymentTransactionObserver stranded in the old world, with glue code accumulating around it year after year. Every new feature that touched purchase state needed a continuation wrapper or a completion-handler bridge, and each bridge was one more place where a transaction callback could be silently dropped.

There was also a quieter, fourth reason that I suspect applies to many long-running indie apps: nobody on the team — which in my case means nobody but me — fully remembered why every line of the old billing code existed. Code you no longer understand is code you cannot safely modify, and the longer I waited, the worse that asymmetry would get. Migrating while I could still reconstruct the original intent felt like paying down a debt before the interest compounded again.

Before / After — How Much Shorter Purchase Code Becomes

Here is the skeleton of the code that ran for ten years:

// Before: SKPaymentQueue-based (written around 2015)
// Registration, purchase, restore, and validation all live in different places
class IAPManager: NSObject, SKPaymentTransactionObserver {
    func purchase(productID: String) {
        let payment = SKMutablePayment()
        payment.productIdentifier = productID
        SKPaymentQueue.default().add(payment) // result arrives via delegate
    }
 
    func paymentQueue(_ queue: SKPaymentQueue,
                      updatedTransactions transactions: [SKPaymentTransaction]) {
        for tx in transactions {
            switch tx.transactionState {
            case .purchased, .restored:
                // ...and from here it continues into receipt validation
                SKPaymentQueue.default().finishTransaction(tx)
            default: break
            }
        }
    }
}

With StoreKit 2, purchasing and entitlement checks collapse into this:

// After: StoreKit 2 (iOS 15+ / the skeleton of the code now shipping in all four apps)
import StoreKit
 
@MainActor
final class EntitlementStore: ObservableObject {
    @Published private(set) var isAdFree = false
 
    // Call right after launch: enumerate valid entitlements and rebuild state
    func refresh() async {
        var adFree = false
        for await result in Transaction.currentEntitlements {
            // Anything other than .verified (possible tampering) is rejected here
            guard case .verified(let tx) = result else { continue }
            // Refunded or family-sharing-revoked purchases carry a revocationDate
            if tx.productID == "com.example.adfree", tx.revocationDate == nil {
                adFree = true
            }
        }
        isAdFree = adFree
        print("entitlements refreshed: isAdFree=\(isAdFree)")
        // Expected output on a device that owns the purchase:
        // entitlements refreshed: isAdFree=true
    }
 
    // Purchase: the result is a return value. No delegate needed
    func purchaseAdFree() async throws {
        guard let product = try await Product.products(for: ["com.example.adfree"]).first else { return }
        let result = try await product.purchase()
        if case .success(let verification) = result,
           case .verified(let tx) = verification {
            await tx.finish() // forgetting finish() leaves the transaction pending forever
            await refresh()
        }
    }
}

Delegates, receipt validation, and restore flows simply disappear. Across the four apps, the billing layer shrank to roughly one third of its former line count. The real work of this migration was not the rewriting — it was deciding what was safe to delete.

What I Delegated to the Antigravity Agent — and What I Didn't

The mechanical rewriting across four apps went to an Antigravity agent. The setup mirrored what I described in Antigravity × Xcode 26 × iOS 26 — iOS Developer Guide Before WWDC 2026: let Planning mode draft the migration plan, review the first app line by line as a human, then fan the pattern out to the remaining three.

Two practical details made the delegation work better than my earlier attempts at this kind of fan-out. First, I gave the agent the migrated first app as a reference implementation inside the workspace, rather than describing the target pattern in prose — diffing against working code anchors an agent far more reliably than instructions do. Second, I asked it to produce the change as a series of small, per-file commits instead of one sweeping rewrite, which made the human review pass on apps two through four take minutes rather than hours.

But there was one place the agent was never allowed to touch: the final ad-visibility decision. My apps combine a one-time ad-removal purchase with a temporary ad-free state earned by watching rewarded ads, and every ad decision must pass through the composite check isAdFree || isRewardAdFree. At one point the agent, reasoning purely within the migration context, judged that composite check "redundant" and tried to simplify it. An automated rewrite there would have broken both revenue and user trust at once. The boundary I held throughout: mechanical API substitution goes to the agent; any logic that decides the flow of money stays with me.

Three Traps That Only Appeared on Real Devices

Testing during the rollout surfaced three surprises.

  • Missing the Transaction.updates listener. Transactions that arrive outside your purchase flow — an Ask to Buy approval, for example — come through Transaction.updates and must be finished there. Without a listener running from launch, unfinished transactions resurrected themselves on every restart, repeatedly showing the purchase dialog.
  • Entitlements can look empty on a fresh offline launch. currentEntitlements normally answers from a local cache, but immediately after a reinstall, while offline, the enumeration can come back empty. Treating "empty" as "not purchased" and flipping the UI is a mistake; I now keep the previous verdict and let the next online refresh overwrite it.
  • Skipping the revocationDate check. The agent's first draft treated every enumerated entitlement as valid, without filtering refunded transactions. A sandbox refund test caught it — but I still get a chill thinking about what would have shipped without that review.

These are the kinds of traps you don't discover in the order the documentation presents them. As with Surviving New iPhone Resolution Support with Antigravity — 29 Changes in DefineManager.h, One Honest Recap, the real device ended up being the best teacher.

Your Next Step — Start with Code That Only Reads

If you still have SKPaymentQueue-era purchase code in production, don't start by rewriting it. Add a diagnostic snippet that merely enumerates Transaction.currentEntitlements and logs the results. It changes nothing in your legacy implementation, yet it lets you observe the gap between the entitlement world StoreKit 2 sees and the purchase state your app believes in. In my case, that list of gaps became the migration plan itself.

One more recommendation before you write any migration code: set up a StoreKit Configuration file in Xcode and rehearse the whole flow locally — purchases, refunds, Ask to Buy — before going anywhere near the sandbox. Two of the three traps above are reproducible entirely on a simulator with a local configuration, and finding them there is a far gentler experience than finding them in TestFlight feedback. The rehearsal costs an afternoon; each trap found later costs a release cycle.

Replacing code that ran for ten years still carries a residual fear, I'll admit. But once you're living in a world where verified transactions arrive as typed values from the language itself, the years spent wrestling with receipt validation feel like a different era. If the same migration is ahead of you, I hope this record makes the path a little clearer.

Share

Thank You for Reading

Antigravity 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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

App Dev2026-05-20
Two Weeks of Letting Antigravity Translate Localizable.xcstrings Across 8 Languages
A two-week log of letting Antigravity draft 8-language translations for new keys added to Localizable.xcstrings. What I delegated, what I kept under my own judgment, the unexpected behaviors, and how I reconciled drafts against the existing translation corpus.
App Dev2026-05-13
Implementing Google ML Kit with Antigravity: What the Docs Don't Tell You
A practical guide to integrating Google ML Kit into iOS and Android apps with Antigravity. Covers text recognition, face detection, the Xcode 15 SPM bug workaround, and honest notes on where AI assistance helps — and doesn't.
App Dev2026-05-04
Swift Testing × Antigravity — Beyond XCTest to AI-Driven Test Design
A comprehensive guide to transforming iOS test quality using Swift Testing and Antigravity together. Covers @Test and #expect macros in practice, parameterized testing, async patterns, XCTest migration strategy, and AI-powered test generation.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →