How do subscription groups and levels govern upgrades, and what are introductory, promotional, offer-code and win-back offers?
Predict first
When do plan changes apply, and does a promotional-offer signature survive a changed offer id?
import Foundation
import CryptoKit
import StoreKit
enum Change { case upgrade, downgrade, crossgrade }
func timing(level from: Int, _ to: Int, months a: Int, _ b: Int) -> String {
// group level 1 is the HIGHEST level
if to < from { return "upgrade: immediately" }
if to > from { return "downgrade: at next renewal" }
return a == b ? "crossgrade, same duration: immediately" : "crossgrade, different duration: at next renewal"
}
print(timing(level: 2, 1, months: 1, 12))
print(timing(level: 1, 2, months: 1, 1))
print(timing(level: 2, 2, months: 1, 1))
print(timing(level: 2, 2, months: 1, 12))
print(Product.SubscriptionOffer.OfferType.introductory.rawValue, Product.SubscriptionOffer.OfferType.promotional.rawValue, Product.SubscriptionOffer.OfferType.winBack.rawValue)
print(Product.SubscriptionOffer.PaymentMode.freeTrial.rawValue, Product.SubscriptionOffer.PaymentMode.payAsYouGo.rawValue, Product.SubscriptionOffer.PaymentMode.payUpFront.rawValue)
// the server signs a promotional offer with the subscription key: fields joined by U+2063
let key = P256.Signing.PrivateKey()
let sep = "\u{2063}"
func payload(_ offer: String) -> Data {
["com.example.app", "KEYID12345", "com.example.pro.monthly", offer,
"0a1b2c3d-0000-4000-8000-000000000001", "4f2c9e8a-0000-4000-8000-0000000000aa", "1767225600000"]
.joined(separator: sep).data(using: .utf8)!
}
let sig = try! key.signature(for: payload("WINTER25"))
print("verifies:", key.publicKey.isValidSignature(sig, for: payload("WINTER25")))
print("other offer id verifies:", key.publicKey.isValidSignature(sig, for: payload("SPRING25")))
print("DER length plausible:", (68...72).contains(sig.derRepresentation.count))
- upgrade immediately, downgrade at next renewal, crossgrade depends on duration; verifies true, other offer false
- every change applies at next renewal
- the signature still verifies after changing the offer id
Mental model. A group is a menu of one-seat tickets: you can swap tickets, and the rules say whether the swap happens now or at the next show; an offer is a coupon, and the server is the only one holding the stamp.
Subscriptions that compete live in one subscription group; a user can hold only one active subscription per group, ordered by level (level 1 is the highest). Moving to a higher level is an upgrade and takes effect immediately; to a lower level a downgrade and takes effect at the next renewal; the same level (a monthly versus an annual plan of the same tier) is a crossgrade, immediate if the duration is the same and at the next renewal if it differs. The check encodes these documented rules, it does not query the store. Put products that are alternatives in one group (monthly, annual, premium), and separate products (say a second app feature) in a different group, otherwise users cannot hold both.
Offer types (Product.SubscriptionOffer.OfferType): introductory (free trial, pay-as-you-go or pay-up-front, once per group per Apple ID, check isEligibleForIntroOffer and never trust your own cache), promotional (for existing or lapsed subscribers, up to a documented limit per product, signed), offer codes (redeemed via a code, no signing in your app) and win-back (for lapsed subscribers, eligibility computed by the store). Payment modes print as FreeTrial, PayAsYouGo, PayUpFront. A promotional offer must be authorised by your server: it signs a string of the bundle id, key id, product id, offer id, an identifier for the user (the appAccountToken in StoreKit 2 flows), a nonce and a timestamp, joined with U+2063, with an ES256 subscription key from App Store Connect; the check signs such a string with CryptoKit and shows that changing the offer id breaks verification. Newer StoreKit versions also support a JWS-based signature, so check current documentation; the private key never ships in the app.
The signature covers every field, so any change invalidates it.
Common traps
- Hard-coding "show the free trial". Eligibility is per user and per group; ask
isEligibleForIntroOfferorProduct.SubscriptionInfo.isEligibleForIntroOffer(for:)and fall back to the plain price. - Signing promotional offers in the app. The key would be extractable; sign on a server.
They might follow up with
- Can a user have a trial twice? Not in the same group on the same Apple ID; eligibility is checked by the store.
- Where does a refunded user land in a group? Revoked, and the group is free for a new subscription.
Related questions
- 28.2.1 What are the subscription renewal states, how do grace period and billing retry differ, and who should keep…
- 28.5.1 How do you design a paywall that is clear, localised and legal, and how do you run experiments on it?
- 28.4.1 How is a StoreKit JWS built, and what must a server check before trusting signedTransactionInfo?
- 17.4.2 How does StoreKit 2 work: products, purchases, transaction verification, entitlements, refunds, and what must…
← What are the subscription renewal states, how do grace period and…