โ† All articles

How iOS Subscription Groups, Upgrades, and Downgrades Work

How iOS subscription groups control upgrades, downgrades, and crossgrades: what a level is, when each change takes effect, and how to read status in code.

How iOS Subscription Groups, Upgrades, and Downgrades Work

An iOS subscription group is a set of auto-renewable subscriptions that a customer can hold only one of at a time. Each subscription in the group has a level, and that level is what decides what happens when someone switches plans: moving to a higher level is an upgrade that takes effect immediately with a prorated refund, and moving to a lower level is a downgrade that waits until the next renewal. You never write that proration logic yourself. The App Store applies it, and your job is to configure the group correctly and reflect the resulting status in your app.

This post explains what a subscription group is, how levels drive upgrade and downgrade behavior, exactly when each kind of change takes effect, and how to read the current subscription (and any pending change) from StoreKit 2 and RevenueCat. It ends with the gotchas that produce confused users and support tickets.

On this page

What a subscription group is

A subscription group is a container you create in App Store Connect that holds related auto-renewable subscriptions. The rule that makes groups matter: a customer can be subscribed to only one subscription per group at any time. If you put "Pro Monthly," "Pro Yearly," and "Premium Yearly" in one group, a subscriber has exactly one of them active, and switching between them is a plan change rather than a second purchase.

That is almost always what you want. Monthly and yearly options of the same product belong in one group so a customer moving from monthly to yearly changes their plan instead of paying for both. You would use a separate group only for genuinely independent subscriptions a customer could reasonably hold at the same time (for example a "Pro features" subscription and an unrelated "Extra storage" subscription). If you are still mapping out the whole lifecycle, how an iOS subscription works end to end covers the surrounding pieces.

Levels decide upgrade vs downgrade

Inside a group, you rank each subscription by level (also called service level or rank). In App Store Connect this is just the order of the subscriptions in the group: the one at the top is level 1, the highest tier. Level is not about price, it is about how much service the tier represents, though in practice they usually line up.

The level of the destination relative to the current subscription is what classifies a plan change:

  • Switching to a higher level (a smaller level number) is an upgrade.
  • Switching to a lower level (a larger level number) is a downgrade.
  • Switching to a different subscription at the same level (most commonly monthly to yearly of the same tier) is a crossgrade.

You set these levels once. From then on the App Store reads them to decide the timing and refund of every switch, which is why getting the ranking right at setup time matters more than any code you write later.

When each change takes effect

This is the part that surprises developers and users, because not every plan change happens right away:

ChangeLevel moveWhen it takes effectRefund
Upgradeto a higher levelImmediatelyProrated refund of the unused time on the old plan
Downgradeto a lower levelAt the next renewal dateNone
Crossgradesame level, different durationAt the next renewal dateNone
Crossgradesame level, same durationImmediatelyNone

Read the crossgrade rows carefully, because the classic monthly-to-yearly switch is a crossgrade with a different duration, so it takes effect at the next renewal, not immediately. The customer keeps their current monthly period, and the yearly plan begins when the month would have renewed. A user who "upgraded to annual" and does not see an immediate charge has not hit a bug. This is the documented behavior, and your paywall copy should say "starts at your next renewal" rather than implying an instant switch.

Upgrades are the opposite: immediate access to the higher tier, a prorated refund for the unused portion of the old subscription, and a fresh charge for the new one. You do not calculate any of this. The App Store does, and it reports the result back to your app as a new transaction.

Reading the current subscription in code

With StoreKit 2 you query the status for the whole group and read both what the customer owns now and what is scheduled to renew. The scheduled renewal is how you detect a pending downgrade or crossgrade:

import StoreKit
 
/// The customer's active subscription in a group, plus what will renew next.
func currentSubscription(inGroup groupID: String) async -> (current: String, renewingTo: String)? {
    let statuses = try? await Product.SubscriptionInfo.status(for: groupID)
    guard let status = statuses?.first(where: { $0.state == .subscribed }) else {
        return nil
    }
 
    guard case .verified(let transaction) = status.transaction,
          case .verified(let renewal) = status.renewalInfo else {
        return nil
    }
 
    // autoRenewPreference is the product that will renew next. When it differs
    // from the current product, the customer has a downgrade or crossgrade
    // scheduled for the next renewal date.
    let renewingTo = renewal.autoRenewPreference ?? transaction.productID
    return (current: transaction.productID, renewingTo: renewingTo)
}

