Working with in-app purchases in StoreKit 2
Offering in-app purchases is one of the main ways through which apps can make money in the App Store. Since the introduction of in-app purchases, they’ve been handled through the StoreKit framework, a highly asynchronous, delegate-based API written back in the Objective-C days.
Additionally, the actual verification of transactions and unlocking of paid content or features has always been the responsibility of each individual app developer. While that approach has some advantages — like preventing a single vulnerability from enabling bypassing of in-app purchases in every app — for the vast majority of use cases, simple on-device verification of purchases is enough.
This year, Apple is introducing StoreKit 2. This new version brings the ability to verify purchases on-device with very minimal code, full async/await compatibility, and much more. Let’s take a quick look at what’s new.
Tip: testing in Xcode
If you’d like to follow along with the examples in this post, then you can set up a StoreKit configuration file in your Xcode project (a feature that was introduced last year). Just add a new file to your project, choose the “StoreKit Configuration” option, and add a non-consumable product.
Having a StoreKit configuration file in the project means that we can try out the entire in-app purchasing experience without having to configure anything in App Store Connect. After creating the file, you can enable it in Product > Scheme > Edit Scheme, in the “Options” tab.
Fetching products
The first step that’s required before a user can purchase anything is to fetch the app’s products from StoreKit. This can be done quite simply by using the request
method that’s available on the new Product
struct:
func fetchProducts() async throws -> [Product] {
let storeProducts = try await Product.request(with: Set(["codes.rambo.PremiumContent"]))
return storeProducts
}
In this example, I’m only fetching a single product, but this API can be used to fetch all of our products at once, based on their unique identifiers.
To perform the fetch starting from a synchronous context, such as a view controller’s viewDidAppear
method, we can wrap it in an async
block:
private var productLoadingState = LoadingState<[Product]>.idle {
didSet { updateUI() }
}
override func viewWillAppear() {
super.viewWillAppear()
productLoadingState = .loading
async {
do {
let products = try await fetchProducts()
productLoadingState = .loaded(products)
} catch {
productLoadingState = .failed(error)
}
}
}
Once again we see the LoadingState
enum making an appearance (no pun intended). Learn more about it in the Swift by Sundell article “Handling loading states within SwiftUI views”.
Performing a transaction
Once we have downloaded the product information from StoreKit, we can do many things with it, such as displaying a custom user interface where users can pick products to purchase. When the user chooses to buy a product, we can simply call the purchase
method in order to start the purchasing process:
func purchase(_ product: Product) async throws -> Transaction {
let result = try await product.purchase()
switch result {
case .pending:
throw PurchaseError.pending
case .success(let verification):
switch verification {
case .verified(let transaction):
await transaction.finish()
return transaction
case .unverified:
throw PurchaseError.failed
}
case .userCancelled:
throw PurchaseError.cancelled
@unknown default:
assertionFailure("Unexpected result")
throw PurchaseError.failed
}
}
As you can see, the purchase
method is asynchronous — it can throw an error, and it returns a result. Switching on that result gives us different states that the transaction can be in. If the transaction is in the pending
state, then that means that the user is in the “ask to buy” flow, where a parent will have to approve the purchase (more on this later). The userCancelled
state is self-explanatory, and so is the success
state, which gives us yet another enum as an associated value.
The enum we get in the success
state allows us to check whether the transaction has been successfully verified as authentic by Apple. In this example, I’m considering an unverified transaction a failure, only returning the transaction to the caller when it’s in the verified state. Calling finish
on the transaction lets StoreKit know that we’re done with it.
I mentioned the pending state before. When a transaction ends up in that state, it can take hours (or even days) for the transaction to finish or fail, depending on what happens with the approval process. Because of that, the app will have to listen for transactions and update its internal state accordingly.
There’s a new API to deal with that case in StoreKit. The Transaction
struct has a listener
, which is an async sequence that we can iterate over in order to be notified whenever there’s a new transaction for us:
func listenForStoreKitUpdates() -> Task.Handle<Void, Error> {
detach {
for await result in Transaction.listener {
switch result {
case .verified(let transaction):
print("Transaction verified in listener")
await transaction.finish()
// Update the user's purchases...
case .unverified:
print("Transaction unverified")
}
}
}
}
Since the Transaction.listener
sequence never ends (unless we break out of the for
loop, that is), we detach it into a Task
. Early in the app’s lifecycle, we then store a reference to this task, which we can then use later to cancel it if we want to:
private var storeKitTaskHandle: Task.Handle<Void, Error>?
// Call this early in the app's lifecycle.
private func startStoreKitListener() {
storeKitTaskHandle = listenForStoreKitUpdates()
}
That way, whenever a transaction gets added or updated, we’ll get a chance to react to that change.
Giving users access to paid content
A big feature that was missing from StoreKit was a way to actually unlock paid content or features within your app based on the user’s purchases. This is now possible using another new API available on Transaction
.
We can use the static method currentEntitlements(for:)
to fetch the current transaction that gives the user access to a specific product identifier, or just use Transaction.currentEntitlements
, another async sequence which returns all transactions that entitle the user access to a given product or feature.
@MainActor
func updatePurchases() {
async {
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else { continue }
if transaction.revocationDate == nil {
purchasedProducts.insert(transaction.productID)
} else {
purchasedProducts.remove(transaction.productID)
}
}
}
}
Note that I’m checking the transaction’s revocationDate
before giving the user access to the product. This property will be non-nil
if the purchase has been refunded, or if the user was previously entitled to it through Family Sharing, but has since been removed from the corresponding family.
Offering in-app refunds
I’m sure that pretty much every developer who sells in-app purchases in the App Store has had at least one or two emails in the past requesting a refund for an in-app purchase, for whatever reason. Right now all that we can do is to instruct the customer to contact Apple in order to receive their refund.
Fortunately, with the new version of StoreKit, Apple is now offering an API for developers to send users to a refund flow — right from within the app itself:
@MainActor
func beginRefundProcess(for productID: String) {
guard let scene = view.window?.windowScene else { return }
async {
guard case .verified(let transaction) = await Transaction.latest(for: productID) else { return }
do {
let status = try await transaction.beginRefundRequest(in: view.window!.windowScene!)
switch status {
case .userCancelled:
break
case .success:
// Maybe show something in the UI indicating that the refund is processing
setRefundingStatus(on: productID)
@unknown default:
assertionFailure("Unexpected status")
break
}
} catch {
print("Refund request failed to start: \(error)")
}
}
}
This won’t refund the user immediately, but it will at least show an interface where they can ask Apple for the refund, which should be processed within a couple of business days.
As of the publishing of this article, this API doesn’t seem to be working yet, because it requires a server-side update from Apple.
Conclusion
There are many things that I didn’t cover in this article, such as new ways to check for subscription statuses and how purchases are now synced across devices automatically. I really like these new StoreKit APIs, and I think they will enable a much richer shopping experience in apps going forward.
If you’d like to learn more about the topics mentioned here, check out the session “Meet StoreKit 2”.
Take the macOS Spotlight experience to the next level: Create Jira issues, manage GitHub pull requests and control other tools with a few keystrokes. Automate every-day tasks with scripts and boost your developer productivity by downloading Raycast for free.