Implement In-App Subscriptions Using Swift and StoreKit2 (Serverless) and share active purchases with extensions.
A complete guide to implementing a complete SwiftUI paywall with in-app subscriptions.
This tutorial is going to be paired with code snippets and a starter project that you can find at:
[https://github.com/aisultanios/storekit-2-demo-app/]
What you'll learn:
- App Store Connect setup
- StoreKit 2
- Display, complete, and verify the purchase
- Handle changes that happen outside the app (renewal, cancellation, billing issue, etc)
- Share active purchases with extensions.
Introduction
While migrating my personal apps Subscription system from using a combination of StoreKit and SwiftyStoreKit framework, I have faced the issue of Apple's documentation being not enough. Apple’s documentation doesn’t provide us with a full working example and low level details of an API, but rather focuses on High level explanation. After reading a ton of articles, threads, and implementing the StoreKit 2 in a production environment I've decided to pack all the knowledge that is necessary to implement a working solution.
Set up App Store Connect
Before heading straight to the code we have to create the products (IAP — In-app purchases) in the developer dashboard in App Store Connect. The are two sections for in-app purchases. The first one is "Inn-App Purchases" and the second is the "Subscriptions". The "Inn-app purchases" is used for consumables and non consumables (e.g game tokens), while "Subscriptions" is for auto/non renewing subscriptions.
App Store Connect requirements
To be able to sell in-app purchases in your app you would first have to complete these steps:
- Setup Bank information
- Sign Paid App agreement
Create subscriptions
We will use subscriptions for this tutorial but you can setup consumables instead if you want.
- Go to "Subscriptions"
- Create a "Subscription Group"
- Add a localization for the "Subscription Group"
- Create a new subscription
- Fill out all metadata (duration, price, localization, review information)
[App Store Connect screen to add auto-renewable and non-renewing subscriptions]
Set up StoreKit configuration file
If you haven't heard of StoreKit configuration files before or haven't used them, then I advice you to check this great WWDC videos that show all the pros of using the StoreKit configuration file.
Create the file
- Launch the demo app or your project, then choose File > New > File. (Cmd + N)
- Search for "storekit" in the Search field.
- Select "StoreKit Configuration File".
- Name it, check “Sync this file with an app in App Store Connect”, and save it.
Here is how the configuration file looks like:
Enable the StoreKit Configuration File
To use the StoreKit Configuration File we have to select it in an Xcode scheme.
- Click scheme and select “Edit scheme.”
- Go to Run > Options.
- Select a value for “StoreKit Configuration.”
[Image of scheme]
Now, lets Implement the subscription manager!
Here is a little breakdown of what we will implement:
- List products
- Purchase products
- Unlock features for active subscriptions and lifetime purchases
- Share active purchases with extensions (Watch app, widgets, etc.)
- Handle renewals, cancellations, and billing errors
- Validate receipts
Step 1: Listing products
extension SubscriptionsManager {
func loadProducts() async {
do {
self.products = try await Product.products(for: productIDs)
.sorted(by: { $0.price > $1.price })
} catch {
print("Failed to fetch products!")
}
}
}
The loadProducts() function fetches products by the product identifiers to show in the paywall. The product identifiers should match the products defined in a StoreKit Configuration File or App Store Connect. The end result is an array of Product objects. A Product object contains all of the information needed to show in the buttons of the paywall. We will use these Product objects to make a purchase when the button is tapped.
Note: The product identifiers are hardcoded in the demo project, but they shouldn’t be hardcoded in a production app. Hardcoded product identifiers make it difficult to swap out different products by requiring an app update. Not all users have automatic updates on, which means that some product identifiers could be purchased for longer than intended. It is best to serve the list of available products to be purchased from a remote source.
Before showing the projects in the ContentView we first have to set the SubscriptionsManager as an environment object of the ContentView:
import SwiftUI
@main
struct storekit_2_demo_appApp: App {
@StateObject
private var subscriptionsManager = SubscriptionsManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(subscriptionsManager)
}
}
}
In the code snipped above we've created a SubscriptionsManager in the App and passed it down into ContentView as an environment object. This approach allows SwiftUI views to easily gain access to the same SubscriptionsManager object. Because the SubscriptionsManager is an ObservableObject, the SwiftUI view will auto-update when its properties change.
Now it’s time to show those products in the view. Implement the code bellow:
private var subscriptionOptionsView: some View {
VStack(alignment: .center, spacing: 12.5) {
if !subscriptionsManager.products.isEmpty {
Spacer()
proAccessView
featuresView
VStack(spacing: 2.5) {
productsListView
purchaseSection
}
} else {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(1.5)
.ignoresSafeArea(.all)
}
}
}
In this snippet, the loadProducts() function is called when our view appears by using .task(). The products are then iterated over within the List in the productsListView. Each product represents a selectable option which will later be purchasable on the press of a button.
Step 2: Purchasing products
Now that all the products are visible on the paywall we can implement the purchase of the product.
In StoreKit2 initiating a purchase is as simple as calling the purchase() method of a Product object.
If this method throws PurchaseError or StoreKitError, the purchase didn’t go through. However, the absence of an error still does not indicate that the purchase was successful.
The snippet below shows how to implement a purchase function that checks for all the possible results from purchasing a product:
func buyProduct(_ product: Product) async {
do {
let result = try await product.purchase()
switch result {
case let .success(.verified(transaction)):
// Successful purhcase
await transaction.finish()
case let .success(.unverified(_, error)):
// Successful purchase but transaction/receipt can't be verified
// Could be a jailbroken phone
print("Unverified purchase. Might be jailbroken. Error: \(error)")
break
case .pending:
// Transaction waiting on SCA (Strong Customer Authentication) or
// approval from Ask to Buy
break
case .userCancelled:
// ^^^
print("User Cancelled!")
break
@unknown default:
print("Failed to purchase the product!")
break
}
} catch {
print("Failed to purchase the product!")
}
}
You can find an in-depth explanation of all of the different result types and what they mean in apple docs here.
Now call the buyProduct() function in buttons action closures:
struct PurchaseButtonView: View {
@Binding var selectedProduct: Product?
var subscriptionsManager: SubscriptionsManager
var body: some View {
Button(action: {
if let selectedProduct = selectedProduct {
Task {
await subscriptionsManager.buyProduct(selectedProduct)
}
} else {
print("Please select a product before purchasing.")
}
}) {
RoundedRectangle(cornerRadius: 12.5)
.overlay {
Text("Purchase")
.foregroundStyle(.white)
.font(.system(size: 16.5, weight: .semibold, design: .rounded))
}
}
.padding(.horizontal, 20)
.frame(height: 46)
.disabled(selectedProduct == nil)
}
}
Step 3: Unlocking premium features
At this point the user can see the subscription options and even purchase them when tapping a button. However, to make the implementation complete, we still have to unlock features that are only available for premium users.
The feature will be built around the StoreKit2’s Transaction.currentEntitlements function.
for await result in Transaction.currentEntitlements {
}
This function returns an array of transaction that are active. The documentation explains it more thoroughly. I recommend you to read it. The function that we will implement in the SubscriptionsManager will iterate over there transactions and store the product identifiers into a Set(because it can't contain duplicates) called purchasedProductIDs. These identifiers can now be used to unlock features and content in the app for users that have purchased them.
func updatePurchasedProducts() async {
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else {
continue
}
if transaction.revocationDate == nil {
self.purchasedProductIDs.insert(transaction.productID)
} else {
self.purchasedProductIDs.remove(transaction.productID)
}
}
}
Sharing active purchases with extensions
In-between the steps we will also add an entitlementManager which will share the current entitlements with extensions like Widget, Intent, Watch Apps, and much more. These extensions usually run in a separate contexts from the main app, but there is a good chance that subscriptions could unlock some functionality in these extensions.
The StoreKit2 makes it very easy to share this transactions with extensions. The Transaction.currentEntitlements will fetch locally cached transactions but can also get the latest transactions if the device is online. However, the app extensions have a very limited runtime and capabilities, so the best practice for them would be to look at a boolean flag if certain products were purchased. This can be done by storing the shared information in a UserDefaults instance that is shared between the main app and its extensions with app groups.
The EntitlementManager class will have a single responsibility of storing the unlocked feature state that happens when purchasing a product. The SubscriptionsManager will update EntitlementManager after calling Transaction.currentEntitlements from within the updatePurchasedProducts() function.
import SwiftUI
class EntitlementManager: ObservableObject {
static let userDefaults = UserDefaults(suiteName: "group.demo.app")!
@AppStorage("hasPro", store: userDefaults)
var hasPro: Bool = false
}
EntitlementManager also is an ObservableObject just like the SubscriptionsManager is, so that the SwiftUI could easily observe the changes and make updates. We use SwiftUI's property wrapper @AppStorage to save the value to UserDefaults, and it will also act as a @Published variable, which will redraw the SwiftUI views when updated.
The next step is to give SubscriptionsManager an instance of EntitlementManager to update in updatePurchasedProducts().
@MainActor
class SubscriptionsManager: ObservableObject {
let productIDs: [String] = ["pro_monthly", "pro_yearly"]
var purchasedProductIDs: Set<String> = []
@Published var products: [Product] = []
private var entitlementManager: EntitlementManager? = nil
private var updates: Task<Void, Never>? = nil
init(entitlementManager: EntitlementManager) {
self.entitlementManager = entitlementManager
}
}
func updatePurchasedProducts() async {
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else {
continue
}
if transaction.revocationDate == nil {
self.purchasedProductIDs.insert(transaction.productID)
} else {
self.purchasedProductIDs.remove(transaction.productID)
}
}
self.entitlementManager?.hasPro = !self.purchasedProductIDs.isEmpty
}
We also need to update the App with the initialization of SubscriptionsManager. We will modify the initialization of the two @StateObject values to make it more readable. We will also call the new updatePurchasedProducts() function on application start, after a purchase is made, and when transactions are updated to ensure that purchasedProductIDs return the correct value that the user is expecting.
import SwiftUI
@main
struct storekit_2_demo_appApp: App {
@StateObject
private var entitlementManager: EntitlementManager
@StateObject
private var subscriptionsManager: SubscriptionsManager
init() {
let entitlementManager = EntitlementManager()
let subscriptionsManager = SubscriptionsManager(entitlementManager: entitlementManager)
self._entitlementManager = StateObject(wrappedValue: entitlementManager)
self._subscriptionsManager = StateObject(wrappedValue: subscriptionsManager)
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(entitlementManager)
.environmentObject(subscriptionsManager)
.task {
await subscriptionsManager.updatePurchasedProducts()
}
}
}
}
Lastly, update the ContentView to accomodate another @EnvironmentObject and update its state based on entitlementsManager's hasPro value.
import SwiftUI
import StoreKit
struct ContentView: View {
@EnvironmentObject private var entitlementManager: EntitlementManager
@EnvironmentObject private var subscriptionsManager: SubscriptionsManager
@State private var selectedProduct: Product? = nil
private let features: [String] = ["Remove all ads", "Daily new content", "Other cool features", "Follow for more tutorials"]
var body: some View {
if entitlementManager.hasPro {
hasSubscriptionView
} else {
subscriptionOptionsView
.padding(.horizontal, 15)
.padding(.vertical, 15)
.onAppear {
Task {
await subscriptionsManager.loadProducts()
}
}
}
}
For the extensions to check the unlock status, EntitlementManager simply needs to be shared to the extensions targets, which will give the ability for extension to call entitlementManager.hasPro.
let entitlementManager = EntitlementManager()
if entitlementManager.hasPro {
} else {
}
Watch App
Watch apps are not able to use UserDefaults that are shared via an app group, which means that the EntitlementManager cannot be used directly in the watchOS app. The workaround is to use WatchConnectivity to communicate with the main iOS app, which can return the entitlement statuses from EntitlementManager.
Next, the buyProduct() function needs to call the updatePurchasedProducts() after a successfully verified purchase:
func buyProduct(_ product: Product) async {
do {
let result = try await product.purchase()
switch result {
case let .success(.verified(transaction)):
// Successful purhcase
await transaction.finish()
await self.updatePurchasedProducts()
case let .success(.unverified(_, error)):
// Successful purchase but transaction/receipt can't be verified
// Could be a jailbroken phone
print("Unverified purchase. Might be jailbroken. Error: \(error)")
break
case .pending:
// Transaction waiting on SCA (Strong Customer Authentication) or
// approval from Ask to Buy
break
case .userCancelled:
// ^^^
print("User Cancelled!")
break
@unknown default:
print("Failed to purchase the product!")
break
}
} catch {
print("Failed to purchase the product!")
}
}
With that the transactions that were made in-app can be updated, but to listen to the transactions that were made outside that app we have to set-up a task that would monitor changes in Transaction.updates. These transactions could be subscriptions that have been canceled, renewed, or revoked due to billing issues, but they could also be new purchases that happened on another device that should unlock content on this one as well.
@MainActor
class SubscriptionsManager: ObservableObject {
let productIDs: [String] = ["pro_monthly", "pro_yearly"]
var purchasedProductIDs: Set<String> = []
@Published var products: [Product] = []
private var entitlementManager: EntitlementManager? = nil
private var updates: Task<Void, Never>? = nil
init(entitlementManager: EntitlementManager) {
self.entitlementManager = entitlementManager
super.init()
self.updates = observeTransactionUpdates()
}
deinit {
updates?.cancel()
}
func observeTransactionUpdates() -> Task<Void, Never> {
Task(priority: .background) { [unowned self] in
for await _ in Transaction.updates {
await self.updatePurchasedProducts()
}
}
}
}
With all the changes in place for the SubscriptionsManager the demo app now properly handles and removes the paywall when a product purchase has been made. You don't have to do anything in the ContentView as it is already handled by checking if the entitlementManager.hasPro is true or not.
Step 4: Handling purchased products when offline
With StoreKit2 we don't have to worry about designing any logic or custom caching to make apps work offline. The Transaction.currentEntitlements will return locally cached data when device is not connected to the internet.
Step 5: Restoring purchases
The StoreKit2 will keep in-app subscription status and history up to date through Transaction.currentEntitlements and Transaction.all. By using these functions there really is no need for user to manually attempt to restore transactions, but Apple still keeps it as a best practice to have a "Restore Purchases" button in the app.
According to documentation we should restore subscriptions by calling AppStore.sync() function, which enforces the app to obtain transaction information and subscription statuses from the App Store. Implement the new restorePurchases() function in the SubscriptionsManager and call it on the press of a Restore button in purchaseSection View:
func restorePurchases() async {
do {
try await AppStore.sync()
} catch {
print(error)
}
}
private var purchaseSection: some View {
VStack(alignment: .center, spacing: 15) {
PurchaseButtonView(selectedProduct: $selectedProduct, subscriptionsManager: subscriptionsManager)
Button("Restore Purchases") {
Task {
await subscriptionsManager.restorePurchases()
}
}
.font(.system(size: 14.0, weight: .regular, design: .rounded))
.frame(height: 15, alignment: .center)
}
}
A button calling restorePurchases() will be placed on the paywall under the list of available products to purchase. In the unusual case where the user has purchased a product, but the paywall is still showing, AppStore.sync() will update the transactions, the paywall will disappear, and the purchased in-app content will be available for the user to use.
Step 6: Handling renewals, cancelations, billing issues, and more
Another noteworthy aspect where Transaction.currentEntitlements excels is in providing the most recent transaction details for both active auto-renewable subscriptions and non-renewing subscriptions. As previously mentioned, Transaction.currentEntitlements ensures the retrieval of the latest transaction information, including renewals, cancellations, and billing issues, for each subscription type.
This implies that whenever the application is opened, Transaction.currentEntitlements will contain updated transactions reflecting any changes such as renewals, cancellations, or billing problems. Subscribers retain their entitlements upon renewal, but these are forfeited upon cancellation. Additionally, unresolved billing issues can result in the loss of subscriptions.
While the application ensures that users receive accurate access based on the current status of their subscriptions, it may not always lead to the optimal user experience. Renewals and cancellations, events that users typically don’t need notifications for, contrast with billing issues, which users would prefer to be promptly informed about. Billing issues often arise unexpectedly, and users prefer addressing them promptly to avoid any interruptions in their subscription.
Handling subscriptions exclusively on the device may not efficiently notify users of billing issues and grace periods. Although the subscription status remains up-to-date, addressing this scenario through server-side subscription handling proves advantageous. Server-side handling allows for quicker detection of billing issues and grace periods, empowering applications to promptly inform users of relevant updates.
Step 7: Validating receipts
Validating receipts has always been a pain when integrating StoreKit. With StoreKit 1, validating and parsing the receipt was the only way to determine purchases and what to unlock for users. There were 2 ways to validate receipts:
- Local, on-device receipt validation, which is best for validating the signature of the receipts for apps with in-app purchases.
- Server-side receipt validation with the App Store, which works best for persisting in-app purchases to maintain and manage purchases records.
In this tutorial I will only show you a full local implementation of StoreKit without any server-side components. There are also plenty of existing blog posts on how to validate and parse a receipt but, thankfully, any thought of the receipt can be ignored because of improvements made with StoreKit 2.
Thankfully, the StoreKit2 encapsulates parsing and validating inside of Transaction.currentEntitlements and Transaction.all so we don't need to worry about any of it.
Conclusion
We've completed this on-device in-app purchases implementation and now you know everything that is needed to implement StoreKit. A pure on-device StoreKit implementation may not be ideal for some types of apps, for example the multi platform apps, that have both android and iOS clients. However, it is perfect for those apps that are only available on the Appstore. The biggest con of this implementation is that we have no way of notifying user of any billing errors or win-back campaigns on cancellation outside the app. A lot of data and hidden opportunities are caught inside of the StoreKit 2. I plan to implement StoreKit 2 with a server in my personal project RebFit and will surely write a tutorial covering that as well. For now, thank you very much for sticking with me. I really hope that this tutorial helped some of you to improve your apps.
You can find the complete implementation of the demo app here:
[https://github.com/aisultanios/storekit-2-demo-app/]
Check out my GitHub profile for more open source projects and follow to not miss any new tutorials!