⟵ Back to blog

iOS in-app purchases, part 4: server-side purchase validation

15 min read

Share

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 ones as well:

  1. iOS in-app purchases. part 1: configuration and adding to the project.
  2. iOS in-app purchases, part 2: purchase initialization and processing.
  3. iOS in-app purchases, part 3: testing purchases in Xcode.
  4. iOS in-app purchases, part 4: server purchase validation.
  5. iOS in-app purchases, part 5: the list of SKError codes and how to handle them.

What is server purchase validation?

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.

Why validate purchases?

It should be noted that server validation isn’t mandatory — in-app purchases will still work without it. It grants some advantages, though:

  1. Advanced payment analytics, which is especially important for subscriptions since everything that happens after the activation isn’t processed by the device. With no server purchase processing, you won’t be able to retrieve the current subscription status and know whether the user has renewed the subscription or canceled it, whether there are any billing issues, and so on.
  2. Being able to verify the purchase’s authenticity. You’ll be sure that the transaction isn’t fraudulent, and the user has actually paid for your product.
  3. Cross platform subscriptions. If you can check the user’s subscription status in real time, you can synchronize it with other platforms. For example, the user who purchased the subscription from an iOS device will be able to use it on Android, the Web, and other platforms. 
  4. Being able to control content access from the server side, which protects you from users trying to access the data with no subscription by simply executing requests to the server. 

Speaking from our experience, the first advantage alone is enough to set up server purchase processing.

Purchase validation

In general, the process of receipt validation on iOS looks like this:

Generating the shared secret

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 In-App Purchases → Manage and click App-Specific Shared Secret. In the window that opens, you’ll be able to generate a new token or copy the existing one.

Generating an app-specific Shared Secret

To receive the secret for all apps in your account, open the Users and Access page and select the Shared Secret tab.

How to find a Shared Secret for all apps

Requesting payment validation

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 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 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 returned 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.

Retrieving the subscription status and transaction history

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 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:

  • product_id: the text identifier of the purchased product.
  • transaction_id: the unique numeric identifier of the transaction. Each purchase or renewal will have its own identifier that can be used to understand whether the transaction had previously been processed. 
  • original_transaction_id: the unique numeric identifier of the transaction chain. It will match transaction_id upon the subscription or trial activation. Upon further subscription renewals, though, transaction_id will change, while original_transaction_id will stay the same. This is handy for tracking how many times the subscription was renewed.
  • purchase_date и original_purchase_date: the transaction date and the original transaction date. This works under the same logic as the previous property. 
  • expires_date: the subscription’s expiration date.
  • cancellation_date: the date of the refund, not the subscription cancellation as the name might suggest. If the response has this field, it means that you can terminate the user’s access to their subscription, as well as account for the cancellation in your analytics — you won’t receive any payment from this transaction.
  • is_in_intro_offer_period: the flag showing whether the intro offer was used upon the subscription activation.
  • is_trial_period: the flag showing whether there was a trial period upon the subscription activation.
  • offer_code_ref_name: the offer code that was used upon subscription activation.
  • promotional_offer_id: the text identifier of the promo offer that was used when entering the current billing period.
  • in_app_ownership_type: the subscription ownership type. It reveals whether the user purchased the product themselves or received it as a part of the family subscription. Some possible values include:
  • PURCHASED: the user purchased the product themselves.
  • FAMILY_SHARED: the user received the product as a part of the family subscription.

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.

Subscription renewal info, grace period, and billing issues

The pending_renewal_info array stores the subscription renewal data. It allows 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 discuss 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"
}
  • product_id: the text identifier of the product that was purchased.
  • auto_renew_product_id: the text identifier of the product that will be activated in the next billing period. If it’s different from the current one (product_id), it means the user has changed their subscription type.
  • auto_renew_status: the flag showing whether the subscription is to be continued into the next billing period.
  • expiration_intent: the reason behind the subscription expiry. Some possible values include:
  • 1 — the user canceled the subscription themselves,
  • 2 — the subscription was canceled because of billing issues,
  • 3 — the user didn’t agree to a price increase,
  • 4 — the subscription product was unavailable for renewal, e.g. if it had been removed from App Store Connect.
  • 5 — an unknown reason.
  • grace_period_expires_date: the expiry date of the grace period if it exists for your app. If that’s the case, the user should have access to the premium features up to the date specified here, not the one in the transaction itself. If you have this key, you can notify the user they need to update their card information or top up their balance.
  • is_in_billing_retry_period: the flag showing whether the subscription has the billing retry status. It means the subscription wasn’t canceled, but Apple couldn’t charge the renewal payment and will keep trying to do it over the course of 60 days.
  • offer_code_ref_name: the offer code that will be used in the next billing period.
  • promotional_offer_id: the text identifier of the promo offer that will be used in the next billing period.
  • price_consent_status: the flag showing whether the user agreed to the upcoming subscription price increase. If it has the 0 value, you should offer the user some different product or a promo offer so that they don’t cancel the subscription.

Consumable, non-consumable, and non-renewable subscriptions

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 receiptin_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 receiptin_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.

Server transaction notifications

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. Next, put the link into the URL for App Store Server Notifications field and save your changes.

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_receiptlatest_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:

  • DID_CHANGE_RENEWAL_STATUS: it means the user turned the subscription auto renewal off or on (the latter is much more rare). If the user opted out of auto renewal, you may want to encourage them to rejoin your active subscribers.
  • DID_FAIL_TO_RENEW: the subscription couldn’t be renewed because of billing issues. You should notify the user about it so that the subscription doesn’t get canceled automatically.
  • DID_RENEW: the subscription was successfully renewed. 
  • REFUND: the subscription was refunded. You should restrict the user’s access to premium features this purchase was to grant and account for the refund (that is, losing this money) in your analytics.

Conclusion

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.


Kirill Potekhin
August 26, 2021