StoreKit 2 Capabilities Deep Dive

During WWDC21, Apple announced many exciting product features, and StoreKit 2 is one of the most significant updates. In-app subscriptions have become the most popular monetization method that generates billions of dollars in revenue for Apple and thousands of mobile app developers. Developers have earned $230 billion through the App Store since its launch, with the biggest chunk of that revenue coming in the last few years.

That makes in-app purchases a particular focus of Apple and Google. Just last year WWDC20 presented StoreKitTransactionManager with a .storekit file that simplifies the process of in-app purchases testing. We have been waiting for this update since iOS3. And the next updates were not long in coming.

This article will cover what’s new in StoreKit 2 and discuss the difference between the updated framework and its previous version – StoreKit. 

Before we jump into StoreKit 2 overview, let’s briefly cover what was wrong with the previous StoreKit.

Key limitations of StoreKit

Complexity

Compared with other native SDKs, StoreKit is difficult to understand (that’s great if this is not your case). Complicated system, products, transactions, payments, requests, receipts, refreshes, and on top of that, you also need a running server. This complexity led to the emergence of SaaS products which provide infrastructure for working with the StoreKit both on a client and server side. 

Bad architecture

We are talking about the perplexed control flow with callbacks that one needs to implement in order to handle events from StoreKit. Specifically – delegate and observer patterns. The initialization of product loading happens in one place. Still, instead of receiving the response in a common completion block, you will get it in a completely different component from SKProductsRequestDelegate.

A similar story with the purchase process. The response comes only from SKPaymentTransactionObserver, so you would have to store and correspond the data from the response, and hope that it will work well in the processing logic you implemented. It introduces a risk of dropping some purchases. It is less of an issue with subscriptions where we can call restore, but a big problem with expendable purchases represented as consumables – missing those would be very unfortunate.

Absence of purchase validation

There is no out-of-the-box solution. Yes, you could attempt to parse the receipt on the device, but that only solves part of the issue as it won’t protect from the users who would try to use your app for free. You might be surprised by the number of fraudsters who use jailbreak to get free access to the apps.

We witnessed it when our new customer flooded the bug tracker with reports of misbehaving SDK. Their analytics showed frequent restore requests that would always fail. Those requests were coming from the fraudsters, who got surprised by the loss of access to fake purchases they had forged on the device (as we also have receipt validation on Apple servers) and started to send multiple restore requests. As a result, access was denied as server validation didn’t confirm these user’s purchases.

Automatic synchronization across devices

Imagine you have an iPhone and Ipad (or perhaps you are indeed the proud owner of both). You purchased something/product/access on your iPhone but didn’t receive the app access on your iPad, even though both of these devices share the same Apple ID. To access the purchase across all your devices, you would have to take additional steps, so consider the hassle. 

There is no data about existing purchases

It is not that difficult to store it yourself, but it still counts as an effort. After a purchase or renewal of a subscription, you receive a transaction. The only way to restore this information from StoreKit again is to use the Restore API, which is not supposed to be used for this purpose. Thus you are left with an option of keeping this data yourself (in UserDefaults/your server) to provide a user with access to purchases which is an additional reliability risk. If you’re using UserDefaults there is a surprise for you cause once you reinstall the app, you will lose all data.

Lack of essential data

Let’s go through the most common cases. It’s a common practice to make discounts and free trial periods for some purchases. Naturally, you would want to offer discounts based on purchases that a user has already made, but you won’t be able to do so as StoreKit doesn’t provide the means to get the needed information. You also won’t tell whether your customer canceled (or is about to cancel) a subscription unless you have a server listening to App Store Server Notifications. Thus, you won’t be able to offer them a discount, ask for feedback, or retain them by some other means without making an extra effort to maintain additional server infrastructure.

What’s new in StoreKit 2? 

We have already covered a list of new features in our previous article, but today we would like to highlight the most significant two: Swift-first design and API update

Swift-first design

StoreKit 2 takes advantage of advancements in Swift. To be more precise, Concurrency with async and await makes a software engineer’s life much easier. It solves (fully or partially – based on one’s needs) many issues with unnecessarily complicated architecture. 

Fetching product information 

Previously, you had to create a SKProductsRequest, attach a delegate, initialize the request, and be sure to keep a strong reference to the request to ensure task completion. It looked like that:

let productsRequest = SKProductsRequest(productIdentifiers: identifiers)
productsRequest.delegate = self
productsRequest.start()
        
self.productsRequest = productsRequest

And then receive the response:

extension StoreKitService: SKProductsRequestDelegate {
    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
        // handle products here
    }
}

With StoreKit 2 it will look like this:

let storeProducts = try await Product.request(with: identifiers)

And that’s it. The following line will have the product information. 

Purchase 

To process a purchase in StoreKit, you would have to write something like this:

