Apphud – integrate, analyze and improve in-app purchases and subscriptions in your iOS/Android apps

Development

StoreKit 2: what is this and how it affects Apphud?

Hi everyone!

At WWDC 2021 Apple introduced many new APIs and Frameworks for developers as well as iOS 15. One of the biggest changes is related to In-App Purchases – a new StoreKit 2.

StoreKit 2

It’s a brand-new framework, written in pure modern Swift syntax using a new concurrency pattern. Here are the main changes:

  • Removed App Store binary receipt. Yes, long base64 strings are now disappeared from the framework. All done under the hood. Instead of this, Apple lets developers retrieve transaction details on their servers using the new App Store Server API
  • Removed SKPaymentTransactionObserver. Purchasing is now done using the await command
  • Removed SKRequestDelegate. Requesting product information is also done using the await command
  • Added many new APIs, like managing refunds from the app
  • Added Subscription API, including status, renewal info, etc.
  • It’s now possible to retrieve historical transactions as well as the latest transactions
  • Purchased transactions are now automatically validated for you!
  • Product and Transaction are now structures instead of classes
  • Each transaction is signed using JWS (JSON Web Signature)

Products

Product is now a structure and getting product details is done using static method:

public static func request(with identifiers: Set<String>) async throws -> [Product]

In practice it looks like this:

func loadProducts() async {
     do {
         let ids = [ "com.apphud.weekly", "com.apphud.weekly2", "com.apphud.monthly"]
         self.products = try await Product.request(with: Set(ids))
     } catch {
         print("error while loading products: (error.localizedDescription)")
     }
 }

Much easier, isn’t it?

Also, there are new methods in Product, like, ProductType, which returns a type of in-app purchase:

public static var consumable: Product.ProductType
public static var nonConsumable: Product.ProductType
public static var nonRenewable: Product.ProductType
public static var autoRenewable: Product.ProductType

Or, for example, formatted display price of the product:

/// A localized string representation of `price`.
public let displayPrice: String

Another cool change are two new methods of checking trial/intro eligibility:

/// Whether the user is eligible to have an introductory offer applied to their purchase.
public var isEligibleForIntroOffer: Bool { get async }
public static func isEligibleForIntroOffer(for groupID: String) async -> Bool

The first one is instance getter for a particular product, and the second one is a static method which takes subscription group ID as a parameter.

App Account Token

It is now possible to include anonymous user identifier to a Transaction, which persists forever even using REST API. That means that you can map your user with a transaction from REST API.

/// A UUID that associates the purchase with an account in your system.
public static func appAccountToken(_ token: UUID) -> Product.PurchaseOption

Purchasing

Purchase process has been significantly simplified. SKPaymentTransactionObserver has gone and purchase is made with one single line:

let result = try await product.purchase()

As a result, you get enum PurchaseResult:

public enum PurchaseResult {
/// The purchase succeeded with a `Transaction`.
case success(VerificationResult<Transaction>)

/// The user cancelled the purchase.
case userCancelled

/// The purchase is pending some user action.
///
/// These purchases may succeed in the future, and the resulting `Transaction` will be
/// delivered via `Transaction.listener`
case pending
}

This method can throw errors which can be caught easily:

func purchase(_ product: Product) async throws -> Transaction? {
       //Begin a purchase.
       let result = try await product.purchase()

       switch result {
       case .success(let verification):
           let transaction = try checkVerified(verification)

           //Deliver content to the user.
           await updatePurchasedIdentifiers(transaction)

           //Always finish a transaction.
           await transaction.finish()

           return transaction
       case .userCancelled, .pending:
           return nil
       default:
           return nil
       }
   }

func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
       //Check if the transaction passes StoreKit verification.
       switch result {
       case .unverified:
           //StoreKit has parsed the JWS but failed verification. Don't deliver content to the user.
           throw StoreError.failedVerification
       case .verified(let safe):
           //If the transaction is verified, unwrap and return it.
           return safe
       }
}

