Validating In-App Purchases Through Your Own Server

As we know, Apple lets users purchase goods and services in apps with Apple IDs. And all in-app purchases need to be validated. There are two ways to verify these purchases: 1) via the App Store, through a secure connection between your app and your server, or 2) locally.

Local verification can be used for simple apps that don’t require a server. But here you run into security risks: local purchases can be forged on hacked iPhones. Therefore, using your own server to communicate with the App Store is generally the best option. In this case, your app will recognize and trust only your server letting you control all transactions between your server and user devices.

In this article, we’ll share our expertise verifying purchases through our server. We implemented this functionality in one of our projects, a dating app called Bro.

What’s Bro?

Bro is a dating app for men looking to meet other men. The Bro app is rich in features, and offers two types of in-app purchases:

  • One-month, six-month, and yearly subscriptions. Subscribers get an ad-free experience, can see more potential matches, and are shown more (up to 200) matches among local users.

  • A “Bromance” feature that acts like Tinder’s Super Like.

When we worked with in-app purchases in Bro, we had to deal with a few challenges that we’d like to share with you. Products the app offers include Premium Subscription (renewable), Premium Subscription (non-renewable), Consumable products (bromances).

Before we started developing our purchase system, we researched the benefits of using our own server for validation. Here’s what we came up with:

1. Server-side validation is more secure than local validation.

2. We already have our own server.

3. If users are administrators, they are able to access certain premium features without purchasing them.

4. As we have both Android and iOS apps, we need to track premium user status on both OS’s, which is most convenient to do on a single server.

Some special information about purchase receipts:

A receipt is a file that contains all information about purchases, including purchase date, expiration date, original purchase ID, product ID, etc.

Apple recently introduced a new receipt format – the universal receipt. Previously, you would receive a separate receipt for each transaction (i.e. one transaction equals one receipt). But now they send only one receipt that contains all information about all purchases for a given Apple ID, including both completed and pending purchases.

Another change with the universal receipt is that they’re downloaded from the mainBundle() using the appStoreReceiptURL variable, instead of coming from the transaction itself.

Flow:

The user purchases a subscription or consumable product. When the purchase is complete, Apple sends a receipt which we can access from mainBundle() When we receive the receipt, we then send it to our server as a line of code.

let mainBundle = NSBundle.mainBundle()
let receiptUrl = mainBundle.appStoreReceiptURL
let isPresent = receiptUrl?.checkResourceIsReachableAndReturnError(nil)
if isPresent == true, let receiptUrl = receiptUrl, receipt = NSData(contentsOfURL: receiptUrl) {
    let receiptData = receipt.base64EncodedStringWithOptions(NSDataBase64EncodingOptions(rawValue: 0))
}
 
//receiptData - encoded string for our server

When this receipt reaches our server, it’s verified by the App Store, and if valid, then information is sent to the client’s device, for example about a change to the client’s status on the server side, or about the number of purchases made by the client.

In the Bro app, when a client becomes a premium user or pays for additional services, these actions are run through the server. This means that all information about a user’s purchase status (whether or not they have a premium account, for example) is always up-to-date on the server.

But as an additional layer – the server – is introduced between the client and the purchase, we have to consider cases when a purchase has already been made but the server hasn’t received any information about it yet – and the user has already closed the app. This can happen if the internet connection breaks, for example, or if a device’s battery dies.

Apple clearly specifies in their technical documentation that after a purchase is made its status must be set to “Finished” before the process can be considered complete. In our case, we can only mark a purchase as “Finished” after the client’s device has received a response from the server indicating that all data were transferred and validated by the server successfully.

 private func validateReceipt(transaction: SKPaymentTransaction, isRestoring: Bool = false) {
        let mainBundle = NSBundle.mainBundle()
        let receiptUrl = mainBundle.appStoreReceiptURL
        let isPresent = receiptUrl?.checkResourceIsReachableAndReturnError(nil)
        if isPresent == true, let receiptUrl = receiptUrl, receipt = NSData(contentsOfURL: receiptUrl) {
            let receiptData = receipt.base64EncodedStringWithOptions(NSDataBase64EncodingOptions(rawValue: 0))
            configureRestoringRequest(
                parameters,
                completion: {
                    SKPaymentQueue.defaultQueue().finishTransaction(transaction) // finish transaction only when response from server is received
                }
            )
        } else {
            // handle case when there is no receipt data
        }
    }
   
    private func configureValidationRequest(receiptData: String, completion: () -> ()) {
        APIClient.defaultClient().validatePremium(
            receiptData,
            success: {[weak self] _, _ in
                completion()
            },
            failure: {[weak self] _, error in
                // handle errors here
            }
        )
    }