func purchase(_ product: SKProduct) {
    let payment = SKPayment(product: product)
    SKPaymentQueue.default().add(payment)
}

And to handle the response, you would need to write this:

extension StoreKitService: SKPaymentTransactionObserver {
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        transactions.forEach { transaction in
            switch transaction.transactionState {
            case .purchased:
                // handle purchased
            case .failed:
                // handle failed
            case .deferred:
                // handle deferred
            case .restored:
                // handle restored

            default: break
            }
        }
}

In response, you would find an array with one item – your transaction. The same function will be receiving the results from the restoreCompletedTransactions. In the latter case, there are multiple transactions, and that’s why the function returns the array. On top of that, you will also need to remember who called the purchase in order to send the response back to the right place.

One option is to keep completion in the same class that will be returned – you do not have direct access to completion at this point as handling happens in a different unrelated function. If you need some additional data, it would have to be stored somewhere and synchronized later.

Here is how that could be implemented with StoreKit 2:

func purchase(_ product: Product) async throws -> Transaction? {
    let result = try await product.purchase()
    
    switch result {
    case .success(let verification):
        // handle success
        ...
        return result
    
    case .userCancelled, .pending:
        // handle if needed
    
    default: break
}

And again – that’s it.

You have the result from the second line and the rest is its handling. Also, note that a new switch case .userCancelled for transactions that correspond to a user aborting purchase process. To get this info in the previous StoreKit, you would have to process transactionState of SKPaymentTransaction and if it has .failed, then check the error code. It’s important to notice that if there was a problem, you should show an error message to the user, but showing it for a canceled purchase would be silly. It’s nice that the new SDK recognizes this situation as an individual outcome in PurchaseResult and doesn’t mix it up with errors. 

You also don’t need to keep track of who requested a purchase nor think of how it needs to be wrapped. You can just return result at the right moment.

Validation 

As you may notice, after a successful purchase, you might see a verification value that confirms that StoreKit validates this purchase. It is all up to you which field you would follow – do you find device validation reliable or check it on a server.

Additional payment options

Several extra options can be set when a purchase is made, but we found the following is pretty interesting.

Tokens

An arbitrary UUID token can be attached to a transaction that will remain forever. This comes in handy if you have your authorization implementation that isn’t based on AppStore accounts. Let’s say you are providing a streaming service where a subscription provides access across all user devices as long as they stay logged in on your service. In this case, you could simply include your internal accounts as UUID tokens in user purchases. It could be something between these lines:

let result = try await product.purchase(options::[.appAccountToken(yourAppToken))])

Other interesting features

  • Updated interface of promotional purchases
  • Ability to buy products in “batches”. For example, if you have an SKU “1000 coins”, then a user can select five of those and receive 5000 coins at once.

One more thing 

There is a lot to say about the interface updates since almost everything has been reworked and adjusted for the new Swift features, but that’s not the main point. We would like to highlight the changes in the interface of SKPaymentTransactionObserver, which was used to process transactions. For example, whether this is a confirmation of a purchase from a parent account, SCA, an auto-renewal of the subscription, or something else. It is now a listener for the Transaction object. And you can “listen” to new transactions like this:

func listenForTransactions() -> Task.Handle<Void, Error> {
    return detach {
        for await result in Transaction.listener {
            do {
                // handle transaction result here
            }
        }
    }
}

The key is to remember to call transaction.finish(). Otherwise, they will keep coming to the listener on every app restart. Although, this behavior isn’t new as it has not changed since the previous version.

Swift-first design summary

As we can see, Swift’s innovations, coupled with the new StoreKit 2 interface, make developers’ lives much easier. In just a couple of code lines, the developers are now able to: 

  • Fetch product info
  • Launch in-apps
  • Process the purchase results
  • Verify purchases
  • Get new transactions

According to Apple, StoreKit 2 significantly improves the security level of purchase verification, but this is still not a replacement for your server-side validation. And not a reason to give it up.

Powerful new APIs

Here Apple added a lot of new and valuable features. We covered almost everything related to Products and Purchases in the first part. Let’s address the rest now.

Many entities and fields have been reworked, improved, and expanded. Now there is more information on products, purchases, and subscription statuses. Apple has added a large amount of data to the StoreKit 2 public API that was previously only available in a receipt. Since the information in it is encrypted, most of the data available in StoreKit 2 public API will also be encrypted using JWS. For example, information on transactions and auto-renewal of a subscription. And yes, StoreKit 2 will automatically validate this data.

Let’s look at the most exciting advancements of API more closely:

  • Transactions 
  • Current entitlements 
  • Subscription information
  • Auto-synchronization of purchases

Transactions

Now you can get a list of all transactions (or just the latest one) for a particular product directly from StoreKit 2. Previously, you had to parse a receipt to do this. Transaction data can be helpful, for example, for some kind of analytics or simply to share the details with the user on their purchase.

Current entitlements

