The Ultimate Handbook On App-Store Receipt Validation

Eugene Virnik avatar
Eugene Virnik

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.

KeyType When in the responseBody?
statusNumberAlways
is-retryableBooleanOnly receipts with status codes 21100-21199
environmentStringAlways
receiptJSON objectAlways
latest_receiptBase64Only auto-renewable subscriptions receipts
latest_receipt_infoArray of JSON objectsOnly auto-renewable subscriptions receipts
pending_renewal_infoArray of JSON objectsOnly 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 codePossible status valuesDescriptionHow to resolve
2000Valid receiptNo resolution required
20021000The App Store request was not made using the HTTP POST methodSwap the HTTP request method to POST
20021001No longer sent by the App StoreNo resolution required
20021002The receipt-data property was distorted or there’s a temporary issue with the receipt serverCheck the receipt-data property and/or try again
20021003Authentication failedReceipt could not be authenticated
20021004Discrepancy between the shared secret you provided and the one on file for your accountDouble-check the shared secret and send the correct, app-specific one, in line with App Store Connect
20021005The server is unable to provide the receipt at this timeTry again
20021006The 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
20021007The receipt is from a test environment and sent to the production environment for verificationSend this receipt to https://sandbox.itunes.apple.com/verifyReceipt 
20021008The receipt is from a production environment and sent to the test environment for verificationSend this receipt to https://buy.itunes.apple.com/verifyReceipt  
20021009Internal data access errorTry again
20021010User’s account can’t be found or has been deletedNo resolution required
20021100-21199Apple’s internal data access errorsLook over “is-retryable” to decide whether to try again
500Internal service errorApply retry logic or add to job queue for future processing
502Bad gatewayApply retry logic or add to job queue for future processing
503Service unavailableApply 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.

KeyTypeWhen in the responseBody.Receipt?
adam_idNumberAlways
app_item_idNumberAlways
application_versionStringAlways
bundle_idStringAlways
download_idNumberAlways
expiration_dateStringOnly apps obtained via Volume Purchase Program
expiration_data_msStringOnly apps obtained via Volume Purchase Program
expiration_data_pstStringOnly apps obtained via Volume Purchase Program
in_appArrayAlways
original_application_versionStringAlways
original_purchase_dateStringAlways
original_purchase_date_msStringAlways
original_purchase_date_pstStringAlways
preorder_dateStringOnly if the user pre-ordered the app
preorder_data_msStringOnly if the user pre-ordered the app
preorder_date_pstStringOnly if the user pre-ordered the app
receipt_creation_dateStringAlways
receipt_creation_date_msStringAlways
receipt_creation_date_pstStringAlways
receipt_typeStringAlways
request_dateStringAlways
request_date_msStringAlways
request_date_pstStringAlways
version_external_identifierNumberAlways

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

KeyTypeWhen in the responseBody.latest_receipt_info?
cancellation_dateStringOnly when transactions were refunded by the App Store
cancellation_date_pstStringOnly when transactions were refunded by the App Store
cancellation_date_msStringOnly when transactions were refunded by the App Store
cancellation_reasonStringOnly when transactions were refunded by the App Store
expires_dateStringAlways
expires_date_pstStringAlways
expires_date_msStringAlways
in_app_ownership_typeStringOnly when Family Sharing is on
is_in_intro_offer_periodStringAlways
is_trial_periodStringAlways
is_upgradedStringOnly in upgrade transactions
offer_code_ref_nameStringOnly when a subscription offer code was redeemed
original_purchase_dateStringAlways
original_purchase_date_pstStringAlways
original_purchase_date_msStringAlways
original_transaction_idStringAlways
product_idStringAlways
promotional_offer_idStringOnly when a promotional offer was redeemed
purchase_dateStringAlways
purchase_date_pstStringAlways
purchase_date_msStringAlways
quantityStringAlways
subscription_group_identifierStringAlways
web_order_line_item_idStringAlways
transaction_idStringAlways

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!

KeyTypeWhen in the responseBody.pending_renewal_info?
auto_renew_product_idStringOnly when there’s a down- or crossgrade to a subscription of a different duration.
auto_renew_statusStringAlways
expiration_intentStringOnly when a receipt contains an expired auto-renewable subscription.
grace_period_expires_dateStringOnly when there’s been a billing error at the time of renewal.
grace_period_expires_date_msStringOnly when there’s been a billing error at the time of renewal.
grace_period_expires_date_pstStringOnly when there’s been a billing error at the time of renewal.
is_in_billing_retry_periodStringOnly when an expired auto-renewable subscription is in the retry phase.
offer_code_ref_nameStringOnly when a subscription offer code was redeemed.
original_transaction_idStringAlways
price_consent_statusStringOnly when the customer was notified of an increase in price.
product_idStringAlways
}
    ],
    "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.