If the purchase has a status of Purchased or Restored, it will remain in the payment queue for processing until that status changes to Finished or Canceled.

func paymentQueue(queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        for transaction in transactions {
            switch transaction.transactionState {
            case .Purchased:
                // handle transaction is Purchased if needed
            case .Restored:
                // handle transaction is Restored if needed
            case .Purchasing:
                // handle transaction purchasing is in progress if needed
            case .Failed:
                // handle transaction failure if needed
            default:
                break
            }
        }
    }

This is why we activate our Purchase service every time the app is launched, so we can process any transactions that, for some reason or another, were not successfully sent to the server previously.

  // call this function when your session starts
    private func setupPurchaseService() {
        BroPurchaseService.sharedPurchasingService.setupPurchasingService()
    }
 
    private func checkStoreAvailability() {
        SKPaymentQueue.defaultQueue().addTransactionObserver(self)
        if SKPaymentQueue.canMakePayments() {
            let productID = NSSet(objects: productOneId, productTwoId, etc) // add all products ids  
            let request = SKProductsRequest(productIdentifiers: productID as! Set<String>)
            request.delegate = self
            request.start()
        } else {
            // handle cases when your app can't make payments
        }
    }

Also, all renewable subscriptions through the App Store request a shared secret key during the validation stage. This key has to be sent together with the receipt during validation, and this key is the same for all users of an app. In order to save time and avoid sending the key from the client to the server, we store it on the server and only send a receipt. This is much more secure than sending the secret key to server for each transaction. If the secret key has to be replaced (for security reasons, for example if data were leaked), then the administrator can replace the secret key by generating a new key and replacing the old key with the new key using their own profile.

When a user successfully purchases a product, the server will let us know about it no matter what device they log in with. Also, the server will automatically check their subscription status on App Store and will restrict premium features if it has expired.

Some hints about restoring subscriptions:

When you restore purchases, all completed transactions go to the payment queue with the status Restored. This means that we have to validate and try to restore each transaction; but if we restore them in the SandBox environment it can take 5 minutes to renew a 1-month subscription, and it can take 30 minutes to renew a 6-month subscription. It can take 30 minutes for 6 different transactions! 

We decided not to send a request for each transaction when we restore it, but to send just one receipt to the server that contains information about all purchases. Apple then validates that receipt on their server, checking all transactions and restoring those that need to be restored. Once the server sends a confirmation that the transactions were successful, all transactions that previously had the status Restored will receive the status Finished.

  func paymentQueue(queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        var restoreBegan = false
        for transaction in transactions {
            switch transaction.transactionState {
            case .Purchased:
                // handle transaction is Purchased if needed
            case .Restored:
                if !restoreBegan {
                    restoreBegan = true
                    validateReceipt(transaction) // validate receipt as it is purchased
                } else {
                    SKPaymentQueue.defaultQueue().finishTransaction(transaction)
                }
            case .Purchasing:
                // handle transaction purchasing is in progress if needed
            case .Failed:
                // handle transaction failure if needed
            default:
                break
            }
        }
    }

Additionally, if a user tries to buy a subscription again after an unsuccessful attempt, Apple will instead offer them to restore their existing subscription instead of completing a new purchase. There’s also another unusual case to consider: If a user tries to buy a product twice, it will appear in the payment queue once again, but will be restored as well, meaning that user won’t be charged for a new purchase.

Apple’s new universal receipt allows us to perform all operations by sending just one request to the server, as it contains all information about all previous transactions. 

We would also like to mention some common Sandbox Pitfalls we’ve noticed:

  • On one of our test devices, we would sometimes receive transactions from multiple test accounts, including accounts from deleted Apple IDs.

  • Receipt could be as large as 100 kb (causing troubles when interacting with some types of servers).

  • Some purchases ended up with the status Restored, though this was not supposed to happen according to the technical specifications of the project.

  • The response times from Apple’s servers were slow.

  • Sometimes, purchases did not appear in the queue at all – and we didn’t get any callbacks from Apple.

Also, based on our experience, we recommend testing one user with one Apple ID on one device.

Let’s sum up our experience with in-app purchases.

Validating receipts with the App Store has the following advantages over local verification:

  • All information is validated on the server side, which means no matter what device the app is running on, users will get up-to-date data about purchases.

  • The server also traces all purchases to make sure that one purchase (in our case, a subscription for an app) is only used once. In other words, one purchase can’t end up being credited to more than one account.

3.9/ 5.0
Article rating
28
Reviews
Remember those Facebook reactions? Well, we aren't Facebook but we love reactions too. They can give us valuable insights on how to improve what we're doing. Would you tell us how you feel about this article?

We use cookies to personalize our service and to improve your experience on the website and its subdomains. We also use this information for analytics.

More info