![Paywall Newsletter: Special Issue with Thomas Petit 8 opengraph Thomas](https://adapty.io/wp-content/uploads/2023/04/opengraph_Thomas.png)
Paywall Newsletter
12 min read
December 21, 2022
Updated: September 9, 2024
28 min read
This is the fourth tutorial of our series on implementing in-app purchases into an app. Today I’m going to discuss server-side purchase validation for iOS. I recommend you read the other articles as well:
Server validation (server-side receipt validation) is a way to verify the purchase’s authenticity. Unlike device-based validation, server validation occurs – wait for it – on the server side. Validation signifies that the device or the server makes a request to Apple’s servers to find out whether the purchase actually occurred and whether it was valid.
A receipt is a PKCS#7 encrypted file that contains information about all in-app purchases made by the user. It basically serves as an ordinary paper receipt that enables the developer to check if the transaction was valid or what in-apps were ever purchased (in case the user decides to restore them in the future). A receipt is issued after each transaction, it’s made and signed by Apple and is stored locally on the device, but there are three ways to validate it:
The first variant is rather complicated and requires integrating OpenSSL library into your application. Validating receipts on the device may be unsafe, as there’s a risk of having your requests intercepted. Server-side receipt validation is a safer and more reliable method among them all.
It should be noted that server validation isn’t mandatory – in-app purchases will still work without it. It grants some advantages, though:
Speaking from our experience, the first advantage alone is enough to set up server purchase processing.
Looking to fortify your iOS in-app purchases and enhance subscription transparency? Discover the difference with Adapty! Schedule a free, no-obligation demo call to explore how our advanced features can help you gain precise control over your content and elevate your subscription revenue, while you focus on delivering exceptional value to your users.
In general, the process of receipt validation on iOS looks like this:
To send a request of payment validation, you have to include the shared secret to authorize the request. You can generate one in App Store Connect.
Shared secret can be created for a specific app (app-specific secret) or for all apps in the account (primary secret).
To generate an app-specific secret, open the app’s page in App Store Connect, go to General → App information → App-Specific Shared Secret and click Manage. In the window that opens, you’ll be able to generate a new token or copy the existing one.
To receive the secret for all apps in your account, open the Users and Access page and select the Shared Secret tab.
Once you receive the shared secret, you can send receipts to get validated on Apple servers. This is done via the verifyReceipt request. You have to send a POST request to https://buy.itunes.apple.com/verifyReceipt. In the JSON body of the request, pass the shared secret in the password field and the receipt in the receipt-data field. There’s also the optional exclude-old-transactions parameter. If it has the true value, then for each auto-renewable subscription, you’ll receive the last transaction only instead of the full renewal history.
Here’s the payload of the purchase validation request:
{
"password": "f4d35830e3...52aae",
"receipt-data": "MIIUVQY...4rVpL8NlYh2/8l7rk0BcStXjQ==",
"exclude-old-transactions": false
}
If you’re working in a Sandbox environment – that is, you’re testing purchases – then send validation requests to https://sandbox.itunes.apple.com/verifyReceipt. The shared secret, as well as the payload and the response formats stay the same.
It’s important to note that you won’t be able to validate a receipt created in the Sandbox environment on a Production server, and vice versa. That’s why in real-world systems, the best practice is to direct the first request to the Production server and redirect it to the Sandbox server in case the status key returns the 21007 error code. This behavior is a must during app review, since it allows Apple employees to test purchases while also allowing your app’s real users to make them.
Among other errors to note, there’s the 21004 error code that means we’re using the wrong secret. It’s important to keep track of, since it has an impact on both user experience and analytics accuracy. In the worst-case scenario, the app can be removed from the App Store if the user is never granted access to the premium features after they pay for these.
If the validation was successful (status=0), the response will contain the details of the user’s transactions.
Here’s the payment validation request response:
{
"environment": "Production",
"receipt": {
"receipt_type": "Production",
"adam_id": 123,
"app_item_id": 123,
"bundle_id": "com.adapty.sample_app",
"application_version": "1",
"download_id": 123,
"version_external_identifier": 123,
"receipt_creation_date": "2021-04-28 19:42:01 Etc/GMT",
"receipt_creation_date_ms": "1619638921000",
"receipt_creation_date_pst": "2021-04-28 12:42:01 America/Los_Angeles",
"request_date": "2021-08-09 18:26:02 Etc/GMT",
"request_date_ms": "1628533562696",
"request_date_pst": "2021-08-09 11:26:02 America/Los_Angeles",
"original_purchase_date": "2017-04-09 21:18:41 Etc/GMT",
"original_purchase_date_ms": "1491772721000",
"original_purchase_date_pst": "2017-04-09 14:18:41 America/Los_Angeles",
"original_application_version": "1",
"in_app": [
{
"quantity": "1",
"product_id": "basic_subscription_1_month",
"transaction_id": "1000000831360853",
"original_transaction_id": "1000000831360853",
"purchase_date": "2021-04-28 19:41:58 Etc/GMT",
"purchase_date_ms": "1619638918000",
"purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles",
"original_purchase_date": "2021-04-28 19:41:58 Etc/GMT",
"original_purchase_date_ms": "1619638918000",
"original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles",
"expires_date": "2021-05-05 19:41:58 Etc/GMT",
"expires_date_ms": "1620243718000",
"expires_date_pst": "2021-05-05 12:41:58 America/Los_Angeles",
"web_order_line_item_id": "230000397200750",
"is_trial_period": "true",
"is_in_intro_offer_period": "false",
"in_app_ownership_type": "PURCHASED"
}
]
},
"latest_receipt_info": [
{
"quantity": "1",
"product_id": "basic_subscription_1_month",
"transaction_id": "230001020690335",
"original_transaction_id": "1000000831360853",
"purchase_date": "2021-08-04 19:41:58 Etc/GMT",
"purchase_date_ms": "1628106118000",
"purchase_date_pst": "2021-08-04 12:41:58 America/Los_Angeles",
"original_purchase_date": "2021-04-28 19:41:58 Etc/GMT",
"original_purchase_date_ms": "1619638918000",
"original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles",
"expires_date": "2021-08-11 19:41:58 Etc/GMT",
"expires_date_ms": "1628710918000",
"expires_date_pst": "2021-08-11 12:41:58 America/Los_Angeles",
"web_order_line_item_id": "230000438372383",
"is_trial_period": "false",
"is_in_intro_offer_period": "false",
"in_app_ownership_type": "PURCHASED",
"subscription_group_identifier": "272394410"
},
{
"quantity": "1",
"product_id": "basic_subscription_1_month",
"transaction_id": "230001017218955",
"original_transaction_id": "1000000831360853",
"purchase_date": "2021-07-28 19:41:58 Etc/GMT",
"purchase_date_ms": "1627501318000",
"purchase_date_pst": "2021-07-28 12:41:58 America/Los_Angeles",
"original_purchase_date": "2021-04-28 19:41:58 Etc/GMT",
"original_purchase_date_ms": "1619638918000",
"original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles",
"expires_date": "2021-08-04 19:41:58 Etc/GMT",
"expires_date_ms": "1628106118000",
"expires_date_pst": "2021-08-04 12:41:58 America/Los_Angeles",
"web_order_line_item_id": "230000849023623",
"is_trial_period": "false",
"is_in_intro_offer_period": "false",
"in_app_ownership_type": "PURCHASED",
"subscription_group_identifier": "272394410"
}
],
"latest_receipt": "MIIUVQY...4rVpL8NlYh2/8l7rk0BcStXjQ==",
"pending_renewal_info": [
{
"auto_renew_product_id": "basic_subscription_1_month",
"product_id": "basic_subscription_1_month",
"original_transaction_id": "1000000831360853",
"auto_renew_status": "1"
}
],
"status": 0
}
The response is quite cumbersome and was simplified in the App Store Server API new version, but the current implementation isn’t that hard to get hold of.
To find out whether the user has access to the app’s premium features, you need a way to determine their subscription status. There’s no dedicated request for retrieving subscription status in the current version of the API, so you’ll need to work with the transaction history in any case.
The latest_receipt_info array, by default, contains all in-app purchase transactions of a particular user, except for consumable products that are completed on the app side. This way, you can retrieve the user’s entire purchase history. That’s quite useful for both analytics and determining the current subscription status.
It seems that transactions always come already sorted newest first. To be sure, though, I still recommend implementing your own by-date sorting for transactions.
The transaction payload:
{
"quantity": "1",
"product_id": "basic_subscription_1_month",
"transaction_id": "1000000831360853",
"original_transaction_id": "1000000831360853",
"purchase_date": "2021-04-28 19:41:58 Etc/GMT",
"purchase_date_ms": "1619638918000",
"purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles",
"original_purchase_date": "2021-04-28 19:41:58 Etc/GMT",
"original_purchase_date_ms": "1619638918000",
"original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles",
"expires_date": "2021-05-05 19:41:58 Etc/GMT",
"expires_date_ms": "1620243718000",
"expires_date_pst": "2021-05-05 12:41:58 America/Los_Angeles",
"web_order_line_item_id": "230000397200750",
"is_trial_period": "true",
"is_in_intro_offer_period": "false",
"in_app_ownership_type": "PURCHASED",
"subscription_group_identifier": "272394410"
}
To check the current subscription status, it’s enough to retrieve the latest transaction of the chain and look at the expires_date property. The exception to that would be the grace period, which we’ll discuss a bit later.
For analytics purposes, I recommend saving the following properties:
Apple has announced on WWDC 2021 that they’re planning to add an appAccountToken field to transaction info. It will contain the user’s identifier in your system. This identifier must be in the UUID format and is defined on the app side when the purchase gets initialized. If defined, it will be returned in all transactions of this chain (subscription renewals, billing issues, etc.), which means it’ll be easy for you to understand which user made the purchase.
You should also keep track of the subscription_group_identifier parameter. If the user previously had any transactions with active trials or intro offers, then they shouldn’t have access to these for the same group of subscriptions. This should be tracked on the server side.
The pending_renewal_info array stores the subscription renewal data. It allows us to understand what is to happen to the subscription in the next billing period. For example, if you discovered the user opted out of auto renewal, you can suggest they switch to a different plan or present them with a promo offer. These events can be handily tracked with server notifications, which I’ll cover soon.
Subscription renewal data:
{
"auto_renew_product_id": "basic_subscription_1_month",
"product_id": "basic_subscription_1_month",
"original_transaction_id": "1000000831360853",
"auto_renew_status": "1"
}
If the user has no auto-renewable products, the latest_receipt_info and the pending_renewal_info keys won’t be returned. In this case, transactions can be found in receipt → in_app. The transaction format is similar to auto-renewable transactions but has no fields for expiry, renewals, offers, and other properties exclusive to auto-renewable transactions only.
It should be noted that receipt → in_app will arrive for auto-renewable transactions as well, but the better practice is to use latest_receipt_info, as the subscription data it contains will be the most up to date.
Some time ago you’d have to devise a complex system to track subscription status changes. For example, to understand whether or not the subscription was renewed, you’d have to send a subscription status request to Apple servers every hour starting 24 hours before the subscription expiry. Apple was adding more and more server notifications over time, and these now cover virtually all important events that have to do with subscriptions. That’s highly useful: once there are any changes on Apple’s side, you’ll get notified about it on your own server. That is, you’ll be notified about new purchases, subscription renewals, billing issues, etc. This allows you to collect much more accurate analytics, as well as makes managing the subscription status much easier.
You can enable server notifications in App Store Connect. Open the app’s page and go to General → App information → App Store Server Notifications → Production Server URL and click Edit.
Insert your URL into the Production Server URL field and select the desired notification version.
Server notification:
{
"notification_type": "DID_RENEW",
"password": "f4d35830e3...52aae",
"environment": "PROD",
"auto_renew_product_id": "basic_subscription_1_month",
"auto_renew_status": "true",
"unified_receipt": {
"status": 0,
"environment": "Production",
"latest_receipt_info": [
{
"quantity": "1",
"product_id": "basic_subscription_1_month",
"transaction_id": "230001020690335",
"original_transaction_id": "1000000831360853",
"purchase_date": "2021-08-04 19:41:58 Etc/GMT",
"purchase_date_ms": "1628106118000",
"purchase_date_pst": "2021-08-04 12:41:58 America/Los_Angeles",
"original_purchase_date": "2021-04-28 19:41:58 Etc/GMT",
"original_purchase_date_ms": "1619638918000",
"original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles",
"expires_date": "2021-08-11 19:41:58 Etc/GMT",
"expires_date_ms": "1628710918000",
"expires_date_pst": "2021-08-11 12:41:58 America/Los_Angeles",
"web_order_line_item_id": "230000438372383",
"is_trial_period": "false",
"is_in_intro_offer_period": "false",
"in_app_ownership_type": "PURCHASED",
"subscription_group_identifier": "272394410"
},
{
"quantity": "1",
"product_id": "basic_subscription_1_month",
"transaction_id": "230001017218955",
"original_transaction_id": "1000000831360853",
"purchase_date": "2021-07-28 19:41:58 Etc/GMT",
"purchase_date_ms": "1627501318000",
"purchase_date_pst": "2021-07-28 12:41:58 America/Los_Angeles",
"original_purchase_date": "2021-04-28 19:41:58 Etc/GMT",
"original_purchase_date_ms": "1619638918000",
"original_purchase_date_pst": "2021-04-28 12:41:58 America/Los_Angeles",
"expires_date": "2021-08-04 19:41:58 Etc/GMT",
"expires_date_ms": "1628106118000",
"expires_date_pst": "2021-08-04 12:41:58 America/Los_Angeles",
"web_order_line_item_id": "230000849023623",
"is_trial_period": "false",
"is_in_intro_offer_period": "false",
"in_app_ownership_type": "PURCHASED",
"subscription_group_identifier": "272394410"
}
],
"latest_receipt": "MIIUVQY...4rVpL8NlYh2/8l7rk0BcStXjQ==",
"pending_renewal_info": [
{
"auto_renew_status": "1",
"auto_renew_product_id": "basic_subscription_1_month",
"product_id": "basic_subscription_1_month",
"original_transaction_id": "1000000831360853"
}
]
},
"bid": "com.adapty.sample_app",
"bvrs": "0"
}
The server notification format is similar to the payment validation response. The transaction details are stored in unified_receipt → latest_receipt_info. The password key stores the shared secret for your app, allowing you to verify the request’s authenticity. The notification_type key stores the event type. In my opinion, the most useful of these are:
Server validation can supercharge your analytics for the data you collect from your app. It also makes it harder for fraudsters to access your premium content, as well as allowing you to make your subscriptions cross-platform. At the same time, implementing server validation can take quite a lot of time, especially if you need highly accurate data. This would require taking lots of side cases into account: subscription upgrade, subscription crossgrade, trial periods, promo/intro offers, grace periods, refunds, family subscriptions, etc. You also have to know of and account for various nuances, e.g. Apple having a policy of lowering the commission from 30% to 15% for subscriptions that get regularly renewed for over a year.
Increase your app’s revenue by exploring Adapty! If you’re an app developer or owner looking to skyrocket your conversion rates and in-app subscriptions, don’t miss the chance to schedule a free demo call with us. We’ll walk you through our platform, showcasing our array of features and benefits, enabling you to integrate in-app purchases within an hour, all with no obligation!
Recommended posts
Paywall Newsletter
12 min read
December 21, 2022