The short version
- Create a subscription group and its subscriptions in App Store Connect.
- Load them with
Product.products(for:). - Buy with
product.purchase(), verify the result, unlock, thenfinish()the transaction. - Work out access from
Transaction.currentEntitlements, and listen toTransaction.updatesfrom launch. - Show the paywall with
SubscriptionStoreView, or your own design. - Test in Xcode with a StoreKit configuration file, then in the sandbox and TestFlight.
1. Set up the subscription group
Every auto-renewable subscription belongs to a subscription group, and people can hold only one subscription per group at a time, which is why Apple recommends a single group for most apps (Apple). Rank the subscriptions in the group by level, from the one that offers most (level 1) down. The levels decide what happens when people switch:
- Upgrade: immediate, with a prorated refund of the old subscription.
- Downgrade: takes effect at the next renewal date.
- Crossgrade: between equal levels; immediate if the durations match, otherwise at the next renewal.
Give each subscription a clear display name, and check the subscription is available on every device type the app supports, as guideline 3.1.2(a) requires.
2. A store object, and loading products
One object owns everything StoreKit: the products, what the person is entitled to, and a listener for transactions that happen outside the app.
import StoreKit
import Observation
@MainActor
@Observable
final class Store {
private(set) var products: [Product] = []
private(set) var activeProductIDs: Set<String> = []
@ObservationIgnored private var updates: Task<Void, Never>?
init() {
// Purchases can also happen outside the app: renewals, Ask to Buy,
// offer codes, other devices. Listen for them from launch.
updates = Task { [weak self] in
for await result in Transaction.updates {
guard case .verified(let transaction) = result else { continue }
await self?.refreshEntitlements()
await transaction.finish()
}
}
}
func loadProducts() async throws {
products = try await Product.products(for: ["com.example.pro.monthly", "com.example.pro.yearly"])
.sorted { $0.price < $1.price }
}
}3. Purchase and verify
StoreKit 2 signs every transaction. VerificationResult tells you whether the signature checks out; unlock only for .verified results, and call finish() once the content is delivered, or the transaction will be delivered again.
extension Store {
func purchase(_ product: Product) async throws -> Bool {
switch try await product.purchase() {
case .success(let verification):
let transaction = try checkVerified(verification)
await refreshEntitlements()
await transaction.finish()
return true
case .userCancelled, .pending:
return false
@unknown default:
return false
}
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .verified(let value):
return value
case .unverified(_, let error):
throw error
}
}
}4. Entitlements, restore and updates
Transaction.currentEntitlements returns the latest transaction for everything the person is entitled to right now, including subscriptions bought on another device or shared through Family Sharing. Skip revoked transactions, which is what a refund looks like.
extension Store {
func refreshEntitlements() async {
var active: Set<String> = []
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result, transaction.revocationDate == nil {
active.insert(transaction.productID)
}
}
activeProductIDs = active
}
var isPro: Bool { !activeProductIDs.isEmpty }
// Only when the person taps Restore: it can ask them to sign in.
func restore() async throws {
try await AppStore.sync()
await refreshEntitlements()
}
}For more detail on a subscription, such as whether it is in a grace period after a failed payment, read its status:
if let statuses = try await product.subscription?.status {
let entitled = statuses.contains { $0.state == .subscribed || $0.state == .inGracePeriod }
// In the billing retry period, keep access if you offer it,
// and ask the person to update their payment method.
}5. The paywall
SubscriptionStoreView (iOS 17 and later) draws a paywall for a subscription group with the prices, durations and terms in place. You supply the marketing content, show the restore and policy buttons, and point the policy links at your terms and privacy policy.
import SwiftUI
import StoreKit
struct PaywallView: View {
var body: some View {
SubscriptionStoreView(groupID: "YOUR_GROUP_ID") {
VStack(spacing: 8) {
Text("Pro").font(.largeTitle.bold())
Text("Unlimited projects, synced across your devices.")
.foregroundStyle(.secondary)
}
}
.storeButton(.visible, for: .restorePurchases)
.storeButton(.visible, for: .policies)
.subscriptionStorePolicyDestination(url: URL(string: "https://example.com/terms")!, for: .termsOfService)
.subscriptionStorePolicyDestination(url: URL(string: "https://example.com/privacy")!, for: .privacyPolicy)
}
}A custom paywall is fine too, as long as it shows everything Apple requires. That list is in paywall best practices. For letting people manage or cancel inside the app, SwiftUI has manageSubscriptionsSheet(isPresented:).
6. The server side
An app that only unlocks features on the device can rely on StoreKit 2 alone. If your backend also needs to know who is subscribed, for example to unlock features on the web, use the App Store Server API and App Store Server Notifications, which tell your server in real time about renewals, refunds, billing problems and offer redemptions.
7. Testing
- A StoreKit configuration file in Xcode: local products, accelerated renewals, failed payments and refunds, without App Store Connect.
- The sandbox: your real App Store Connect products, with sandbox test accounts.
- TestFlight: the full flow on testers’ devices before release.
Test the unhappy paths as carefully as the purchase: cancelled payments, Ask to Buy, a refund, a lapsed card, restoring on a new device.
8. Offers
- Introductory offers: one per subscription group per customer.
- Promotional offers: for current and former subscribers, up to 10 per subscription.
- Offer codes: one-time or custom codes, redeemable in the app with
offerCodeRedemption. - Win-back offers: Apple shows them to eligible former subscribers.
New for iOS 27
- Bundles and Suites: several subscriptions as one purchase, across your apps or even other developers’ apps. Access is by request.
- Multiseat purchases: Volume Purchasing for organisations from 22 October 2026, and Group Purchases later in the winter. They are on by default; you can turn them off in App Store Connect.
- Monthly with a 12-month commitment: a lower monthly price for a year’s commitment, available outside the United States.
- Retention Messaging: a message, or an offer, shown when someone chooses to cancel.
What you earn
For auto-renewable subscriptions, you receive 70% of the price, minus taxes, during a subscriber’s first year of paid service, and 85% after that. Members of the App Store Small Business Program receive 85% from the start. Free trials do not count towards the year (Apple).
Related: Paywall best practices · Subscription app development · SwiftUI vs UIKit