In order to run async code within a sync method, just wrap it into async {}:

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let product = products[indexPath.row]
async {
await self.purchaseAsync(product: product)
}
}

TransactionListener

Instead of Transaction Observer, Apple added Transaction Listener, which returns transactions that didn’t come through a direct call to purchase().

// Start a transaction listener as close to app launch as possible so you don't miss any transactions.

taskHandle = listenForTransactions()

func listenForTransactions() -> Task.Handle<Void, Error> {
       return detach {
           //Iterate through any transactions which didn't come from a direct call to `purchase()`.
           for await result in Transaction.listener {
               do {
                   let transaction = try self.checkVerified(result)

                   //Deliver content to the user.
                   await self.updatePurchasedIdentifiers(transaction)

                   //Always finish a transaction.
                   await transaction.finish()
               } catch {
                   //StoreKit has a receipt it can read but it failed verification. Don't deliver content to the user.
                   print("Transaction failed verification")
               }
           }
       }
   }

You can use Transaction Listener to handle transactions that were previously in a pending state. For example, to handle “Ask To Buy” or “Strong Customer Authentication”.

Transaction History

StoreKit 2 can also return transaction history using TransactionSequence struct. You can get all transactions or just latest one. Or even get entitlements – a set of transactions, one per each entitlement, which is subscription or single purchase. In other words, you get the latest transaction for a user’s subscription and all transactions for non-consumable purchases, if available.

Here are these for methods in code:

/// A sequence of every transaction for this user and app.
public static var all: Transaction.TransactionSequence { get }

/// Returns all transactions for products the user is currently entitled to
///
/// i.e. all currently-subscribed transactions, and all purchased (and not refunded) non-consumables
public static var currentEntitlements: Transaction.TransactionSequence { get }

/// Get the transaction that entitles the user to a product.
/// - Parameter productID: Identifies the product to check entitlements for.
/// - Returns: A transaction if the user is entitled to the product, or `nil` if they are not.
public static func currentEntitlement(for productID: String) async -> VerificationResult<Transaction>?

/// The user's latest transaction for a product.
/// - Parameter productID: Identifies the product to check entitlements for.
/// - Returns: A verified transaction, or `nil` if the user has never purchased this product.
public static func latest(for productID: String) async -> VerificationResult<Transaction>?

Subscription Info

Another great improvement in StoreKit 2 is Subscriptions API. Now you can get subscription info right from the app!

What’s available:

  • Renewal State. It’s simply subscription status: subscribed, expired, inBillingRetryPeriod, inGracePeriod, revoked.
  • Renewal Info. Get all main subscription information, like, autoRenewalStatus, gracePeriodExpirationDate. This info is similar to pending_renewal_info from verifyReceipt endpoint’s JSON.
  • Latest transaction. Get the most recent transaction. Developer is responsible to check whether transaction is not revoked and not upgraded.

All transactions are available upon app download and automatically sync on each device. There is still sync() method in StoreKit 2 API which forces transaction to sync, however in most cases it won’t be needed. You can remove your Restore Purchases button from UI as it will because useless.

The full video from WWDC can be viewed here.

StoreKit 2 + Apphud = ❤️

StoreKit 2 makes life easier for developer, as it can validate transaction and update subscription status out from the box. It is positive news for Apphud:

  • SDK (finally) can get rid of sending large receipts to backend, which will lower outcoming Internet traffic and increase server response time.
  • The simpler API, the fewer bugs may occur in SDK and transactions handling.
  • iOS will now be responsible for subscription status updating and restoring, which will allow Apphud to focus on more important parts of the service.

Unfortunately, StoreKit 2 is only available to devices running iOS 15. It may take a few years before a new framework becomes mainstream.

For the lower iOS versions, you still have to use old API. But we are going to update our SDK to support both StoreKit versions. A new methods with await/async syntax will appear in SDK soon.

The updated version of Apphud SDK with StoreKit 2 support will be available later this summer.

Leave a Reply

Your email address will not be published. Required fields are marked *