Migrating an iOS app from Paid up Front to Freemium
Published on: January 30, 2026Paid up front apps can be a tough sell on the App Store. You might be getting plenty of views on your product page, but if those views aren't converting to downloads, something has to change. That's exactly where I found myself with Maxine: decent traffic, almost no sales.
So I made the switch to freemium, even though I didn't really want to. In the end, the data was pretty obvious and I've been getting feedback from other devs too. Free downloads with optional in-app purchases convert better and get more users through the door. After thinking about the best way to make the switch, I decided that existing users get lifetime access for free, and new users get 5 workouts before they need to subscribe or unlock a lifetime subscription. That should give them plenty of time to properly try and test the app before they commit to buying.
In this post, we'll explore the following topics:
- How to grandfather in existing users using StoreKit receipt data
- Testing gotchas you'll run into and how to work around them
- The release sequence that ensures a smooth transition
By the end, you'll know how to migrate your own paid app to freemium without leaving your loyal early adopters behind.
Grandfathering in users through StoreKit
Regardless of how you implement in-app purchases, you can use StoreKit to check when a user first installed your app. This lets you identify users who paid for the app before it went free and automatically grant them lifetime access.
You can do this using the AppTransaction API in StoreKit. It gives you access to the original app version and original purchase date for the current device. It's a pretty good way to detect users that have bought your app pre-freemium.
Here's how to check the first installed version (which is what I did for Maxine):
import StoreKit
func isLegacyPaidUser() async -> Bool {
do {
let appTransaction = try await AppTransaction.shared
switch appTransaction {
case .verified(let transaction):
// The version string from the first install
let originalVersion = transaction.originalAppVersion
// Compare against your last paid version
// For example, if version 2.0 was your first free release
if let version = Double(originalVersion), version < 2.0 {
return true
}
return false
case .unverified:
// Transaction couldn't be verified, treat as new user
return false
}
} catch {
// No transaction available
return false
}
}Since this logic could potentially cause you missing out on revenue, I highly recommend writing a couple of unit tests to ensure your legacy checks work as intended. My approach to testing the legacy check involved having a method that would take the version string from AppTransaction and check it against my target version. That way I know that my test is solid. I also made sure to have tests like making sure that users that were marked pro due to version numbering were able to pass all checks done in my ProAccess helper. For example, by checking that they're allowed to start a new workout.
If you want to learn more about Swift Testing, I have a couple of posts in the Testing category to help you get started.
I opted to go for version checking, but you could also use the original purchase date if that fits your situation better:
import StoreKit
func isLegacyPaidUser(cutoffDate: Date) async -> Bool {
do {
let appTransaction = try await AppTransaction.shared
switch appTransaction {
case .verified(let transaction):
// When the user first installed (purchased) the app
let originalPurchaseDate = transaction.originalPurchaseDate
// If they installed before your freemium launch date, they're legacy
return originalPurchaseDate < cutoffDate
case .unverified:
return false
}
} catch {
return false
}
}
// Usage: check if installed before your freemium release
let isLegacy = await isLegacyPaidUser(
cutoffDate: DateComponents(
calendar: .current,
year: 2026,
month: 1,
day: 30
).date!
)Again, if you decide to ship a solution like this I highly recommend that you add some unit tests to avoid mistakes that could cost you revenue.
The version approach works well when you have clear version boundaries. The date approach is useful if you're not sure which version number will ship or if you want more flexibility.
Once you've determined the user's status, you'll want to persist it locally so you don't have to check the receipt every time:
import StoreKit
actor EntitlementManager {
static let shared = EntitlementManager()
private let defaults = UserDefaults.standard
private let legacyUserKey = "isLegacyProUser"
var hasLifetimeAccess: Bool {
defaults.bool(forKey: legacyUserKey)
}
func checkAndCacheLegacyStatus() async {
// Only check if we haven't already determined status
guard !defaults.bool(forKey: legacyUserKey) else { return }
let isLegacy = await isLegacyPaidUser()
if isLegacy {
defaults.set(true, forKey: legacyUserKey)
}
}
private func isLegacyPaidUser() async -> Bool {
do {
let appTransaction = try await AppTransaction.shared
switch appTransaction {
case .verified(let transaction):
if let version = Double(transaction.originalAppVersion), version < 2.0 {
return true
}
return false
case .unverified:
return false
}
} catch {
return false
}
}
}My app is a single-device app, so I don't have multi-device scenarios to worry about. If your app syncs data across devices, you might want a more involved solution. For example, you could store a "legacy pro" marker in CloudKit or on your server so the entitlement follows the user's iCloud account rather than being tied to a single device.
Also, storing in UserDefaults is a somewhat naive approach. Depending on your minimum OS version, you might run your app in a potentially jailbroken environment; this would allow users to tamper with UserDefaults quite easily and it would be much more secure to store this information in the keychain, or to check your receipt every time instead. For simplicity I'm using UserDefaults in this post, but I recommend you make a proper security risk assessment on which approach works for you.
With this code in place, you're all set up to start testing...
Testing gotchas
Testing receipt-based grandfathering has some quirks you should know about before you ship.
TestFlight always reports version 1.0
When your app runs via TestFlight it runs in a sandboxed environment and AppTransaction.originalAppVersion returns "1.0" regardless of which build the tester actually installed. This makes it impossible to test version-based logic through TestFlight alone.
You can get around this using debug builds with a manual toggle that lets you simulate being a legacy user. Add a hidden debug menu or use launch arguments to override the legacy check during development.
#if DEBUG
var debugOverrideLegacyUser: Bool? = nil
#endif
func isLegacyPaidUser() async -> Bool {
#if DEBUG
if let override = debugOverrideLegacyUser {
return override
}
#endif
// Normal receipt-based check...
}Reinstalls reset the original version.
If a user deletes and reinstalls your app, the originalAppVersion reflects the version they reinstalled, not their very first install. This is a limitation of on-device receipt data. If you've written the user's pro-status to the keychain, you would actually be able to pull the pro status from there.
Sadly I haven't found a fail-proof way to get around reinstalls and receipts resetting. For my app, this is acceptable. I don't have that many users so I think we'll be okay in terms of risk of someone losing their legacy pro access.
Device clock manipulation.
Users with incorrect device clocks could work their way around your date-based checks. That's why I went with version-based checking but again, it's all a matter of determining what an acceptable risk is for you and your app.
Making the move
When you're ready to release, the sequence matters. Here's what I did:
Set your app to manual release. In App Store Connect, configure your new version for manual release rather than automatic. This gives you control over timing.
Add a note for App Review. In the reviewer notes, explain that you'll switch the app's price to free before releasing. Something like: "This update transitions the app from paid to freemium. I will change the price to free in App Store Connect before releasing this version to ensure a smooth transition for users."
Wait for approval. Let App Review approve your build while it's still technically a paid app.
Make the app free first. Once approved, go to App Store Connect and change your app's price to free (or set up your freemium pricing tiers).
Then release. After the price change is live, manually release your approved build.
I'm not 100% sure the order matters, but making the app free before releasing felt like the safest approach. It ensures that the moment users can download your new freemium version, they're not accidentally charged for the old paid model.
In Summary
Grandfathering paid users when switching to freemium comes down to checking AppTransaction for the original install version or date. Cache the result locally, and consider CloudKit or server-side storage if you need cross-device entitlements.
Testing is tricky because TestFlight always reports version 1.0 and sandbox receipts don't perfectly mirror production. Use debug toggles and, ideally, a real device with an older App Store build for thorough testing.
When you release, set your build to manual release, add a note for App Review explaining the transition, then make the app free before you tap the release button.
Changing your monetization strategy can feel like admitting defeat, but it's really just iteration. The App Store is competitive, user expectations shift, and what worked at launch might not work six months later. Pay attention to your conversion data, be willing to adapt, and don't let sunk-cost thinking keep you stuck with a model that isn't serving your users or your business.