Switching plans does not need special code. A plan change is just a normal purchase of the target product in the same group:

// The App Store applies upgrade/downgrade/crossgrade timing and any prorated
// refund automatically. Do not implement proration yourself.
let result = try await targetProduct.purchase()

When renewingTo differs from current, show the user that their plan changes at the next renewal, and offer a way to cancel the scheduled change (which is just re-selecting their current plan). Comparing the two also lets you render an accurate "Current plan" badge on the paywall, which prevents the most common confusion.

Handling groups with RevenueCat

RevenueCat models the same group as an entitlement (for example pro) unlocked by any package in the group, so your app checks one flag instead of tracking individual product IDs:

import RevenueCat
 
let info = try await Purchases.shared.customerInfo()
if info.entitlements["pro"]?.isActive == true {
    // Buying a different package in the same group triggers the App Store's
    // upgrade/downgrade handling; RevenueCat reflects the result in customerInfo.
    let offerings = try await Purchases.shared.offerings()
    if let yearly = offerings.current?.annual {
        _ = try await Purchases.shared.purchase(package: yearly)
    }
}

The important thing RevenueCat does not change: the upgrade, downgrade, and crossgrade timing is still enforced by the App Store based on your App Store Connect levels, not by the SDK. RevenueCat reports the outcome in customerInfo, and its prebuilt paywall reads the active entitlement to mark the current plan. For the underlying wiring, see adding subscriptions with RevenueCat.

Common gotchas

  • Monthly to yearly is not instant. It is a crossgrade with a different duration, so it starts at the next renewal. Set expectations in the paywall.
  • One introductory offer per group, ever. A free trial or other introductory offer is granted once per subscription group per Apple ID and Family Sharing group. Trialing the monthly tier makes the customer ineligible for a trial on the yearly tier in the same group.
  • A customer can hold only one subscription per group. If you want two subscriptions a customer can own simultaneously, they must be in separate groups.
  • Level is set by ordering in App Store Connect, not by price. Two tiers at the same level are crossgrades even if their prices differ, which changes the timing and removes the prorated refund.
  • Downgrades can be cancelled before they apply. Until the renewal date, the customer can switch back, so treat a pending downgrade as reversible rather than final.

FAQ

What is an iOS subscription group?

It is a container in App Store Connect holding related auto-renewable subscriptions, where a customer can be subscribed to only one at a time. Groups are what turn switching between plans (like monthly and yearly) into an upgrade or downgrade instead of a second, parallel purchase.

Why did my monthly-to-yearly upgrade not charge immediately?

Because it is a crossgrade, not an upgrade. Monthly and yearly of the same tier are usually at the same level with different durations, and same-level crossgrades with different durations take effect at the next renewal date rather than immediately.

Do I have to calculate the prorated refund for an upgrade?

No. When a customer upgrades to a higher-level subscription, the App Store issues the prorated refund and charges the new subscription automatically. Your app only needs to reflect the resulting transaction and status.

How many subscriptions can one group have?

A group can hold many subscriptions across as many levels as you need, but a customer holds only one of them at a time. Use levels to rank them from highest tier to lowest.

When should I use more than one subscription group?

Use separate groups only for subscriptions a customer could reasonably hold at the same time, such as two unrelated features. Different durations or tiers of the same product belong in one group so switching is a plan change, not a double charge.

How do I show the customer's current plan on the paywall?

Read the group's status with StoreKit 2 (Product.SubscriptionInfo.status(for:)) or the active entitlement with RevenueCat, then mark the matching product as the current plan. Comparing the current product to the renewal preference also lets you show any scheduled downgrade.


Subscription groups, levels, product IDs, and per-market prices all have to be set up correctly before any of this upgrade and downgrade behavior works, and that setup is exactly where indie projects stall. Spaceport does it through the App Store Connect API: it creates your subscription products, their group and level, their product IDs, and their prices across 25 major markets, then matches everything in RevenueCat as an entitlement, offering, and packages. The generated SwiftUI project ships with the paywall and a StoreKit configuration file already wired in, so the plan-switching behavior described here works from the first build. And when you are lining up a waitlist and launch-day audience for the app, our sister tool Lighthouse covers that side.

From an indie iOS dev, for indie iOS devs.

Read more at spaceport.build

Community appsJoin Discord