A detailed guide to in-app purchases for your iOS app
Updated: September 9, 2024
In the world of mobile apps, in-app purchases have become an increasingly popular way to monetize applications. With the rise of subscription-based models, apps can now offer a recurring revenue stream by providing users with access to premium features or content for a recurring fee. But implementing in-app purchases can be tricky and requires a deep understanding of Apple’s StoreKit framework.
This article provides a comprehensive guide to in-app purchases on iOS, covering everything from creating and configuring products in App Store Connect to using the StoreKit framework in your app code and testing with .storekit
files. The article also covers best practices for handling transactions, restoring purchases, and supporting family sharing.
One of the key challenges when implementing in-app purchases is ensuring that users have a smooth and reliable experience. This includes handling renewals, refunds, billing issues, and more. The article offers tips on how to handle these scenarios and ensure that users are always receiving the correct access for their subscriptions.
Whether you’re new to in-app purchases or looking to improve your existing implementation, this article has everything you need to know to make sure your app is monetizing effectively and providing a great user experience.
Are you striving to optimize your app’s monetization and offer a flawless user experience? Start your journey with Adapty! Schedule a free demo call with us to uncover how our cutting-edge solutions can drive your iOS in-app purchases, ensuring a seamless and reliable experience for your users while maximizing your recurring revenue stream.
Prerequisites
- macOS 13.2.1
- Xcode 14.2
- iOS 16.0+
- SwiftUI
- StoreKit 2
Types of Apple In-App Purchases
It’s essential to understand the different types of in-app purchases to come up with the most effective business model for your app. There are four types of in-app purchases:
- Consumable: These are digital items that can be purchased multiple times and are typically used up or consumed after use. Examples of consumable in-app purchases include in-game currency, temporary power-ups, or virtual goods like clothing or furniture in a virtual world.
- Non-Consumable: These are digital items that are purchased once and permanently unlocked within the app. Examples of non-consumable in-app purchases include the permanent removal of ads or lifetime access to exclusive content.
- Auto-Renewing Subscription: This type of in-app purchase allows users to access content or features for a set period of time, with the subscription automatically renewing at the end of each period. Users have the option to cancel at any time. Examples of auto-renewing subscriptions include access to premium content or features in a news app or a music streaming service.
- Non-Renewing Subscription: This type of in-app purchase allows users to access content or features for a set period of time, but the subscription does not renew automatically. Users must manually renew the subscription to continue accessing the content or features. A good example of it is access to a limited-time course or premium content within an app.
It’s important to note that in-app purchases can only be offered for digital items and not physical goods or services. Make sure to consult Apple’s full documentation on creating in-app purchase products to ensure you’re following their guidelines.
Also, note that additional downloadable content available for purchase can be stored on either the app developer’s server or Apple’s servers.
Step 1. Getting started
To start the exploration of the in-app purchases world, download the starter project from our GitHub.
In the project, you will find some parts of the UI presets including the counters for different in-apps.
Step 2. Configure the project
Now you need to configure the project. First, you need to log into your App Store Connect account in Xcode.
Then select the project file, and select target “AdaptyPurchase”. Go to “Signing & Capabilities” and change the team to your own one. Then you need to update the bundle identifier so that it is unique for your app.
Next, add the In-App Purchase capability. Click on ‘+ Capability’.
Then type in the search field “in-app” and select “In-App Purchase”.
Step 3. Use a .storekit file for local testing
The .storekit
file is a good way to define the available in-app products and their metadata for local testing. It might be useful for testing without a network connection in case you are working on your way to the Canary islands or just want faster debug cycles.
Here is how we create it:
- In Xcode, navigate to File > New > File or use the keyboard shortcut ⌘N.
- In the template selection dialog, choose “StoreKit Configuration File” under the “iOS” section.
- Type in the name “SyncedProducts” and do not tick “Sync this file with an app in App Store Connect”.
- Click “Next” and then “Create”.
Now let’s add the first product which will represent a non-consumable item in some game.
Open the newly created Products.storekit
and click on the “+” symbol in the bottom left corner. Then select “Add Non-Consumable In-App Purchase. In the opened editor window update the Reference Name to “Stone”, and Product ID to “stone”.
Then click on the only localization twice and change the Display Name to “Indestructible Stone”. Because did you try to break the curling stone?
Right-click on the Products.storekit
file. Select Open As > Source Code. It should look similar to the following:
JSON
{
"identifier" : "<FooBar>",
"nonRenewingSubscriptions" : [
],
"products" : [
{
"displayPrice" : "0.99",
"familyShareable" : false,
"internalID" : "<FooBar>",
"localizations" : [
{
"description" : "",
"displayName" : "Indestructible Stone",
"locale" : "en_US"
}
],
"productID" : "stone",
"referenceName" : "Stone",
"type" : "NonConsumable"
}
],
"settings" : {
},
"subscriptionGroups" : [
],
"version" : {
"major" : 2,
"minor" : 0
}
}
As you can see the structure of this file is pretty simple which makes it easier to test in-app purchases. However, .storekit
file is not used for the published app hence it is not the default source of in-app purchases. We need to explicitly tell Xcode to use this file during the next app run.
To do that, click on the target icon with your app name in the Xcode status bar. Then click “Edit Scheme”.
In the “Options” tab of the “Run” scheme select the Products.storekit
file we created.
Sandbox environment
Sandbox testing is the older way of testing in-app purchases in a simulated environment that mimics the App Store environment. It is essential to test and verify that in-app purchases work correctly before releasing them to production.
Sandbox testing for in-app purchases is more difficult than with .storekit
files because it requires a live connection to the App Store server. The sandbox environment is isolated, but it still requires a live connection to the App Store server to verify transactions.
This means that in order to test in-app purchases in the sandbox environment, you need to have a functioning backend server that can handle requests from the App Store server. This server must be able to communicate with the App Store server to validate and complete transactions.
In contrast, testing with .storekit
files can be done offline without requiring a connection to the App Store server. This makes it a simpler and more straightforward process. However, it’s important to note that testing with .storekit
files does not fully replicate the App Store environment, so sandbox testing is still necessary to fully verify in-app purchases before releasing them to production.
Step 4. Fetch and display the in-app product
The .storkit file emulates the in-app purchases we will register in the app store later. We need to fetch the products first to display them.
Get in-apps in the app
Let’s do that in our Store component which represents an actual store with goods and subscriptions. Open the Store.swift
and add the following lines to it.
swift
// 1:
import StoreKit
// 2:
class Store: ObservableObject {
// 3:
private var productIDs = ["stone"]
// 4:
@Published var products = [Product]()
// 5:
init() {
Task {
await requestProducts()
}
}
// 6:
@MainActor
func requestProducts() async {
do {
// 7:
products = try await Product.products(for: productIDs)
} catch {
// 8:
print(error)
}
}
}
- First, we need to import
StoreKit
which allows us to interact with the in-app purchases. It contains both StoreKit 1 and StoreKit 2 APIs. Store
object should conform to anObservableObject
protocol so that our SwiftUI interface could change when the `Store` data changes.productIDs
is an array of IDs that are used to uniquely identify the products we create in the .storekit file or on App Store Connect.products
is an array that stores the available in-app purchases and subscriptions. It is marked@Published
to update the user interface as soon as we fetch the data.- We need to start fetching the products as soon as the View that displays them appears on the screen.
- Getting products is an async operation because the
.storekit
file only works for testing purposes and the published version of your app will be getting the products from App Store using the network connection. When the products are received we need to switch to the same thread as UI works on to display them right away. This is the reason to mark the method as@MainActor
. product
is the main entity of StoreKit 2 which represents any in-app purchase. The staticproducts
method fetches the products with the provided IDs. It is asynchronous and can throw an error.- The error
products
method might throw is of typeSKError
which happens for 21 different reasons. If you want to dive deeper into it, we have a guide about handling every one of them.
If we run the app now, nothing will happen in the interface.
Display in-app purchases
Find the “To buy” section in the `ContenView.swift`
file. It should look like this:
Section(header: Text("To buy")) {
// Add in-app purchases here...
}
Remove the comment and put the following code in its place:
// 1:
ForEach(store.products, id: .id) {
product in
HStack {
// 2:
Text(product.displayName)
Spacer()
// 3:
Button("(product.displayPrice)") {
// Here is going to be purchasing action...
}
}
}
- Even though for now there is only one product, we account for the future when our Store will contain more in-app purchases.
- We show the
displayName
of the product which might be different depending on the user’s locale and the localizations we created. - Users usually prefer to see the price in the currency of their country. That is why we use
displayPrice
value. The `price` property of theProduct
is usually used for calculations and never for the user interface.
Now run the app and you should see something like this:
Our in-app product is available for sale! However, if you tap on it, nothing happens. Let’s correct it by implementing the purchase feature.
Step 5. Add the purchase feature
Now that the user sees the available products, let them buy! Now, add the array for the non-consumable products that we’ll sell first.
@Published var purchasedNonConsumables = [Product]()
Then
@MainActor
func purchase(_ product: Product) async throws -> Transaction ? {
// 1:
let result =
try await product.purchase()
switch result {
// 2:
case .success(.verified(let transaction)):
// 3:
purchasedNonConsumables.append(product)
// 4:
await transaction.finish()
return transaction
default:
return nil
}
}
- Initiate an asynchronous call to the purchasing API.
- In this step we only handle the successful action path. That means the user really paid in the modal sheet and the transaction was successfully verified.
- After the transaction verification we are sure that the purchase is successful and we can provide the content to the user.
- Calling the
finish
method on a transaction tells AppStore that we have successfully delivered the paid content to the user. It’s extremely important for consumable in-app purchases and you’ll see why in Step.8.
Next thing we need to do is call this action from our View when the user taps on the product price. Go to ContentView.swift
and find the comment we left earlier.
Button("(product.displayPrice)") {
// Here is going to be purchasing action...
}
Replace the comment with a call to purchasing method.
Button("(product.displayPrice)") {
Task {
try await store.purchase(product)
}
}
And to see when the item is purchased add
ProductView(
icon: "🥌",
quantity: "(store.purchasedNonConsumables.count)"
)
Now if you run the app you’ll see the purchasing menu
As we use the .storekit
file we are in testing mode. Don’t be afraid to tap Purchase, you won’t be actually charged but the purchase will be successful. After you do that, you should see the number of curling stones change from 0 to 1.
Now if you close the app and open it again all the numbers will be zeros again. Imagine the face of the user when they see that! Furthermore, it’s not the only problem. Let’s correct all of them ASAP.
Step 6. Listen for transactions
You might’ve noticed a hint of what we are going to do now in a form of a warning in Xcode. It’s displayed on the line where we fetch the products.
What that means is if something goes wrong after the purchase was successfully made, we might still miss the transaction. This can be solved by listening to transaction updates provided by StoreKit in real time. Let’s write the function that would solve the task.
func listenForTransactions() -> Task < Void, Error > {
// 1:
return Task.detached {
// 2:
for await result in Transaction.updates {
switch result {
// 3:
case let.verified(transaction):
// 4:
guard
let product = self.products.first(where: {
$0.id == transaction.productID
})
else {
continue
}
self.purchasedNonConsumables.append(product)
// 5:
await transaction.finish()
default:
continue
}
}
}
}
- The listening mechanism should perform its work in real-time. Meanwhile, the app should be able to perform other work such as processing user interactions. That’s why we detach the task.
Transaction.updates
constantly monitors the new transactions and provide the new ones as they appear.- Swift API of StoreKit 2 makes us check if the transaction was really verified.
Transaction
doesn’t provide us with theProduct
object that the user purchased. Instead, it gives us the product’s id and then we should get it separately.- After we provide the content again, we must finish the transaction.
Now we need to start the listener. To do that, add a property that will store a detached task first.
var transacitonListener: Task<Void, Error>?
And then call the task creation method we’ve just written in the initializer of the Store
object.
init() {
transacitonListener = listenForTransactions()
Task {
await requestProducts()
}
}
The warning won’t appear if you run the app again. However, after you purchased the product once, you won’t see the purchasing menu if you try to purchase it again. Instead, the counter of curling stones will just increase by one every time you tap on the product.
The reason for that is you can purchase a non-consumable in-app only once but with every tap purchase method executes the success case where we append the same product to the array. Let’s apply an easy fix by changing the array for non-consumables to a set of them.
Change
@Published var purchasedNonConsumables = [Product]()
To
@Published var purchasedNonConsumables = Set<Product>()
And in 2 places change
purchasedNonConsumables.append(product)
To
purchasedNonConsumables.insert(product)
Try and test in-app purchases now and you’ll see that the counter updates only once.
The last problem we are going to solve with transaction listening is the in-app purchase persistence problem I mentioned at the end of the previous step.
First, let’s put the transaction processing in a separate method.
@MainActor
private func handle(transactionVerification result: VerificationResult <Transaction> ) async {
switch result {
case let.verified(transaction):
guard
let product = self.products.first(where: {
$0.id == transaction.productID
})
else {
return
}
self.purchasedNonConsumables.insert(product)
await transaction.finish()
default:
return
}
}
And call this method in `listenForTransactions`
:
func listenForTransactions() -> Task<Void, Error> {
return Task.detached {
for await result in Transaction.updates {
await self.handle(transactionVerification: result)
}
}
}
Now let’s add the current user entitlements processing.
private func updateCurrentEntitlements() async {
for await result in Transaction.currentEntitlements {
await self.handle(transactionVerification: result)
}
}
To provide the desired app behavior for each in-app entitlement status, the Transaction.currentEntitlements
will retrieve the latest transactions, provided that there is an internet connection. In the absence of internet connectivity, it will fetch locally cached data. Additionally, transactions are automatically synced to the device whenever the internet connection is restored, allowing the app to maintain up-to-date transactions even when the user goes offline.
And add the call to this function in the initializer:
init() {
transacitonListener = listenForTransactions()
Task {
await requestProducts()
// Must be called after the products are already fetched
await updateCurrentEntitlements()
}
}
We must call the current entitlements to update after the products are fetched because the transaction doesn’t include the product.
If you run the app now, it will update the counter of curling stones right after the launch of the application.
However, this way of understanding which of the products is already purchased doesn’t work for every type of in-app purchase. One of them is consumables which I’ll cover in the next step.
2024 subscription benchmarks and insights
Get your free copy of our latest subscription report to stay ahead in 2024.
Step 7. Deliver Content for consumables
Let’s introduce a new entity into our in-game store. To do that open the Products.storekit
, tap on the ‘+’ button, and select “Add Consumable In-App Purchase”. The reference name is “Heart”, the Product ID is “heart”, and the price is $2.99. After tapping on the available localization change the Display Name to “Life Heart”.
Extend the array
productIDs
with one more item “heart” in the “Store” class. This should fetch one more product for our product list.
If you try to buy the new item now, it will be included in the curling stone category. We should fix that by creating a separate array for consumables. Note that we can not use a set this time, as consumables can be purchased multiple times.
Add this line to the “Store” class:
@Published var purchasedNonConsumables = Set<Product>()
@Published var purchasedConsumables = [Product]() // new line
Then also change the ProductView
quantity
parameter:
ProductView(
icon: "❤️",
quantity: "(store.purchasedConsumables.count)"
)
Add a new function for adding a purchased product which will put the different types of products into separate collections.
private func addPurchased(_ product: Product) {
switch product.type {
case .consumable:
purchasedConsumables.append(product)
case .nonConsumable:
purchasedNonConsumables.insert(product)
default:
return
}
}
Add a call to this function in
handle(transactionVerification:)
function by replacing a line
self.purchasedNonConsumables.insert(product)
By
self.addPurchased(product)
If you try to purchase the “Life Heart” now, even multiple hearts, everything will work correctly. Right until you try to restart the app. You will see “0” Hearts again.
The reason for that is consumables are not stored in `Transaction.currentEntitlements`.
It’s extremely important to persist the consumable in-app purchases using other storage mechanisms. We will apt for UserDefaults
.
Create a new file called Persistence.swift
. Add the following implementation into it.
import Foundation
class Persistence {
static let consumablesCountKey = "consumablesCount"
private static let storage = UserDefaults()
static func increaseConsumablesCount() {
let currentValue = storage.integer(forKey: Persistence.consumablesCountKey)
storage.set(currentValue + 1, forKey: Persistence.consumablesCountKey)
}
}
The Persistence class has a simple function of storage of the current number of purchased consumables for key Persistence.consumablesCountKey
. We can use the same key to get the number in the UI later.
Add a new property to the ContentView
.
@AppStorage(Persistence.consumablesCountKey) var consumableCount: Int = 0
Make it the displayed number of hearts.
ProductView(
icon: "❤️",
quantity: "(consumableCount)"
)
Call the increaseConsumablesCount
in addPurchased(_ product:)
function:
purchasedConsumables.append(product)
Persistence.increaseConsumablesCount() // new line
Now if you run the application again, buy some hearts and restart it, you will see that it works correctly.
We have 2 more types of in-app purchases to implement but first, let’s see how to prepare a published version of IAPs.
Step 8. Create an app in App Store Connect
First, we need to create the app in App Store Connect to make our in-app purchases available to real users. To do that you need to
- Log in to your Apple Developer account and navigate to the App Store Connect homepage.
- Accept all the agreements App Store Connect asks you to in the banner on the Home page of your App Store Connect account.
- On the App Store Connect homepage click on the ‘+’ icon in the top left corner of the screen and select ‘New App’ from the dropdown menu.
- Tick the iOS platform, and select the Bundle ID that you have chosen in Xcode before.
- Fill the rest of the fields to your liking and click “Create”.
The app should appear on the homepage dashboard.
Step 9. Register IAPs in App Store Connect
At the App Store Homepage click “My Apps” and then on the app, we have just created. On the sidebar, you can find a “Features” section. The in-app purchases are created using the “In-App Purchases” and “Subscriptions” pages.
Register Consumable In-App Purchase
“In-app purchases” is used for consumable and non-consumable IAPs. Let’s start with them.
Go to “In-app purchases” and click the “Create” button in the middle of the page. You can also use a ‘+’ button near the header.
Select a “Consumable” type, “Heart” as Reference Name, “heart” as Product ID, and click “Create”.
Scroll the loaded page down to the Availability section and click on “Set up availability”. Here you can change the regions where this IAP is available in. We’ll leave everything as it is and click “Confirm”.
Lower on the product page you can find “Price Schedule”. Click on “Add Pricing”. Select currency and price and click “Next”. On the next page you can adapt the price for the different regions. We won’t change anything so just click “Next” and then “Confirm”.
On the product’s page scroll down to “App Store Localization” and click on “Add Localization”. Type “Life Heart” in “Display Name” and any suitable description. Then click “Create”.
All the data necessary for testing is added. Now you can click on “Save” at the top of the page.
Register Non-Consumable In-App Purchase
Now we should do the same for the “Indestructible Stone”. Click on plus button and fill out the data exactly as in Step 4. Fill in the “Availability”, “Price Schedule” and “App Store Localization” section and click “Save”.
Register Non-Renewing Subscription
To create the non-renewing subscription you should go to “Subscriptions” page, scroll down to the bottom and in a section called “Non-Renewing Subscriptions” click on “Manage” link.
Then click on the ‘+’ button next to the header, type in Reference Name “Tournament Streaming”, Product ID “tournament” and click create. Set up global availability, global price schedule of $9.99 and localization. Write “Tournament Streaming” for Display Name.
Click “Save” and the new IAP is ready.
Register Auto-Renewing Subscription
The auto-renewing type of subscription is created a bit differently. First, you need to create a subscription group and then create different tiers or types of subscriptions.
Users can buy only one subscription in the same subscription group. Using this you can easily handle subscription upgrades and we see how to do that later in the code.
To create the subscription group go to the subscriptions page and click on ‘+’ button next to the “Subscription Groups” title. Type in the Reference Name “Membership” and click “Create”.
On the loaded page add App Store Localization with the Subscription Group Display Name “Membership”.
Now let’s create the first level of service subscription. On the Subscription Group page in the section “Subscriptions” click “Create”. Add the Reference Name “Elite” and Product ID membership.elite
and click “Create”.
On the opened page fill in global availability, price of $9.99, and localization with the Display Name “Elite Membership”. In comparison to other in-apps, subscription has another required field which is “Subscription Duration”. Set it to 1 month and click “Save” above.
Add another tier the same way. Set the Product ID to membership.pro
, priced at $4.99, 1-month duration, and Display Name “Pro Membership”. Click “Save”.
Note that the subscriptions in the list should always go from the highest level of service to the lowest in descending order.
Step 10. Synced .storekit
file
Now let’s create another .storekit
file that contains all the in-app purchases and subscriptions that we created in App Store Connect. To do that
- In Xcode, navigate to File > New > File or use the keyboard shortcut ⌘N.
- In the template selection dialog, choose “StoreKit Configuration File” under the “iOS” section.
- Type in the name “Products” and tick “Sync this file with an app in App Store Connect”.
- Select the team profile and an app we created before in App Store Connect, click “Next”, and then “Create”.
- Click on the target icon with your app name in the Xcode status bar. Then click “Edit Scheme”, and in the “Options” tab of the “Run” scheme select the
SyncedProducts.storekit
file we created
Now if you launch the app, you should see all the newly created products from App Store Connect. If you’ll need to re-sync the file then open it and tap the button in the bottom left corner that looks like the page reload in the browser.
Step 11. Track time for Non-Renewing subscriptions
Let’s add a tournament streaming in-app to the products list. First, let’s add a new product id.
private var productIDs = ["stone", "heart", "tournament"]
Then we need to add a new set for the subscription. A user can buy only one non-renewing subscription with the same id.
@Published var purchasedNonConsumables = Set<Product>()
@Published var purchasedNonRenewables = Set<Product>() // new line
For the tournament streaming
var tournamentEndDate: Date = {
var components = DateComponents()
components.year = 2033
components.month = 2
components.day = 1
return Calendar.current.date(from: components)!
}()
Update `addPurchased(product:)`
by adding the following case.
case .nonRenewable:
if Date() <= tournamentEndDate {
purchasedNonRenewables.insert(product)
}
Non-renewable subscriptions do not contain an expiry date.
Step 12. Auto-renewing subscriptions
Auto-renewing subscriptions have become one of the most popular ways of monetization. Let’s implement the two levels of membership that we have registered.
First, let’s add a set for the subscription products. There can be only one subscription with the same product id at a time.
@Published var purchasedSubscriptions = Set<Product>()
Then we should add the insertion in the addProduct
method for the autoRenewable
case:
case .autoRenewable:
purchasedSubscriptions.insert(product)
And finally let’s update the UI so that it shows the actual subscription information:
ProductView(
icon: "⚽️",
quantity: "(store.purchasedSubscriptions.count)"
)
Now launch the app and subscribe for the Pro Membership. Your subscription count should become “1” after that. You might need to wait for a couple of seconds for that. If you subscribe to Elite Membership now, you will see “2” in the subscription counter.
But something is wrong as we created a group of subscriptions and there can be only one subscription valid in the same group. Why does Transaction.currentEntitlements
still return the old subscription?
That happens because Apple still provides you with the previously purchased subscription as a current entitlement. However, there is a mechanism for filtering out the upgraded subscriptions.
Let’s add another line to our handle(transactionVerification:)
method.
guard !transaction.isUpgraded else { return transaction } // New line
self.addPurchased(product)
Now, whenever the user upgrades the subscription, we filter out the lower-level subscription and provide the higher access features. The subscription counter should show “1” now.
But what if our user wants to change the subscription again or cancel it? What should we do with it?
Step 13. Subscription management and special offers
Note: Everything in this step works only for auto-renewable subscriptions.
Subscription Management
You can provide a native interface for subscription management. In our current app subscription purchasing stays available but in your real app, you’ll probably hide the subscription view and show only upgrade offers.
It is still good to provide a user an option to update the subscription level or cancel it from the app. Maybe somewhere in the support section.
Let’s try to implement this part and go to our SupportView
. First, we need to add a flag for the state of the subscription management screen showing:
@State var isManageSubscriptionsSheetPresented: Bool = false
Then let’s add a function that opens the subscription management screen.
func showManageSubscriptionSheet() {
isManageSubscriptionsSheetPresented = true
}
And lastly we should add a SwiftUI modifier function call to the Form
. You can do it this way:
Form {
Button("Subscription management") {
showManageSubscriptionSheet()
}
Button("Request a refund") {
}
Button("Redeem code") {
}
}
.manageSubscriptionsSheet(isPresented: $isManageSubscriptionsSheetPresented)
Now you can run the app, go to the “Support” section and after tapping on “Subscription management” you should see the following:
Subscription Offer Codes
Another great thing you can do for your users is provide them with a promo code. Unfortunately, you can only do that after your app and IAP are approved by Apple. Before that, you can test the feature using our beloved .storekit
file.
Its API is almost the same as subscription management. Try to implement it yourself in SupportView
using this API:
.offerCodeRedemption(isPresented: $isOfferCodeRedepmtionPresented)
If you have trouble implementing it, you can download the final version of the project.
Step 14. Add the Restore Purchases option
StoreKit 2 automatically keeps track of in-app subscription status and transaction history through Transaction.currentEntitlements
, making it unnecessary for users to manually restore purchases.
However, it’s a best practice to have a “Restore Purchases” button, which satisfies the App Store Review Guidelines. Adding in the “Restore Purchases” functionality is easy thanks to AppStore.sync()
.
It’s rarely needed but nice to have, as users may wonder whether purchases are up to date or not. In cases where a user suspects that the app is not showing all transactions, calling AppStore.sync()
will force the app to obtain transaction information and subscription status from the App Store.
You need to add one line to the restore method for the “Restore Purchases” button to start working in our app.
@MainActor
func restore() async throws {
try await AppStore.sync()
}
This should trigger the transaction update. However, in StoreKit 2 it’s mostly done because of Apple requirements as users feel safer this way.
Step 15. Handling Refunds and Customer Support
The app market gets more and more competitive and customer support is more important than ever, and it’s essential to have a solid strategy in place for handling service issues and refunds.
Handling Refunds
To handle the refunds we need the user to select transactions for which to handle the refund. Open the Store
class and add the entitlements array.
@Published var entitlements = [Transaction]()
Then we need to start appending the transactions provided by StoreKit to this array. Change the body of updateCurrentEntitlements
in the following way.
for await result in Transaction.currentEntitlements {
if let transaction = await self.handle(transactionVerification: result) {
entitlements.append(transaction)
}
}
Now we need to update the UI.
We will handle refunds in a separate screen as the user needs to choose which transaction to refund. Create a new file by pressing ⌘N. Select the file type SwiftUI View
. And name it RefundView.swift
.
Add
import StoreKit
Near the SwiftUI
import statement.
Next, add the in the top of the RefundView
struct.
@EnvironmentObject var store: Store
@Environment(.dismiss) private var dismiss
@State var selectedTransactionID: UInt64?
@State var isRefundRequestPresented: Bool = false
After the body
property, write a function that will configure and show the refund screen.
func startRefund(transactionID: UInt64) {
selectedTransactionID = transactionID
isRefundRequestPresented = true
}
Replace the body contents by this:
Form {
ForEach(store.entitlements, id: .id) {
transaction in
HStack {
Text(transaction.purchaseDate.formatted())
Spacer()
Button("Refund") {
startRefund(transactionID: transaction.id)
}
}
}
}
Then add to the body the special refund modifier.
Form {
…
}
.refundRequestSheet(
for: selectedTransactionID ?? 0,
isPresented: $isRefundRequestPresented
) { result in
handleRefund(result: result)
}
After that the code won’t compile. Add the handleRefund(result:)
function that we just used to resolve the issue.
func handleRefund(result: Result<StoreKit.Transaction.RefundRequestStatus, StoreKit.Transaction.RefundRequestError>) {
switch result {
case .success(.success):
dismiss()
default:
return
}
}
Main documentation of Refund API is made for UIKit. It is more descriptive.
Consumption API
For consumable in-app purchase refunds you need to share information about a customer’s in-app purchase with the App Store. When a customer requests a refund for a consumable in-app purchase, the App Store will send a ConsumptionRequest
notification to the developer’s server, which can respond with the consumption data. This information can be used to inform the refund decision process.
Dealing With Customer Support Issues
In terms of handling renewals, cancellations, billing issues, and other subscription management tasks, Transaction.currentEntitlements
proves to be a useful tool. It will fetch and return the latest transactions for each active auto-renewable subscription and non-renewing subscription every time the app is launched, allowing users to maintain their entitlements after renewals and lose them upon cancellation. However, this may not always result in the best user experience.
For instance, while renewals and cancellations can go unnoticed, billing issues should be brought to the users’ attention so that they can be resolved in time. Although on-device subscription handling can keep the subscription status up-to-date, it cannot effectively inform the user of billing issues and grace periods.
Therefore, server-side subscription handling is preferable, as it can promptly detect and notify the user of such events, providing a better user experience.
Receipt Validation
In StoreKit 1 validating receipts was a major part of properly integrating StoreKit. It required validating and parsing receipts to determine purchases and what to unlock for users. Apple has documentation on the best ways to validate receipts, which includes local, on-device receipt validation and server-side receipt validation with the App Store.
StoreKit 2 improves the process by encapsulating all validation and parsing inside Transaction.currentEntitlements
, which makes it easier for developers to use.
As a result, there is no need to worry about validating receipts in StoreKit 2 unless you want to share the purchases with the backend and across multiple platforms. If you want to see how it works in action, use our receipt validator.
IAPs best practices
When it comes to in-app purchases, there are several best practices that you should follow to create a seamless and user-friendly experience. One of the most important is to let users experience your app before making a purchase, as they may be more likely to invest in paid items or features after discovering their value. Additionally, if you offer auto-renewable subscriptions, consider supporting limited free access to your content.
Another key factor is designing an integrated shopping experience that does not make users feel like they’ve entered a different app. Your products should be presented in a way that mirrors the style of your app, with simple and succinct names and descriptions. It’s also important to display the total billing price for each in-app purchase you offer, regardless of the type, so users know the total amount they will be charged.
Lastly, it’s crucial to use the default confirmation sheet provided by the system to prevent accidental purchases, and not modify or replicate it. By following these guidelines, app developers can create a smooth and transparent in-app purchase experience for their users.
Hide the store when the user cannot make a payment
There are cases when a user is forbidden to make payments. For example, when parental control is on or the device has an MDM profile that doesn’t allow payments. MDM is mobile device management, you can read more here.
Let’s implement it in our app. Find the ForEach
block that is used for the product display. Add a condition that the user is allowed to make payments and show the warning in case it is not possible to proceed with the payment instead of showing the products.
if AppStore.canMakePayments {
ForEach(store.products) {
product in
HStack {
Text(product.displayName)
Spacer()
Button("(product.displayPrice)") {
Task {
try await store.purchase(product)
}
}
}
}
} else {
Text("Unfortunately, your Apple account doesn't allow payments")
}
Enable family sharing
Family sharing is allowed for non-consumable and auto-renewable in-app purchases. To test the family sharing feature, go to Products.storekit
file. Select one of the subscriptions and select “On” in “Family sharing”.
Now you can check if a product is set to be shared with the family by calling product.isFamilyShareable
.
To enable family sharing for an actual subscription, go to App Store Connect and select one of the subscription levels. In the top section of the subscription page find “Family Sharing” and click on the “Turn On” link below. Then click “Confirm”. It’s that easy!
Do I need server-side logic for in-app purchases?
I ask myself this question for every feature of the app. Think of the following to decide on the matter. While client-side logic may seem simpler to implement, it has its limitations, especially when it comes to analytics, handling refunds, and promotional offers. Server-side logic, on the other hand, provides more flexibility and control, but it also has its drawbacks.
One significant advantage of server-side logic is security. It can help prevent fraudulent purchases and ensure that only legitimate purchases are granted access to the app’s content. Additionally, server-side logic allows for cross-platform compatibility, making it possible to extend in-app purchases to other platforms such as Android or the web.
However, implementing server-side logic can come at a cost, both in terms of time and money. You’ll need to manage both the server and client side, which can be complex and time-consuming. Additionally, when your backend app grows beyond the free tier of infrastructure hosting, you may need to spend more money.
Ultimately, the decision to use server-side logic for in-app purchases depends on the specific needs of your app and your available resources. While it may be more complex and costly to implement, server-side logic provides more flexibility and control over managing in-app purchases, making it a worthwhile consideration for developers.
Improve Your In-App Purchases with Adapty
We at Adapty understand that not all developers can use the latest iOS 16+ APIs that are utilized in this tutorial, which is why we offer a solution that supports older iOS 9+ versions of iOS. Our aim is to simplify the integration of in-app purchases without the need for server coding. Our developer-friendly iOS SDK handles everything from free trials to refunds, so developers can focus on creating great apps while we take care of the backend.
In conclusion, we want to help developers succeed and provide them with the knowledge and tools to build their own solutions. Whether you choose to use StoreKit 2 or Adapty, our goal remains the same – to help you make more money through in-app purchases.
Maximize your app’s potential with Adapty! If you’re an app developer or product owner aiming to boost your conversion rates and in-app subscriptions, schedule a free demo call with us! We’ll walk you through our platform, detailing our myriad of benefits and features at absolutely no cost and with no obligation. Discover how Adapty’s solutions, compatible with iOS 9+ versions, can elevate your in-app purchase integration and backend management, allowing you to concentrate on crafting outstanding apps.