Subscription Upgrade & Downgrade
Understanding how each platform handles subscription tier changes is crucial for correctly detecting and displaying upgrade/downgrade states in your app. Both iOS and Android support subscription changes, but they handle them differently.
Overview#
When users change their subscription tier (e.g., from monthly to yearly, or from basic to premium), both platforms support upgrades and downgrades:
- Upgrades: Moving to a higher-tier or longer-duration subscription
- Downgrades: Moving to a lower-tier or shorter-duration subscription
Subscription upgrades and downgrades only work within the same subscription group. You cannot upgrade or downgrade between subscriptions in different groups.
Platform-specific setup:
- iOS (App Store Connect): All tiers (e.g., Basic, Premium, Ultimate) must be in the same Subscription Group
- Android (Google Play Console): All tiers must be in the same Subscription Product with different Base Plans
In App Store Connect, the row order in your Subscription Group determines the tier hierarchy:
- Position 1 (Top): Automatically recognized as the highest tier
- Position 2, 3, ...: Lower tiers in descending order
Example order:
- Ultimate (yearly) - Highest tier
- Premium (yearly)
- Ultimate (monthly)
- Premium (monthly)
- Basic (yearly)
- Basic (monthly) - Lowest tier
StoreKit uses this order to automatically determine whether a subscription change is an upgrade or downgrade.
The key difference is in how and when these changes take effect. Select your platform below to see specific implementation details:
Key Fields to Understand#
This is not a bug — it's intentional StoreKit behavior. According to Apple's official documentation and forums:
transaction.productID: The currently active subscription product IDautoRenewPreference: The product ID that will be used on the next renewal
Key insight from Apple Developer Forums:
Upgrade Behavior (Charged Immediately)#
When a user upgrades to a higher-tier subscription (e.g., monthly → yearly, basic → premium):
- User is charged immediately for the new subscription tier
- A new transaction is created (though
Transaction.updatesmay initially show the old product - this is a known StoreKit issue) autoRenewPreferenceimmediately reflects the new producttransaction.productIDupdates within a few minutes
When a user upgrades from monthly to yearly subscription:
- Immediately after upgrade:
productId: still "monthly" (takes time to update)autoRenewPreference: "yearly"pendingUpgradeProductId: "yearly" ✅
- A few minutes later:
productId: "yearly" (now updated)autoRenewPreference: "yearly"pendingUpgradeProductId:nil(no pending change)
Use pendingUpgradeProductId to detect when an upgrade is in progress, especially during the brief period when productId hasn't updated yet.
// Detecting upgrades with pendingUpgradeProductId
let subscriptions = try await getActiveSubscriptions()
for sub in subscriptions {
if let renewalInfo = sub.renewalInfoIOS,
let pendingUpgrade = renewalInfo.pendingUpgradeProductId,
pendingUpgrade != sub.productId {
print("⚠️ UPGRADE IN PROGRESS")
print(" Current: \(sub.productId)")
print(" Upgrading to: \(pendingUpgrade)")
// Show UI: "Upgrade processing..."
}
}Downgrade Behavior (Applied at Next Renewal)#
When a user downgrades to a lower-tier subscription (e.g., yearly → monthly, premium → basic):
- No new transaction is created
- Change is reflected in the
renewalInfoobject only autoRenewPreferenceindicates the new (downgraded) product (Stack Overflow - autoRenewalPreference usage)transaction.productIDremains unchanged until the renewal date- User retains access to current tier until expiration
Check autoRenewPreference against productId to detect scheduled downgrades and inform users when the change will take effect.
// Detecting downgrades
let subscriptions = try await getActiveSubscriptions()
for sub in subscriptions {
if let renewalInfo = sub.renewalInfoIOS,
let autoRenewPref = renewalInfo.autoRenewPreference,
autoRenewPref != sub.productId,
renewalInfo.willAutoRenew {
print("⚠️ DOWNGRADE SCHEDULED")
print(" Current (until \(sub.expirationDateIOS ?? 0)): \(sub.productId)")
print(" Next: \(autoRenewPref)")
// Show UI: "Your plan will change to [tier] on [date]"
}
}Using pendingUpgradeProductId#
The pendingUpgradeProductId field is specifically designed to detect subscription tier changes. It is automatically calculated by comparing productID and autoRenewPreference.
How it works
The field is automatically calculated by comparing autoRenewPreference and productId:
// Internal logic (for reference - this is done automatically)
const pendingUpgradeProductId =
(autoRenewPreference !== productId && willAutoRenew)
? autoRenewPreference
: null;Usage in your app
Simply check if pendingUpgradeProductId exists to detect tier changes in progress:
import OpenIap
let subscriptions = try await OpenIapModule.shared.getActiveSubscriptions(nil)
for sub in subscriptions {
if let pending = sub.renewalInfoIOS?.pendingUpgradeProductId {
let current = sub.productId
print("Upgrading from \(current) to \(pending)")
// Show upgrade-in-progress UI
showUpgradeInProgressUI(current: current, pending: pending)
}
}After an upgrade, don't rely on productId to be immediately updated. There can be a delay of several minutes. Instead:
- Use
pendingUpgradeProductIdto detect ongoing tier changes - Check
autoRenewPreferenceto see what the next renewal will be - Only use
productIdfor the current active subscription
// ✅ Correct approach
let effectiveTier = renewalInfo.pendingUpgradeProductId ?? subscription.productId
// ❌ Wrong - may show outdated tier immediately after upgrade
let currentTier = subscription.productIdBest Practices#
- Always check pendingUpgradeProductId when displaying subscription status
- Show upgrade-in-progress UI when
pendingUpgradeProductIdis present - For downgrades, inform users when the change will take effect (at renewal)
- Listen to purchase events to update UI when
productIdfinally updates - Test both scenarios in sandbox environment to understand the timing
Here's a complete React component that demonstrates all best practices for handling subscription tier changes:
// Complete example: Subscription status view (SwiftUI)
struct SubscriptionStatusView: View {
@State private var subscription: ActiveSubscription?
var body: some View {
Group {
if let sub = subscription {
subscriptionContent(sub)
} else {
Text("Loading...")
}
}
.task {
await loadSubscription()
}
}
@ViewBuilder
func subscriptionContent(_ sub: ActiveSubscription) -> some View {
let renewalInfo = sub.renewalInfoIOS
let pending = renewalInfo?.pendingUpgradeProductId
if let pending = pending, pending != sub.productId {
// Upgrade in progress
VStack {
Text("⏳ Upgrading to \(pending)...")
Text("Current: \(sub.productId)")
}
} else if let autoRenewPref = renewalInfo?.autoRenewPreference,
autoRenewPref != sub.productId,
renewalInfo?.willAutoRenew == true {
// Downgrade scheduled
VStack {
Text("Current: \(sub.productId)")
Text("Will change to \(autoRenewPref) on \(formattedDate(sub.expirationDateIOS))")
}
} else {
// Normal active subscription
VStack {
Text("Active: \(sub.productId)")
Text("Renews: \(formattedDate(renewalInfo?.renewalDate))")
}
}
}
func loadSubscription() async {
let subs = try? await OpenIapModule.shared.getActiveSubscriptions(nil)
subscription = subs?.first
}
}Official References#
- Apple · App Store Subscriptions
Subscription groups, upgrade/downgrade hierarchy, and tier order - Apple · Product.SubscriptionInfo.RenewalInfo
autoRenewPreferenceis the source of truth for the next-renewed product ID - Apple Developer Forums: Auto-renewing Subscription Updates
Explains why Transaction.updates may show old product after upgrade - Apple Developer Forums: How to know when user upgrades/downgrades
Official guidance on using autoRenewPreference vs productID - Stack Overflow: autoRenewalPreference usage in StoreKit 2
Community discussion on upgrade vs downgrade transaction behavior