
The Ultimate Handbook On App-Store Receipt Validation
iOS developers are familiar with StoreKit, a framework that allows them to monetize apps through in-app purchases and subscriptions. For everything to work smoothly, avoid piracy, and make sure users get access to what they purchased developers need to perform receipt validation.
The process begins once a user makes an in-app purchase. After that, developers have to process the App Store receipt, the data of which is Base64-encoded. For a decoded version, you need to pass it through Apple’s verifyReceipt endpoint.
Unfortunately, understanding the decrypted receipt can be an arduous process. There are many nuances and potential pitfalls in the data structure. That’s why, in this article, we will cover each element of the decrypted receipt and help you get a better grasp on how you can avoid common problems.
Elements of a Decrypted Receipt
If the customer has a long past purchase history, the App Store decoded receipt response body may be unnerving. Depending on circumstances, there can be 3-7 top-level keys in the data structure:
status
is-retryable
environment
receipt
latest_receipt
latest_receipt_info
pending_renewal_info
{
"status": 21199,
"is-retryable": 1,
"environment": "Sandbox",
"receipt": {},
"latest_receipt": "",
"latest_receipt_info": [{}],
"pending_renewal_info": [{}],
}
responseBody
Since not all top-level keys are always present, let’s first take a look at the key kinds and identify when they are present in the responseBody
.
Key | Type | When in the responseBody? |
---|---|---|
status | Number | Always |
is-retryable | Boolean | Only receipts with status codes 21100-21199 |
environment | String | Always |
receipt | JSON object | Always |
latest_receipt | Base64 | Only auto-renewable subscriptions receipts |
latest_receipt_info | Array of JSON objects | Only auto-renewable subscriptions receipts |
pending_renewal_info | Array of JSON objects | Only auto-renewable subscriptions receipts |
To better understand each of these properties, let’s take a closer look at each one of them.
status
When you get a response from verifyReceipt
, the status key is the first thing you should look at. There’ll either be a 0, meaning the receipt is valid, or a status code indicating an error. To know how to proceed you need to recognize status codes and what they mean.
HTTP status code | Possible status values | Description | How to resolve |
---|---|---|---|
200 | 0 | Valid receipt | No resolution required |
200 | 21000 | The App Store request was not made using the HTTP POST method | Swap the HTTP request method to POST |
200 | 21001 | No longer sent by the App Store | No resolution required |
200 | 21002 | The receipt-data property was distorted or there’s a temporary issue with the receipt server | Check the receipt-data property and/or try again |
200 | 21003 | Authentication failed | Receipt could not be authenticated |
200 | 21004 | Discrepancy between the shared secret you provided and the one on file for your account | Double-check the shared secret and send the correct, app-specific one, in line with App Store Connect |
200 | 21005 | The server is unable to provide the receipt at this time | Try again |
200 | 21006 | The subscription has expired. (This status code is only returned for iOS 6-style transaction receipts for auto-renewable subscriptions) | Upgrade to iOS 7+ style receipts in your app code |
200 | 21007 | The receipt is from a test environment and sent to the production environment for verification | Send this receipt to https://sandbox.itunes.apple.com/verifyReceipt |
200 | 21008 | The receipt is from a production environment and sent to the test environment for verification | Send this receipt to https://buy.itunes.apple.com/verifyReceipt |
200 | 21009 | Internal data access error | Try again |
200 | 21010 | User’s account can’t be found or has been deleted | No resolution required |
200 | 21100-21199 | Apple’s internal data access errors | Look over “is-retryable” to decide whether to try again |
500 | Internal service error | Apply retry logic or add to job queue for future processing | |
502 | Bad gateway | Apply retry logic or add to job queue for future processing | |
503 | Service unavailable | Apply retry logic or add to job queue for future processing |
As you may have noticed, to resolve some status values you just have to try again. This is why your receipt validation service needs to be strong enough to handle various scenarios. Whether it’s network timeouts or service unavailable status codes, you need to be able to deal with it all. If failure continues to occur, it might be worth attempting to process the receipt later and adding it to the job queue.
Now, let’s move on to the next property.
is-retryable
In the previous section, we went through status values and their meanings. One of the last possible values was a range of 21100-21199. If the status value is in that range, you should see the is-retryable
key in the response payload.
Despite Apple listing the type as “boolean”, the values displayed aren’t the typical values of true or false. Instead, they are 0 and 1. 0 means the issue is unresolvable and you shouldn’t try validating this receipt. 1 means the issue is temporary and you can try to validate the receipt again.
environment
This value indicates the environment for which the receipt was generated. It’ll either be Sandbox, a receipt generated from the sandbox environment, or Production, a receipt generated from the production environment.
receipt
The value of receipt is a JSON representation that was sent for verification. This is a key that always exists in a successful receipt, regardless of whether the user has made an in-app purchase.
Ideally, you should send receipt data from your app to a server so that you store and decode many receipts that contain the receipt key with specific metadata even if there have been no in-app purchases.
If you remember, the App Store was built on the principles of the iTunes Store. Back then there were only two types of apps – paid and free. Now, however, we have in-app purchases and subscriptions which have made things a bit more complex.
Additional context was added to the data structure following the introduction of in-app purchases. To understand in-app purchases there’s a full context for you to work with, unlike for the subscription in-app purchases.
This is where latest_receipt_info and pending_renewal_info keys come in. For auto-renewable subscriptions, you will find additional context inside these two properties.
{
"environment": "Sandbox",
"receipt": {
"receipt_type": "ProductionSandbox",
"adam_id": 0,
"app_item_id": 0,
"bundle_id": "product.name",
"application_version": "7",
"download_id": 0,
"version_external_identifier": 0,
"receipt_creation_date": "2021-04-13 06:46:04 Etc/GMT",
"receipt_creation_date_ms": "1618296364000",
"receipt_creation_date_pst": "2021-04-12 23:46:04 America/Los_Angeles",
"request_date": "2021-04-26 08:40:45 Etc/GMT",
"request_date_ms": "1619426445186",
"request_date_pst": "2021-04-26 01:40:45 America/Los_Angeles",
"original_purchase_date": "2013-08-01 07:00:00 Etc/GMT",
"original_purchase_date_ms": "1375340400000",
"original_purchase_date_pst": "2013-08-01 00:00:00 America/Los_Angeles",
"original_application_version": "1.0",
"in_app": [
{
latest_receipt
The latest Base64 encrypted app receipt. This one is only returned for auto-renewable subscription receipts.
latest_receipt_info
This key is an array containing all in-app purchase transactions. Once again, it’s only returned for auto-renewable subscription receipts.
To include only the latest transactions, you can set the exclude-old-transactions field to true in your verifyReceipt request.
"latest_receipt_info": [
{
"quantity": "1",
"product_id": "product.name.1",
"transaction_id": "1000000000000000",
"original_transaction_id": "1000000000000000",
"purchase_date": "2021-04-09 08:20:55 Etc/GMT",
"purchase_date_ms": "1617956455000",
"purchase_date_pst": "2021-04-09 01:20:55 America/Los_Angeles",
"original_purchase_date": "2021-04-13 09:51:39 Etc/GMT",
"original_purchase_date_ms": "1618307499000",
"original_purchase_date_pst": "2021-04-13 02:51:39 America/Los_Angeles",
"expires_date": "2021-04-09 08:25:55 Etc/GMT",
"expires_date_ms": "1617956755000",
"expires_date_pst": "2021-04-09 01:25:55 America/Los_Angeles",
"web_order_line_item_id": "1000000000000000",
"is_trial_period": "false",
"is_in_intro_offer_period": "false",
"subscription_group_identifier": "10000000"
},
pending_renewal_info
Also an array of JSON objects where each element represents the pending renewal information for each auto-renewable subscription identified by the product_id
. It’s only returned for auto-renewable subscription receipts.
responseBody.Receipt
Now that we’ve covered all the responseBody properties, let’s deep dive into the potential elements of responseBody.Receipt.
Key | Type | When in the responseBody.Receipt? |
---|---|---|
adam_id | Number | Always |
app_item_id | Number | Always |
application_version | String | Always |
bundle_id | String | Always |
download_id | Number | Always |
expiration_date | String | Only apps obtained via Volume Purchase Program |
expiration_data_ms | String | Only apps obtained via Volume Purchase Program |
expiration_data_pst | String | Only apps obtained via Volume Purchase Program |
in_app | Array | Always |
original_application_version | String | Always |
original_purchase_date | String | Always |
original_purchase_date_ms | String | Always |
original_purchase_date_pst | String | Always |
preorder_date | String | Only if the user pre-ordered the app |
preorder_data_ms | String | Only if the user pre-ordered the app |
preorder_date_pst | String | Only if the user pre-ordered the app |
receipt_creation_date | String | Always |
receipt_creation_date_ms | String | Always |
receipt_creation_date_pst | String | Always |
receipt_type | String | Always |
request_date | String | Always |
request_date_ms | String | Always |
request_date_pst | String | Always |
version_external_identifier | Number | Always |
This may look overwhelming, but don’t worry. We are going to look over each of these properties in more detail so you can get a better understanding of what they mean.
adam_id and app_item_id
This property is generated by App Store Connect and used to uniquely identify the app corresponding with the receipt. It’s a 64-bit long integer that is assigned solely in production. Thus, expect a unique value in production and a 0 in the sandbox.
application_version
Indicates the app’s version number at the time of the receipt’s receipt_creation_date_ms
. It corresponds to CFBundleVersion
(iOS) or CFBundleShortVersionString
(macOS) in the Info.plist. The value is always “1.0” in the sandbox.
bundle_id
This string is provided on App Store Connect and relates to the value of CFBundleIdentifier in the Info.plist file of the app. It’s a bundle identifier to the app to which the receipt belongs.
download_id
This is a unique identifier for the app download transaction. In the sandbox, the field will be populated with a 0, while in production you will see a unique identifier.
Unfortunately, the download_id is not well covered by Apple. It appears to be connected to the download transaction represented by original_application_version
and original_purchase_date
.
expiration_date, expiration_date_pst, expiration_date_ms
Illustrate the time at which the receipt expires for apps bought through the Volume Purchase Program.
For the expiration_date the date-time format is similar to that of ISO 8601. For the expiration_date_pst it’s in the Pacific Time zone. Finally, for expiration_date_ms
it’s in UNIX epoch time format, in milliseconds.
in_app
This is an array that contains in-app purchase receipt fields for all in-app purchase transactions.
original_application_version
Indicates the version of the app that the user originally acquired. If the originally purchased version was 2.5, the value in this field would be 2.5, even if currently the application runs on version 5.0. In the sandbox, the value is always “1.0”.
This number corresponds to CFBundleVersion (iOS) or CFBundleShortVersionString (macOS) from the Info.plist.
Original_purchase_date, original_purchase_date_pst, original_purchase_date_ms
Illustrate the time of the original app purchase.
As was the case with the expiration_date
property, the original_purchase_date
has the date-time format similar to that of ISO 8601. original_purchase_date_pst
is in the Pacific Time zone format. original_purchase_date_ms
is in UNIX epoch time format, in milliseconds.
preorder_date, preorder_date_pst, preorder_date_ms
If the app was available for pre-orders, this field would indicate the time the user made the pre-order.
preorder_date
property has a date-time format similar to that of ISO 8601. preorder_date_pst
is in the Pacific Time zone format and preorder_date_ms
is in UNIX epoch time format, in milliseconds.
receipt_creation_date, receipt_creation_date_pst, receipt_creation_date_ms
Specify the time at which the App Store generated the receipt.
You probably already got the idea, but let us detail the formats anyway. receipt_creation_date
property has a date-time format similar to that of ISO 8601. Receipt_creation_date_pst is in the Pacific Time zone format and receipt_creation_date_ms
is in UNIX epoch time format, in milliseconds.
receipt_type
Lists the type of the receipt that was generated. The value relates to the environment in which the app or VPP purchase was made. Possible values include:
- Production: the receipt was created in the App Store production environment
- ProductionVPP: the receipt was created in the VPP production environment
- ProductionSandbox: the receipt was created in the App Store sandbox environment
- Production VPPSandbox: the receipt was created in the VPP sandbox environment
request_date, request_date_pst, request_date_ms
Show the time the request to the verifyReceipt
endpoint was processed and generated a response.
For the request_date the date-time format is similar to that of ISO 8601. For the request_date_pst it’s in the Pacific Time zone. Finally, for request_date_ms it’s in UNIX epoch time format, in milliseconds.
version_external_identifier
A random number that specifies a revision of the app. In the sandbox, this value is “0”.
responseBody.latest_receipt_info Properties
Time to move on to the properties of responseBody.latest_receipt_info
Key | Type | When in the responseBody.latest_receipt_info? |
---|---|---|
cancellation_date | String | Only when transactions were refunded by the App Store |
cancellation_date_pst | String | Only when transactions were refunded by the App Store |
cancellation_date_ms | String | Only when transactions were refunded by the App Store |
cancellation_reason | String | Only when transactions were refunded by the App Store |
expires_date | String | Always |
expires_date_pst | String | Always |
expires_date_ms | String | Always |
in_app_ownership_type | String | Only when Family Sharing is on |
is_in_intro_offer_period | String | Always |
is_trial_period | String | Always |
is_upgraded | String | Only in upgrade transactions |
offer_code_ref_name | String | Only when a subscription offer code was redeemed |
original_purchase_date | String | Always |
original_purchase_date_pst | String | Always |
original_purchase_date_ms | String | Always |
original_transaction_id | String | Always |
product_id | String | Always |
promotional_offer_id | String | Only when a promotional offer was redeemed |
purchase_date | String | Always |
purchase_date_pst | String | Always |
purchase_date_ms | String | Always |
quantity | String | Always |
subscription_group_identifier | String | Always |
web_order_line_item_id | String | Always |
transaction_id | String | Always |
Hopefully, you already got used to our format. Just like before, let’s explore each of these keys individually.
cancellation_date, cancellation_date_pst, cancellation_date_ms
Indicate the time at which the subscription was canceled. A couple of things can cause this. First, Apple’s customer support team may have refunded the transaction. Second, if Family Sharing is set up, changes to access could lead to subscription cancellation. Finally, the user may have simply upgraded to a different product.
For the cancellation_date
the date-time format is similar to that of ISO 8601. For the cancellation_date_pst
it’s in the Pacific Time zone. Lastly, for cancellation_date_ms it’s in UNIX epoch time format, in milliseconds.
cancellation_reason
Lists the reason behind the refunded transaction. Possible values are 1 and 0. “1” means that the customer canceled because of an issue within your app. On the other hand, “0” means the cancellation occurred for another reason. Often, if the customer accidentally made the purchase.
expires_date, expires_date_pst, expires_date_ms
Illustrate the time at which the subscription will expire or renew.
Expires_date property has a date-time format similar to that of ISO 8601. expires_date_pst
is in the Pacific Time zone format and expires_date_ms
is in UNIX epoch time format, in milliseconds.
in_app_ownership_type
In this property, you will see whether the user is the purchaser of the product, or rather a family member with access to it through Family Sharing.
The value will either be FAMILY_SHARED
or PURCHASED
.
is_in_intro_offer_period
Illustrates if an auto-renewable subscription is in the introductory price period or not. Possible values are true
and false
. true
indicates that the subscription is in fact in an introductory price period, while false
negates it.
is_trial_period
Specifies whether a subscription is in the free trial period or not.
is_upgraded
This property is only present for upgraded transactions and illustrates if a subscription has been canceled because of an upgrade. The value can only be true
, signifying that this is what happened.
offer_code_ref_name
Only present when a subscription offer code was redeemed. Indicates the reference name of the offer that was set up in App Store Connect.
original_purchase_date, original_purchase_date_pst, original_purchase_date_ms
Illustrate the time of the original auto-renewable subscription purchase.
original_purchase_date
property has a date-time format similar to that of ISO 8601. original_purchase_date_pst
is in the Pacific Time zone format and original_purchase_date_ms
is in UNIX epoch time format, in milliseconds.
original_transaction_id
Identifies the original purchase transaction.
product_id
Identifies the product purchased as set up for that product in App Store Connect.
promotional_offer_id
Identifies the user’s redeemed subscription offer.
purchase_date, purchase_date_pst, purchase_date_ms
Showcase the time at which the user’s account was charged by the App Store for a subscription purchase or renewal.
Purchase_date property has a date-time format similar to that of ISO 8601. purchase_date_pst
is in the Pacific Time zone format and purchase_date_ms
is in UNIX epoch time format, in milliseconds.
quantity
Illustrates the number of accessible products purchased. Usually 1-10, typically 1.
subscription_group_identifier
Identifies the group to which the subscription belongs.
web_order_line_item_id
This is the primary key for identifying subscription purchases. It identifies purchase occurrences across devices.
transaction_id
Identifies purchases, restores, and renewals. The transaction is a purchase when transaction_id matches original_transaction_id
. If they don’t match then it’s a restore or renewal.
responseBody.pending_renewal_info Properties
Now, let’s take a look at responseBody.pending_renewal_info
properties!
Key | Type | When in the responseBody.pending_renewal_info? |
---|---|---|
auto_renew_product_id | String | Only when there’s a down- or crossgrade to a subscription of a different duration. |
auto_renew_status | String | Always |
expiration_intent | String | Only when a receipt contains an expired auto-renewable subscription. |
grace_period_expires_date | String | Only when there’s been a billing error at the time of renewal. |
grace_period_expires_date_ms | String | Only when there’s been a billing error at the time of renewal. |
grace_period_expires_date_pst | String | Only when there’s been a billing error at the time of renewal. |
is_in_billing_retry_period | String | Only when an expired auto-renewable subscription is in the retry phase. |
offer_code_ref_name | String | Only when a subscription offer code was redeemed. |
original_transaction_id | String | Always |
price_consent_status | String | Only when the customer was notified of an increase in price. |
product_id | String | Always |
}
],
"latest_receipt": "[new]",
"pending_renewal_info": [
{
"expiration_intent": "1",
"auto_renew_product_id": "test.vip.6month.3d.3999.1",
"is_in_billing_retry_period": "0",
"product_id": "product.name.1",
"original_transaction_id": "100000000000000",
"auto_renew_status": "0"
}
],
"status": 0
}
auto_renew_product_id
Identifies customer’s unresolved subscription renewal. You will only see this field if the user has downgraded or crossgraded to a different duration subscription for the following period.
auto_renew_status
Indicates whether an auto-renewable subscription will renew at the end of the current subscription period. “1” illustrates that it will and “0” shows that the customer has canceled automatic renewal.
If you see the latter, the customer is at high risk of churn and it might be worth presenting him with a special offer.
expiration_intent
Showcases the reason for subscription expiry. Possible values are 1-5.
- 1 = the customer canceled the subscription themselves. Consider sending a win-back campaign.
- 2 = there was a billing error. Consider sending a reminder to update billing information to avoid losing access.
- 3 = the user didn’t agree to a price increase.
- 4 = the product was unavailable at the time of renewal.
- 5 = unknown
grace_period_expires_date, grace_period_expires_date_pst, grace_period_expires_date_ms
Signify the time when the subscription renewal grace period expires.
grace_period_expires_date
property has a date-time format similar to that of ISO 8601. grace_period_expires_date_pst
is in the Pacific Time zone format and Grace_period_expires_date_ms is in UNIX epoch time format, in milliseconds.
is_in_billing_retry_period
Illustrates if the customer’s auto-renewable subscription is in the billing retry period. Possible values are 1
and 0
. The former indicates that the App Store is attempting to renew the subscription while the latter shows it has stopped attempting to renew. If the value is 1
, this is a good time to send a billing update request.
offer_code_ref_name
Showcases the reference name of the subscription offer you set up in App Store Connect. This field is only visible when a subscription offer code was redeemed.
original_transaction_id
Identifies the transaction of the original purchase.
price_consent_status
Indicates the consent status for the subscription price increase. The customer has either consented to the increase or hasn’t. 1
means that the customer has agreed and 0
illustrates that the customer has been notified of the price increase but hasn’t agreed to it yet.
product_id
Identifies that purchased product per your setup in App Store Connect.
Date Format Variations
You have probably noticed that throughout the decrypted receipt there are various date formats:
- ISO 8601 -like GMT
- ISO 8601 -like PST
- UNIX epoch time in milliseconds
But do you already know what these date-time formats mean?
In its documentation, Apple refers to a data-time format similar to the ISO 8601 or an RFC 3339 date. Converting these string-based formats can be difficult, so let’s take a closer look.
As you have seen throughout this document, each base date has two modifiers: _pst and _ms. The former represents ISO 8601-like PST (Pacific Standard Time) and the latter represents milliseconds since UNIX epoch time.
A couple of things you should note:
- Be extra careful during Pacific Daylight Time (PDT) because the value remains in PST time.
- The easiest format to work with is UNIX epoch time in milliseconds because the majority of modern programming languages can convert it to a native date-time format.
Improve Your Receipt Management
Congratulations! You got through our rundown of decrypted receipt elements.
Hopefully, you now have a better grasp on each of them, but don’t worry if things are still a bit overwhelming. You can always bookmark this page for future reference.
Overall, you’ve probably noticed that decrypted receipts are quite complex. New features are added to the App Store every year and writing good code to manage receipts and leverage everything the Store has to offer is incredibly challenging.
We understand that you may not want to do all of this yourself. That’s precisely why we are here!
If you want to avoid the hassle with StoreKit or don’t want to build your own subscription validation server, you can always turn to Qonversion! You can head to our Apple Receipt Checker right now or explore the Product Center which provides full in-app purchases infrastructure. As developers, you don’t have to perform this time-consuming receipts management anymore. Leave it to us.