In-app purchases initialization with Swift

Updated: May 13, 2025
12 min read

In our second installment, we continue our series dedicated to iOS in-app purchases. In the previous part, we discussed the process of creating in-app purchases and their configuration. You can read all the tutorials following the links below:
- iOS in-app purchases, part 1: App Store Connect and project configuration
- iOS in-app purchases, part 2: initialization and purchases processing
- iOS in-app purchases, part 3: testing purchases in Xcode
- iOS in-app purchases, part 4: server-side receipt validation
- iOS in-app purchases, part 5: the list of SKError codes and how to handle them
In this part, we’ll show you how to create a simple paywall as well as how to initialize and process the products we’ve previously configured.
Creating an in-app subscription screen
Any app that uses in-app purchases has a paywall. Apple provides some guidelines that determine must-have elements. At this step, we’ll implement just some of them to give you a sense of that to do next.

Our screen will have the following:
- Header with premium description
- Subscription activation buttons with local currency and title
- Restore purchases button. This element is necessary for all the applications that use subscriptions or non-consumable purchases.
I’ve used SwiftUI to build a simple Paywall view. Your paywall view will likely be more complex, but for purposes of this article, we’re keeping it simple.
Show purchase information using Product
After quickly familiarizing yourself with Apple’s documentation on the Product type, let’s build our PaywallView. It receives basic product info from the IAPSubscriptionManager
, but there isn’t any purchasing logic yet. We’ll add that soon.
import SwiftUI
import StoreKit
struct PaywallView: View {
@Environment(IAPSubscriptionManager.self) var subMgr
@Environment(\.dismiss) var dismiss
var body: some View {
VStack {
Text("Upgrade to")
.foregroundStyle(.secondary)
.font(.title2.weight(.semibold))
.padding(.top)
Text("Movie Mania Pro!")
.foregroundStyle(.purple)
.font(.largeTitle.bold())
Spacer()
ForEach(subMgr.subscriptions) { product in
Button {
} label: {
VStack {
Text(product.displayName)
Text(product.displayPrice)
.font(.subheadline.bold())
}
}
.padding(.horizontal)
.buttonStyle(
BlockButtonStyle(
textColor: .white,
bgColor: .purple
)
)
}
Button("Restore Purchases") {
}
.padding(.top)
}
}
}
Let’s see what’s inside:
- An environment variable that connects to our injected subscription manager.
- Some text views to display the copy at the top.
- A
ForEach
that displays the buttons. - A restore button which is a requirement when presenting non consumables or subscriptions.
Note that the subMgr is responsible to fetch products from Apple, and that is done in its init function, which is called at the app’s main entry point. It’s a best practice to fetch products before you need to display them so the paywall view will load quickly.
This button style will let you easily create the purchase buttons with standard formatting and styling.
struct BlockButtonStyle: ButtonStyle {
let textColor: Color
let bgColor: Color
func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.label
.foregroundColor(textColor)
.font(.headline)
}
.padding()
.frame(maxWidth: .infinity)
.background(bgColor.cornerRadius(12))
.scaleEffect(configuration.isPressed ? 0.95 : 1)
}
}
Subscription Manager
The IAPSubscriptionManager
provides a nice interface to all things related to the products and managing their purchase by a user. Right now it only provides the products for display, but we’ll add to this as we go. The type aliases are simply to make it easier to reference the related APIs.
import Foundation
import OSLog
import StoreKit
typealias Transaction = StoreKit.Transaction
typealias SubStatus = StoreKit.Product.SubscriptionInfo.Status
typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo
typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState
@Observable
final class IAPSubscriptionManager {
private(set) var subscriptions: [Product] = []
private(set) var currentSubscription: Product?
private(set) var status: SubStatus?
private let logger = Logger(subsystem: "io.adapty.MovieMania", category: "IAP Helper Service")
private let identifiers: [String] = ["movie.mania.pro.1y", "movie.mania.pro.1m"]
@MainActor
func getProducts() async {
do {
subscriptions = try await Product.products(for: identifiers)
} catch {
logger.error("Products could not be fetched: \(error.localizedDescription)")
}
}
}
Making a Purchase
Let’s add some error handling to our code. I’ve created a StoreError
enum to handle error cases. It implements the Error
protocol to interoperate with error handling code.
enum StoreError: Error {
case failedVerification
case purchaseInProgress
case productNotFound
case unknown
}
All errors on the StoreKit level will be caught as a single error. To get more information about the error types, check the documentation.
The purchase(_:)
function allows you to process a purchase request for a particular subscription product. It attempts to process the purchase, and handles the result returned.
Add this function to your IAPSubscriptionManager
:
@MainActor
func purchase(_ product: Product) async throws -> Bool {
let result = try await product.purchase()
switch result {
case .success(let verificationResult): // 1
switch verificationResult {
case .verified(let transaction): // 2
//Deliver content to the user.
await updateSubscriptionStatus(with: transaction)
//Always finish a transaction.
await transaction.finish()
return true
case .unverified: // 3
throw StoreError.failedVerification
}
case .userCancelled, .pending: // 4
// account verification needed, or parental approval needed
return false
@unknown default:
return false
}
}
Let’s dig into the above to understand what’s going on:
- Most times, you get a successful purchase, which will provide an associated verification result.
- If the purchase comes back verified, it means the StoreKit automatic verification checks passed. You should unlock premium functionality at this point.
- If the purchase is unverified, we throw the
failedVerification
error message. - If the user canceled the request, or a child device is pending verification of the purchase from a parent’s device, you might get this result.
To process the result of the purchase, we need to implement the updateSubscriptionStatus
method:
@MainActor
func updateSubscriptionStatus(with transaction: Transaction?) async {
do { // 1
guard let product = subscriptions.first,
let statuses = try await product.subscription?.status else {
return
}
// 2
for status in statuses {
switch status.state {
case .expired, .revoked:
continue
default: // 3
let renewalInfo = try checkVerified(status.renewalInfo)
// 4
guard let foundSubscription = subscriptions.first(where: { $0.id == renewalInfo.currentProductID }) else {
continue
}
// 5
self.status = status
currentSubscription = foundSubscription
break
}
}
} catch {
logger.error("Could not update subscription status: \(error.localizedDescription)")
}
}
// 6
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
}
}
- Since we only have one subscription group, the status returned applies to every subscription in the group.
- You may receive multiple statuses if the subscription is part of family sharing.
- Once you find a status that isn’t revoked or expired, extract the
renewalInfo
from theverificationResult
. - Match the
currentProductID
in therenewalInfo
with a product ID from your list of subscriptions. - Finally, update your properties to store the status, and the active subscription.
- The
checkVerified(_:)
function allows you to verify therenewalInfo
and unwrap its contents.
Listening for Purchases Made
The last step inside the subscription manager is to set up a listener for transactions that happen outside the context of the purchase function. This step is heavily recommended by Apple and you’ll actually get a runtime warning if you don’t implement it.
I’ve posted the complete IAPSubscriptionManager
class below to allow you to see everything we’ve built so far. I’ll call out the changes to implement the listener underneath the code block.
import Foundation
import OSLog
import StoreKit
typealias Transaction = StoreKit.Transaction
typealias SubStatus = StoreKit.Product.SubscriptionInfo.Status
typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo
typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState
enum StoreError: Error {
case failedVerification
case purchaseInProgress
case productNotFound
case unknown
}
@Observable
final class IAPSubscriptionManager {
private(set) var subscriptions: [Product] = []
private(set) var currentSubscription: Product?
private(set) var status: SubStatus?
private let logger = Logger(subsystem: "io.adapty.MovieMania", category: "IAP Helper Service")
private let identifiers: [String] = ["movie.mania.pro.1y", "movie.mania.pro.1m"]
// 1
var updates: Task<Void, Error>? = nil
// 2
init() {
updates = newTransactionsListenerTask()
Task {
await getProducts()
await updateSubscriptionStatus()
}
}
// 3
deinit {
updates?.cancel()
}
// 4
private func newTransactionsListenerTask() -> Task<Void, Error> {
Task(priority: .background) {
for await verificationResult in Transaction.updates {
self.handle(updatedTransaction: verificationResult)
}
}
}
// 5
private func handle(updatedTransaction verificationResult: VerificationResult<Transaction>) {
guard case .verified(let transaction) = verificationResult else {
return
}
Task {
await updateSubscriptionStatus()
await transaction.finish()
}
}
@MainActor
func getProducts() async {
do {
subscriptions = try await Product.products(for: identifiers)
} catch {
logger.error("Products could not be fetched: \(error.localizedDescription)")
}
}
@MainActor
func purchase(_ product: Product) async throws -> Bool {
let result = try await product.purchase()
switch result {
case .success(let verificationResult):
switch verificationResult {
case .verified(let transaction):
// Deliver content to the user.
await updateSubscriptionStatus()
// Always finish a transaction.
await transaction.finish()
return true
case .unverified:
throw StoreError.failedVerification
}
case .userCancelled, .pending:
// account verification needed, or parental approval needed
return false
@unknown default:
return false
}
}
@MainActor
func updateSubscriptionStatus() async {
do {
/// Since we only have 1 subscription group, the status returned applies to every sub in the group.
guard let product = subscriptions.first,
let statuses = try await product.subscription?.status else {
return
}
for status in statuses {
switch status.state {
case .expired, .revoked:
continue
default:
let renewalInfo = try checkVerified(status.renewalInfo)
guard let foundSubscription = subscriptions.first(where: { $0.id == renewalInfo.currentProductID }) else {
continue
}
self.status = status
currentSubscription = foundSubscription
break
}
}
} catch {
logger.error("Could not update subscription status: \(error.localizedDescription)")
}
}
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw StoreError.failedVerification
case .verified(let safe):
return safe
}
}
}
- This is the task that will run and listen for updates to subscriptions.
- The
init()
function accomplishes three things: start up the listener task, fetch the products, and update the subscription status. The last step helps to ensure functionality is unlocked due to a previous subscription purchase. deinit()
ensures the task is killed when the app closes.newTransactionsListenerTask()
sets up a listener task to listen for transactions from StoreKit that happen outside the context of the purchase method.handle(updatedTransaction:)
processes any received transactions fro the listener in much the same way that the purchase method works.
Wire the Button to the Purchase Process
All that’s left is to wire up the button’s action closure to call the purchase method from our subscription manager and handle the result. Edit your button in the Paywall.swift file to look like the following:
Button {
Task {
do {
let success = try await subMgr.purchase(product)
if success {
dismiss()
}
} catch {
print("Error purchasing product: \(error.localizedDescription)")
}
}
} label: {
VStack {
Text(product.displayName)
Text(product.displayPrice)
.font(.subheadline.bold())
}
}
We now have a functioning basic paywall. In the next part, we will discuss the ways to test the purchase mechanism, and how to design a paywall to pass the App Store review.
Recommended posts