Check For A New App Version Using Apple APIs
I saw a nice app update prompt recently: a simple alert said a new version was available, and tapping Update opened the App Store page for the app.
That raises a good question:
Can an iOS app check the App Store version without maintaining a version number on your own server?
The practical answer is yes, with one important caveat.
There is not a StoreKit API that says “what is the latest App Store version of this app?” But Apple does provide the public iTunes Search and Lookup API. You can call the Lookup endpoint from your app, ask for the record with your bundle identifier, read the returned version, compare it to your installed CFBundleShortVersionString, and open the App Store page if the store version is newer.
So this is not a pure local/native-only check. It is a network call to Apple’s public store metadata. But it does not require your server, your database, App Store Connect API keys, or a manually maintained “latest version” value.
When this is a good fit
Use this for a friendly update prompt:
- “A newer version is available.”
- “Update to get the latest fixes.”
- “Open the App Store.”
Do not use this as your only mandatory upgrade system. If a version must be blocked because an API changed, a security issue exists, or a backend contract is no longer compatible, keep that policy on your server. The Lookup API tells you what Apple’s public store metadata returns for a storefront. It is not a guaranteed real-time policy engine for every user and every device.
A few edges to plan for:
- The app may not be live in the selected country.
- The App Store response can vary by storefront.
- App Review, phased release, propagation, and caching can make timing feel fuzzy.
- TestFlight and debug builds may not match the public App Store record.
- A user may be on an OS version that cannot install the newest App Store build.
One phased-release detail is easy to miss: Apple’s phased release documentation says phased release controls automatic updates, but anyone can still manually download the update from the App Store during the phased release. If you rely on phased release to reduce rollout risk, do not turn on an aggressive in-app update prompt for that version.
That still leaves a very useful feature. For most apps, a low-pressure prompt that appears once in a while is enough.
Apple’s archived iTunes Search API docs describe the Search and Lookup endpoints, including software results, JSON responses, storefront country codes, and a rate-limit note. The Lookup examples show the /lookup endpoint. Apple’s current docs for CFBundleShortVersionString describe the installed marketing version string we compare against.
The archived Search API page says the API is limited to approximately 20 calls per minute, subject to change, and recommends caching for larger usage. The once-per-day gate later in this post is intentionally conservative. Do not retry aggressively. For very high-scale apps, or if you need shared caching and policy control, proxy this through your own backend.
What the Lookup API returns
The request shape is small:
https://itunes.apple.com/lookup?bundleId=com.example.app&country=us
For a live app, the response includes a resultCount and a results array. App records usually include fields such as:
trackId: the numeric App Store item IDbundleId: the bundle identifierversion: the current App Store marketing versiontrackViewUrl: the App Store product page URLreleaseNotes: the current release notes, when availablecurrentVersionReleaseDate: the current version release dateminimumOsVersion: the minimum OS version for the current App Store build
We only need a few of those.
Build the lookup client
Start with a small decodable model.
import Foundation
struct AppStoreLookupResponse: Decodable {
let resultCount: Int
let results: [AppStoreListing]
}
struct AppStoreListing: Decodable {
let trackId: Int
let bundleId: String
let version: String
let trackViewUrl: URL
let releaseNotes: String?
let currentVersionReleaseDate: Date?
let minimumOsVersion: String?
}
struct AppUpdate: Equatable {
let appStoreID: Int
let installedVersion: String
let latestVersion: String
let appStoreURL: URL
let releaseNotes: String?
}
Then create an actor that performs the lookup. I like making the country code explicit. You can default to "us", store the user’s preferred storefront in settings, or choose a storefront from your own app’s region logic.
One small StoreKit footgun: the Lookup API country parameter is a two-letter ISO country code such as us. StoreKit’s Storefront.countryCode is documented as a three-letter code, so do not pass it straight into this URL without mapping it.
import Foundation
actor AppStoreVersionChecker {
enum LookupError: Error {
case missingBundleIdentifier
case badStatusCode(Int)
case appNotFound
case invalidInstalledVersion(String)
case invalidStoreVersion(String)
}
private let bundleID: String
private let countryCode: String
private let session: URLSession
init(
bundleID: String? = Bundle.main.bundleIdentifier,
countryCode: String = "us",
session: URLSession = .shared
) throws {
guard let bundleID, !bundleID.isEmpty else {
throw LookupError.missingBundleIdentifier
}
self.bundleID = bundleID
self.countryCode = countryCode
self.session = session
}
func availableUpdate(
installedVersion: String? = Bundle.main.object(
forInfoDictionaryKey: "CFBundleShortVersionString"
) as? String
) async throws -> AppUpdate? {
guard let installedVersion, !installedVersion.isEmpty else {
throw LookupError.invalidInstalledVersion("missing")
}
let listing = try await fetchListing()
guard let installed = AppVersion(installedVersion) else {
throw LookupError.invalidInstalledVersion(installedVersion)
}
guard let latest = AppVersion(listing.version) else {
throw LookupError.invalidStoreVersion(listing.version)
}
guard installed < latest else {
return nil
}
return AppUpdate(
appStoreID: listing.trackId,
installedVersion: installedVersion,
latestVersion: listing.version,
appStoreURL: listing.trackViewUrl,
releaseNotes: listing.releaseNotes
)
}
private func fetchListing() async throws -> AppStoreListing {
var components = URLComponents(string: "https://itunes.apple.com/lookup")!
components.queryItems = [
URLQueryItem(name: "bundleId", value: bundleID),
URLQueryItem(name: "country", value: countryCode.lowercased())
]
let (data, response) = try await session.data(from: components.url!)
guard let httpResponse = response as? HTTPURLResponse else {
throw LookupError.badStatusCode(-1)
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw LookupError.badStatusCode(httpResponse.statusCode)
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let decoded = try decoder.decode(AppStoreLookupResponse.self, from: data)
guard let listing = decoded.results.first else {
throw LookupError.appNotFound
}
return listing
}
}
Compare versions numerically
Do not compare version strings with plain string comparison. "1.10" should be newer than "1.9", even though lexicographic string sorting can lead you the wrong way.
Apple documents CFBundleShortVersionString as a period-separated numeric version string. Parse the parts as integers and compare each segment.
import Foundation
struct AppVersion: Comparable {
let rawValue: String
private let parts: [Int]
init?(_ rawValue: String) {
let segments = rawValue
.split(separator: ".", omittingEmptySubsequences: false)
guard (1...3).contains(segments.count) else {
return nil
}
guard segments.allSatisfy({ segment in
!segment.isEmpty && segment.utf8.allSatisfy { (48...57).contains($0) }
}) else {
return nil
}
var parts: [Int] = []
for segment in segments {
guard let part = Int(segment) else {
return nil
}
parts.append(part)
}
self.rawValue = rawValue
self.parts = parts
}
static func < (lhs: AppVersion, rhs: AppVersion) -> Bool {
let count = max(lhs.parts.count, rhs.parts.count)
for index in 0..<count {
let left = index < lhs.parts.count ? lhs.parts[index] : 0
let right = index < rhs.parts.count ? rhs.parts[index] : 0
if left != right {
return left < right
}
}
return false
}
}
This treats 1.2 and 1.2.0 as equal, which is usually what you want.
Apple documents the marketing version as three period-separated integers. This helper accepts one to three numeric segments because many real app projects have historically used shorter marketing versions. If your project enforces Apple’s exact documented shape, change the (1...3) guard to require exactly 3.
If your app has historically shipped marketing versions that do not follow Apple’s numeric format, normalize them before this comparison. I would rather fail closed and skip the prompt than accidentally tell people to update when they are already current.
Show a SwiftUI update prompt
Now wrap the checker in a tiny view model. This example checks at most once per calendar day and lets the user skip a specific version.
import SwiftUI
@MainActor
final class AppUpdatePromptModel: ObservableObject {
@Published var update: AppUpdate?
private let checker: AppStoreVersionChecker?
init(countryCode: String = "us") {
self.checker = try? AppStoreVersionChecker(countryCode: countryCode)
}
func check() async {
guard let checker, AppUpdatePromptGate.canCheckToday() else {
return
}
AppUpdatePromptGate.markChecked()
do {
guard let update = try await checker.availableUpdate() else {
return
}
guard AppUpdatePromptGate.shouldPrompt(for: update.latestVersion) else {
return
}
self.update = update
} catch {
// Do not block launch because App Store metadata was unavailable.
// Log this if you have diagnostics, then quietly try another day.
}
}
func skipCurrentVersion() {
if let update {
AppUpdatePromptGate.skip(version: update.latestVersion)
}
update = nil
}
func clear() {
update = nil
}
}
enum AppUpdatePromptGate {
private static let lastCheckKey = "appUpdatePrompt.lastCheck"
private static let skippedVersionKey = "appUpdatePrompt.skippedVersion"
static func canCheckToday(
now: Date = .now,
calendar: Calendar = .current
) -> Bool {
guard let lastCheck = UserDefaults.standard.object(
forKey: lastCheckKey
) as? Date else {
return true
}
return !calendar.isDate(lastCheck, inSameDayAs: now)
}
static func markChecked(now: Date = .now) {
UserDefaults.standard.set(now, forKey: lastCheckKey)
}
static func shouldPrompt(for version: String) -> Bool {
UserDefaults.standard.string(forKey: skippedVersionKey) != version
}
static func skip(version: String) {
UserDefaults.standard.set(version, forKey: skippedVersionKey)
}
}
Then attach the prompt near the root of your app.
import SwiftUI
struct RootView: View {
@StateObject private var updateModel = AppUpdatePromptModel(countryCode: "us")
@Environment(\.openURL) private var openURL
var body: some View {
ContentView()
.task {
await updateModel.check()
}
.alert(
"Update Available",
isPresented: isShowingUpdateAlert,
presenting: updateModel.update
) { update in
Button("Skip This Version", role: .cancel) {
updateModel.skipCurrentVersion()
}
Button("Update") {
openURL(update.appStoreURL)
updateModel.clear()
}
} message: { update in
Text(
"Version \(update.latestVersion) is available. " +
"You are running \(update.installedVersion)."
)
}
}
private var isShowingUpdateAlert: Binding<Bool> {
Binding {
updateModel.update != nil
} set: { isPresented in
if !isPresented {
updateModel.clear()
}
}
}
}
That openURL call sends the user to the trackViewUrl returned by Apple. On iOS, an App Store product URL normally opens the App Store app.
If you prefer an in-app sheet, use SKStoreProductViewController and load the product with SKStoreProductParameterITunesItemIdentifier. Apple’s Product Dictionary Keys page says that item identifier key is required for loading a specific product page.
For update prompts, I usually prefer opening the App Store app. It is familiar, it clearly hands control to the store, and the user sees the real Update button there.
Do not nag
The worst version of this feature is an alert on every launch.
I would keep a few product rules:
- Check in the background after the main UI appears.
- Check at most once per day, or less often.
- Never block launch if the lookup fails.
- Let the user dismiss or skip the current version.
- Do not show this in internal debug builds unless you are testing the prompt.
- Do not show a prompt if the App Store version is lower than your installed build, which can happen during App Review or TestFlight testing.
This sample decodes minimumOsVersion but leaves the compatibility gate out to keep the main flow small. In production, use that field to avoid suggesting an update that the current device cannot install. That is especially useful if your latest App Store build raised its minimum OS version and older users are still running a previous compatible app.
When you still need your own server
This no-server approach is intentionally lightweight. It answers one question: “Does Apple’s public store listing appear to have a newer marketing version than this installed app?”
Use your own server when you need more control:
- minimum supported version
- security or data-migration cutoffs
- staged rollout rules
- different messages per app version
- kill switches
- region-specific business logic
- links to release notes outside the App Store
A nice pattern is to use both:
- App Store Lookup API for a friendly “new version available” prompt.
- Your backend for “this version is no longer supported” policy.
That keeps the common feature simple while keeping critical compatibility decisions somewhere you can update instantly.
The takeaway
You do not need to maintain a latest-version value on your own server just to show a friendly update prompt.
Call Apple’s Lookup API with your bundle ID, decode the App Store version, compare it numerically to CFBundleShortVersionString, and open the returned App Store URL when the user taps Update.
Just keep the feature humble. It is a convenience prompt, not an enforcement system.