Implement In-App Subscriptions Using Swift and StoreKit2 (Serverless) and share active purchases with extensions.

Aisultan Askarov
13 min readJan 17, 2024

--

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/AisultanAskarov/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.
Demo App

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.

“In-App Purchases” and “Subscriptions”

App Store Connect requirements

To be able to sell in-app purchases in your app you would first have to complete these steps:

  1. Setup Bank information
  2. Sign Paid App agreement

Create subscriptions

We will use subscriptions for this tutorial but you can setup consumables instead if you want.

  1. Go to "Subscriptions"
  2. Create a "Subscription Group"
  3. Add a localization for the "Subscription Group"
  4. Create a new subscription
  5. Fill out all metadata (duration, price, localization, review information)

[App Store Connect screen to add auto-renewable and non-renewing subscriptions]

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

  1. Launch the demo app or your project, then choose File > New > File. (Cmd + N)
  2. Search for "storekit" in the Search field.
  3. Select "StoreKit Configuration File".
  4. 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.

  1. Click scheme and select “Edit scheme.”
  2. Go to Run > Options.
  3. 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.

Demo paywall

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)
}
}
Demo purchasing subscription

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.

Demo purchasing subscription with unlocking premium features handling

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:

  1. Local, on-device receipt validation, which is best for validating the signature of the receipts for apps with in-app purchases.
  2. 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/AisultanAskarov/storekit-2-demo-app/]

Check out my GitHub profile for more open source projects and follow to not miss any new tutorials!

--

--