An auto-renewable app subscription lets you monetize your apps by charging users for features, services, or content on a recurring basis. These subscriptions will automatically renew at the end of their duration, and this will end once the user decides to cancel.
There are a huge number of subscription-based apps out there, and they are available on iOS, macOS, iPadOS, tvOS, and watchOS. Auto-renewing subscriptions have become a prevalent business model on the App store. From 2019 to 2020, global consumer spending in the top 100 App Store subscription apps experienced a 32% increase — for a total expenditure of $10.3 billion.
And according to Adjust, 49% of the top 225 App store apps are subscription-based. It's obvious that developers are moving towards this model to increase their revenue.
With so many auto-renewing subscriptions on the market, you might assume that setting up iOS auto-renewable subscriptions is an easy process.
While it can certainly feel simpler as you gain experience, creating Swift monthly subscriptions that auto-renew requires quite a bit of coding. We'll walk you through the process, so you can start monetizing your app ASAP.
What you'll learn: In this Swift subscription tutorial, I will talk about auto-renewable subscriptions on iOS 12 & iOS 13. I will show how to create, manage, purchase, and validate auto-renewable subscriptions. My Swift Apple tutorial will also talk about hidden challenges and some cases that many developers miss.
Even if an app can be downloaded for free, the developer can still make revenue by implementing in-app purchases.
In-app purchases prompt users to purchase add-ons, premium features, subscriptions, and more. In essence, an in-app purchase is any fee that an app can ask for, beyond the initial download cost (if any). The option to make an in-app purchase can be introduced at any point in the app. Mobile game apps, in particular, use strategic timing to offer in-app purchases when the player seems to need help.
Spending money on in-app purchases gives users a premium experience while also giving extra revenue to the developer and to the App store.
There are four kinds of in-app purchases that customers can make.
Some popular in-app purchase mechanics include the following approaches.
If you already have an app in ASC (abbreviated from App Store Connect) you may skip this tutorial and go to coding part of this Swift subscription tutorial. But if you don’t have an app yet here is what you need to do.
Before going to ASC you should first login to Apple Developer Portal and open Certificates, Identifiers & Profile
page. Then click on Identifiers
tab. Since June 2019, Apple has finally updated this page design to match ASC.
You should create a new explicit Bundle ID
. It’s a reverser-domain name style string (i.e. com.apphud.subscriptionstest
). If you scroll down, you can see the capabilities list where In-App Purchases have already been checked. After you create an App ID, go to App Store Connect.
Another main step is filling up all information in Agreements, Tax and Banking
section. You won’t have an active Paid apps agreement you won’t be able to test in-app purchases.
After that, you create a new App Store app. It’s really simple. Choose your recently created Bundle ID and enter any unique name — you can change it later.
On the app page, go to the Features
section.
Adding iOS auto-renewable subscriptions consists of several steps.
Creating a subscription identifier and a subscription group. Subscription groups are like a collection of subscriptions, where just one can be active at one moment. The free trial period can be activated only once within a group. If your app needs two subscriptions at a time, then you will need to create two subscription groups.
You can read more about iOS auto-renewable subscriptions in this article.
Next, you will need to fill subscription page: duration, display name, and description. If you add your first subscription to a group you would need to specify a group display name as well. Save changes from time to time: ASC often freezes.
Finally, add the subscription price. This includes the regular price, introductory offers, and promotional offers
. Add the price in your currency — it will be automatically recalculated for other currencies. Introductory offers let you create a free trial, “pay as you go” or “pay upfront” offers. Promotional offers
is a new thing that was added recently: you can set up personal offers for users who canceled their subscription to bring them back.
On the in-app purchases page you may notice “App-Specific Shared Secret” button. It’s a special key that is used for validating receipts. In our case, we use it to get subscription status.
The shared secret key can be app-specific or account-specific (Master shared secret key). Don’t ever touch shared secrets if you have live apps: you won’t be able to validate purchases with an invalid key.
Copy all your subscriptions IDs and shared keys. Now is the programming part.
How to make a good subscriptions manager class? This class at least should be capable of doing these:
When you implement an in-app purchase in iOS, the process of purchasing a subscription can be divided into two steps: loading products information and implementing payment. But in advance we should set an observer for payment queue SKPaymentTransactionObserver
:
// Starts products loading and sets transaction observer delegate @objc func startWith(arrayOfIds : Set<String>!, sharedSecret : String){ SKPaymentQueue.default().add(self) self.sharedSecret = sharedSecret self.productIds = arrayOfIds loadProducts() } private func loadProducts(){ let request = SKProductsRequest.init(productIdentifiers: productIds) request.delegate = self request.start() } public func productsRequest(\_ request: SKProductsRequest, didReceive response: SKProductsResponse) { products = response.products DispatchQueue.main.async { NotificationCenter.default.post(name: IAP\_PRODUCTS\_DID\_LOAD\_NOTIFICATION, object: nil) } } func request(\_ request: SKRequest, didFailWithError error: Error){ print("error: \\(error.localizedDescription)") }
Full code is available at the end of this article.
When we request products information a delegate method will be called back. IAP_PRODUCTS_DID_LOAD_NOTIFICATION
can be used to update UI in the app.
Later let's write payment initializing:
func purchaseProduct(product : SKProduct, success: @escaping SuccessBlock, failure: @escaping FailureBlock){ guard SKPaymentQueue.canMakePayments() else { return } guard SKPaymentQueue.default().transactions.last?.transactionState != .purchasing else { return } self.successBlock = success self.failureBlock = failure let payment = SKPayment(product: product) SKPaymentQueue.default().add(payment) }
And here’s our SKPaymentTransactionObserver
delegate method:
extension IAPManager: SKPaymentTransactionObserver { public func paymentQueue(\_ queue: SKPaymentQueue, updatedTransactions transactions: \[SKPaymentTransaction\]) { for transaction in transactions { switch (transaction.transactionState) { case .purchased: SKPaymentQueue.default().finishTransaction(transaction) notifyIsPurchased(transaction: transaction) break case .failed: SKPaymentQueue.default().finishTransaction(transaction) print("purchase error : \\(transaction.error?.localizedDescription ?? "")") self.failureBlock?(transaction.error) cleanUp() break case .restored: SKPaymentQueue.default().finishTransaction(transaction) notifyIsPurchased(transaction: transaction) break case .deferred, .purchasing: break default: break } } } private func notifyIsPurchased(transaction: SKPaymentTransaction) { refreshSubscriptionsStatus(callback: { self.successBlock?() self.cleanUp() }) { (error) in // couldn't verify receipt self.failureBlock?(error) self.cleanUp() } } func cleanUp(){ self.successBlock = nil self.failureBlock = nil } }
After the in-app purchase Swift operation finishes, it calls the func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction])
delegate method. We check the transaction’s state to check whether the purchase succeeded or not.
Okay, a subscription has been purchased. But how to get its expiration date?
It is actually the same as validating receipt with the App Store. We send a POST request, called verifyReceipt
to Apple, in this request we include the local receipt as a base64encoded
string parameter. We get JSON in response, containing all our transactions. There’s an array of transactions with all purchases including renewals (by latest_receipt_info
key). All we need to do is loop through an array and check the expiration date for all of those transactions and get active ones.
At WWDC 2017 a new key has been added as a request parameter: exclude-old-transactions
. If we set it to true, then Apple will return only active transactions in a verifyReceipt
request.
func refreshSubscriptionsStatus(callback : @escaping SuccessBlock, failure : @escaping FailureBlock){ // save blocks for further use self.refreshSubscriptionSuccessBlock = callback self.refreshSubscriptionFailureBlock = failure guard let receiptUrl = Bundle.main.appStoreReceiptURL else { refreshReceipt() // do not call block yet return } #if DEBUG let urlString = "https://sandbox.itunes.apple.com/verifyReceipt" #else let urlString = "https://buy.itunes.apple.com/verifyReceipt" #endif let receiptData = try? Data(contentsOf: receiptUrl).base64EncodedString() let requestData = \["receipt-data" : receiptData ?? "", "password" : self.sharedSecret, "exclude-old-transactions" : true\] as \[String : Any\] var request = URLRequest(url: URL(string: urlString)!) request.httpMethod = "POST" request.setValue("Application/json", forHTTPHeaderField: "Content-Type") let httpBody = try? JSONSerialization.data(withJSONObject: requestData, options: \[\]) request.httpBody = httpBody URLSession.shared.dataTask(with: request) { (data, response, error) in DispatchQueue.main.async { if data != nil { if let json = try? JSONSerialization.jsonObject(with: data!, options: .allowFragments){ self.parseReceipt(json as! Dictionary<String, Any>) return } } else { print("error validating receipt: \\(error?.localizedDescription ?? "")") } self.refreshSubscriptionFailureBlock?(error) self.cleanUpRefeshReceiptBlocks() } }.resume() }
At the beginning of this method, you will find that if a local receipt is not found then a request is not being sent. That’s correct: local receipt may not exist in some cases: for example, when you install an app from iTunes. In such cases we need to refresh a receipt first, then call this function again.
There is a special SKReceiptRefreshRequest
class for it:
private func refreshReceipt(){ let request = SKReceiptRefreshRequest(receiptProperties: nil) request.delegate = self request.start() } func requestDidFinish(\_ request: SKRequest) { // call refresh subscriptions method again with same blocks if request is SKReceiptRefreshRequest { refreshSubscriptionsStatus(callback: self.successBlock ?? {}, failure: self.failureBlock ?? {\_ in}) } } func request(\_ request: SKRequest, didFailWithError error: Error){ if request is SKReceiptRefreshRequest { self.refreshSubscriptionFailureBlock?(error) self.cleanUpRefeshReceiptBlocks() } print("error: \\(error.localizedDescription)") }
After we started this request we will get a delegate method requestDidFinish(_ request : SKRequest)
which calls refreshSubscriptionsStatus
again.
How do we parse JSON responses from Apple? Here’s an example of how you can do that:
private func parseReceipt(\_ json : Dictionary<String, Any>) { // It's the most simple way to get latest expiration date. Consider this code as for learning purposes. Do not use current code in production apps. guard let receipts\_array = json\["latest\_receipt\_info"\] as? \[Dictionary<String, Any>\] else { self.refreshSubscriptionFailureBlock?(nil) self.cleanUpRefeshReceiptBlocks() return } for receipt in receipts\_array { let productID = receipt\["product\_id"\] as! String let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss VV" if let date = formatter.date(from: receipt\["expires\_date"\] as! String) { if date > Date() { // do not save expired date to user defaults to avoid overwriting with expired date UserDefaults.standard.set(date, forKey: productID) } } } self.refreshSubscriptionSuccessBlock?() self.cleanUpRefeshReceiptBlocks() }
We loop through the array looking for expires_date
key and save it if is not expired yet. It’s a simple example which considered to be for learning purposes. It doesn’t handle errors or several cases, for example, canceling (refunding) a subscription.
To check if the subscription is active or not you should compare the current date with the subscription’s expiration date:
func expirationDateFor(\_ identifier : String) -> Date?{ return UserDefaults.standard.object(forKey: identifier) as? Date } let subscriptionDate = IAPManager.shared.expirationDateFor("YOUR\_PRODUCT\_ID") ?? Date() let isActive = subscriptionDate > Date()
It’s just one method: SKPaymentQueue.default().restoreCompletedTransactions()
. These functions restore transactions so that observer calls `updatedTransactions` delegate method as many times as many transactions you have.
A popular question is being asked:
Well, both methods let you restore purchases information. Here’s a good screenshot from this WWDC video:
In most cases, refreshing the receipt is enough. And you don't really need to restore transactions. In our auto-renewable subscription iOS example, we send a local receipt to Apple for validation in order to get the subscription's expiration date. And we don't actually care about transactions. However, if you use Apple-hosted content in your purchases — or your target device below iOS 7 — you will have to use the first method.
What about iOS testing for the in-app purchase? To test auto-renewable subscriptions you will need to add sandbox testers first. Subscriptions can be tested only using a device. So in ASC go to Users & Access then go to Sandbox Testers where you will create your first sandbox tester.C
While creating a new tester you can enter any text. However, don’t forget to enter an email and a password!
Previously when testing in-app purchases you used to log out from the App Store. This was very annoying: your iTunes music was being removed from the device. Now this problem has gone, the sandbox test account is separated from the real App Store account.
The process of making an in-app purchase is similar to a real purchase. But it has some nuances:
- You will also have to enter sandbox credentials: Apple ID & password. Using the device’s Touch ID / Face ID is still not supported.
Well, the only new thing is SKStorefront
, which gives you a country code of the user’s App Store account. It may be useful if you need to show different in-app purchases for different countries. Previously, developers had to check iPhone region settings or location to get the country. But now it is made within one line of code: SKPaymentQueue.default().storefront?.countryCode
. There’s also a delegate method that tells you if the user’s App Store country has been changed during the purchase process. In this case, you may continue or cancel the payment process.
cancellation_date
key. But an expires_date
key will still be there. That means that you should always check for the cancellation date first, as it’s primary.I hope this article was useful. I tried not only include the code but explain some important caveats. The full class code can be downloaded here.
This class will be useful for learning purposes. However, for live apps you should use more complex solutions, such as Apphud — an in-app purchase management platform with real-time analytics and subscriber engagement tools.