We have discussed above that the previous version required developers to store the history of active purchases to enable users’ access to it in the app. StoreKit 2 will now do it for you.

Note that this data includes only two types of product:

  • Active subscriptions
  • Non-consumable purchases. Consumable purchases such as coins/ammo/gas will not appear on this list. You must process them immediately upon purchase. 

Subscription information

This part of the article will discuss the most interesting stuff that is included in SubscriptionInfo object. As with transactions, SubscriptionInfo was previously only available in a receipt and required some work on your server. 

Intro offer eligibility

In the previous version of StoreKit, there was no way to determine if a user had a purchase in this product group to decide whether to give them a discount. So you had to do this manually somewhere on the server; now, you can simply call one function and get a Bool in the response.

static func isEligibleForIntroOffer(for groupID: String) async -> Bool

Renewal state

There are several states:

  1. subscribed – the user is currently subscribed.
  2. expired – the subscription expired.
  3. inBillingRetryPeriod – the subscription is in a billing retry period. 
  4. inGracePeriod – the subscription is in a billing grace period state. This feature allows you to extend users access to the app if they have a billing issue. The grace period length varies from 6 to 16 days, depending on the duration of the subscription itself.
  5. revoked – the App Store has revoked the user’s access to the subscription group.

Renewal info

This object will display everything related to the auto-renewal of the subscription. The following information could be found there:

  1. willAutoRenew – a Boolean value that indicates whether the subscription will automatically renew in the next period. If the status is negative, then with some degree of probability, the user will churn. So it’s time to think about how to re-engage them.
  2. autoRenewPreference – the product ID of the subscription that will automatically renew. For example, you can check whether a user has downgraded and plans to use a cheaper option in your subscription. In this case, you can offer that user a discount to keep them on the premium version.
  3. expirationReason – the reason the subscription expired.

An important note about subscription statuses

StoreKit 2 returns an array of statuses. It is necessary for the cases where a user has multiple subscriptions to the same product. For example, they bought one subscription themself and got the second one through family sharing. This way, you will validate the entire array and unlock functionality in your app by referencing the subscription with the highest access level.

Auto-synchronization of purchases

Synchronization of purchases across different devices with one Apple ID is another cool feature. If the user made a purchase on their iPhone and then switched to iPad, this purchase would also be available on it. There is no need for developers to perform additional steps like calling restore().

However, there is a caveat from Apple’s side: automatic synchronizations should cover most of all cases, but as millions of people worldwide use Apple devices, there is still a risk for synchronizations to fail. For such cases, Apple suggests an alternative – a manual call for purchase synchronization – AppStore.sync(). It is pretty similar to restore(), but you will need to call it less often.

Apple also warns that AppStore.sync() should only be called in response to a user action (i.e., pressing a button), since the call initiates the App Store authenticate notification, and if you call it somewhere at the start, then the user experience in the app won’t be satisfactory. 

Summary of the new StoreKit 2 API

A lot of data that previously could only be obtained by decrypting a receipt on the server is now available in the StoreKit itself. It drastically simplifies the work for developers. It has become much easier to check the status of subscriptions and synchronize data across the devices.

So, does StoreKit 2 resolve all the problems of the previous version? Let’s revise the issues we discussed at the beginning of the article.

  1. Is complexity solved? Overall, we still think that this is a complex product that requires a lot of pre-work to understand completely. StoreKit 2 removes the questions like “Where can I get this data?” by introducing “out of the box” solutions.
  2. Has the architecture improved? No doubts. Thanks to Swift Concurrency. 
  3. No validation of purchases? Purchase validation was introduced to StoreKit 2, but API validation remains more reliable, even according to Apple. 
  4. No auto-sync across the devices? Purchases are finally available across all devices with the same Apple ID without any hassle.
  5. No data on existing purchases? This may seem like a minor issue, but it was necessary for everyone who has ever implemented in-app purchases. Now it is also available “right out of the box”.
  6. Missing some valuable data? That still depends on your needs. You may want to parse the receipt for additional data. But StoreKit 2 will cover a lot of use cases. 

To sum up, StoreKit 2 is an excellent product that introduced more than we were asking for. Just think about the features of Swift Concurrency as it solves a lot of everyday problems. Migration to StoreKit 2 won’t happen soon, but there is at least some hope for a brighter future.

Qonversion is staying on top of all new developments and will support StoreKit 2 in all of our products and services. If you’re looking for a single place to manage subscribers and grant them access to premium content — look no further. Our Product Center provides a comprehensive in-app purchases infrastructure so that there’s no need for you to build your servers for validating receipts. Automation and In-App messaging tool can help you to send automatic and personalized messages to your users. In addition, mobile subscription analytics and native integration with marketing services give you the complete visibility of your business, and A/B tests allow you to make justified decisions on your price.

Want to learn more? Don’t hesitate to reach out and stay tuned for new updates on our blog.