Working with Apple In-App Purchases

Tong Qiu
Codehesion
Published in
5 min readApr 10, 2018

Recently, I had my first experience of working with Apple’s In-App Purchases, specifically subscriptions. It was a confoundingly bad experience, so I’ve decided to compile this list of gotchas to watch out for, in the hope that others embarking on the same treacherous journey may save some of the time that we wasted.

Section 1: Development restrictions.

TL;DR:

  • You can’t generate test purchase data for your app without a working payments frontend.
  • Don’t trust the docs. Use the below examples for receipt/event data.
  • Don’t build integration tests that wait for responses from Sandbox. They will error a lot.
  • You have to deal with different Apple environments in a really silly way.

Good luck testing it.

We wanted to build a receipt and events handler, to process payments and turn them into subscriptions in our own database. We figured this is a purely backend task, right? We could build it and send some test data independently of the frontend. Wrong. There is no way to generate test data for your app without a working payments frontend (even in the Sandbox environment). So we built the backend entirely based on the docs, without real data from Apple. And guess what.

The docs lie.

There are numerous inaccuracies; for example, some fields, such as “creation_date” and “cancellation_date” simply don’t exist as the docs claim. But the biggest offenders are the timestamps. The docs say that dates are in RFC 3339 format, which is a timestamp like “2018–04–06T23:28:15.000Z”. You’ll note from the example receipt below, that there are a few occurrences of timestamps in this format, but not with the key foo_date, but foo_date_ms. Of course, not even all foo_date_ms keys are in this format; original_purchase_date_ms is in Unix milliseconds, while expires_date_ms and purchase_date_ms are RFC 3339.

{
environment: Sandbox,
latest_receipt: <encoded string>,
latest_receipt_info: [
{
expires_date: 2018-04-06 23:28:15 Etc/GMT,
expires_date_ms: 2018-04-06T23:28:15.000Z,
expires_date_pst: 2018-04-06 16:28:15 America/Los_Angeles,
is_in_intro_offer_period: false,
is_trial_period: false,
original_purchase_date: 2018-04-06 21:58:07 Etc/GMT,
original_purchase_date_ms: 1523051887000,
original_purchase_date_pst: 2018-04-06 14:58:07 America/Los_Angeles,
original_transaction_id: <string id>,
product_id: <string id>,
purchase_date: 2018-04-06 23:13:15 Etc/GMT,
purchase_date_ms: 2018-04-06T23:13:15.000Z,
purchase_date_pst: 2018-04-06 16:13:15 America/Los_Angeles,
quantity: 1,
transaction_id: <string id>,
web_order_line_item_id: <string id>
}
],
pending_renewal_info: [
{
auto_renew_product_id: <string id>,
auto_renew_status: 0,
expiration_intent: 1,
is_in_billing_retry_period: 0,
original_transaction_id: <string id>,
product_id: <string id>
}
],
receipt: {
adam_id: 0,
app_item_id: 0,
application_version: <app version>,
bundle_id: <bundle id>,
download_id: 0,
in_app: <A list of objects with the same fields as "latest_receipt_info" above. According the to docs, "the value of this key is an array containing all in-app purchase receipts based on the in-app purchase transactions present in the input base-64 receipt-data. For receipts containing auto-renewable subscriptions, check the value of the latest_receipt_info key to get the status of the most recent renewal." It is not explained why the input receipt-data returns multiple receipts, seemingly for different subscriptions.>,
original_application_version: 1.0,
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,
receipt_creation_date: 2018-04-07 11:19:12 Etc/GMT,
receipt_creation_date_ms: 2018-04-07T11:19:12.000Z,
receipt_creation_date_pst: 2018-04-07 04:19:12 America/Los_Angeles,
receipt_type: ProductionSandbox <yeah, what?>,
request_date: 2018-04-07 11:19:14 Etc/GMT,
request_date_ms: 1523099954652,
request_date_pst: 2018-04-07 04:19:14 America/Los_Angeles,
version_external_identifier: 0
},

But wait, it gets even better. Not only are there weird timestamp inconsistencies within the receipt, there are even more between a validated receipt response and an event payload (for renewal, cancellation etc). Here is an example event payload for a RENEW event:

{
auto_renew_product_id: <string id>,
auto_renew_status: true,
environment: Sandbox,
latest_receipt: <encoded string>,
latest_receipt_info: {
bid: <bundle id>,
bvrs: <application version>,
expires_date: 1523057295000,
expires_date_formatted: 2018-04-06 23:28:15 Etc/GMT,
expires_date_formatted_pst: 2018-04-06 16:28:15 America/Los_Angeles,
is_in_intro_offer_period: false,
is_trial_period: false,
item_id: <string id>,
original_purchase_date: 2018-04-06 21:58:07 Etc/GMT,
original_purchase_date_ms: 1523051887000,
original_purchase_date_pst: 2018-04-06 14:58:07 America/Los_Angeles,
original_transaction_id: <string id>,
product_id: <string id>,
purchase_date: 2018-04-06 23:13:15 Etc/GMT,
purchase_date_ms: 1523056395000,
purchase_date_pst: 2018-04-06 16:13:15 America/Los_Angeles,
quantity: 1,
transaction_id: <string id>,
unique_identifier: <uid>,
unique_vendor_identifier: <string id>,
version_external_identifier: 0,
web_order_line_item_id: <string id>
},
notification_type: RENEWAL
}

Notice now that expires_date is in Unix milliseconds, and the other expires date keys have gained a “_formatted”, while purchase_date and original_purchase_date remain the same as in the validated receipt. Oh also, the Unix milliseconds are strings. Thanks, Apple.

Sandbox is a bit shit.

Once we’d built our frontend and corrected for the above, finally, we could test! Only to find out that Apple Sandbox is just down a lot… sometimes for a week at a time.

Environment management is in general a bit shit.

Your app is supposed to send your server an encoded receipt following a purchase. It has no way of differentiating between sandbox and production purchases, so has to send them all to one server endpoint. On the server, you need to verify the receipt with Apple by posting the encoded receipt to an Apple endpoint, which returns the decoded receipt or an error status code. Of course, there are two verification endpoints — one for sandbox and one for production, but you have no way of knowing what kind of receipt you have until you’ve decoded it. Apparently the recommended approach is to try one endpoint, and if you get back a “wrong env” error status, try the other. Which technically works, but seems incredibly stupid.

Section 2: Business restrictions

TL;DR:

  • Before finalising a price for your product/subscription, check that iTunes actually lets you set it.
  • You have no control over refunds.
  • If your app has its own auth system, think about how it should work with Apple accounts.

You can’t choose the price for your product.

When you go to create your products in iTunes, you get a bigass dropdown of prices you can choose from. They seem to go up in random increments and are all like £XX.99. Want round prices? Can’t have them.

You can’t issue refunds.

There is no management UI or even API for you to do this. If a user wants a refund, they have to call Apple’s customer support.

A subscription purchased through Apple In-App Purchases is tied to an Apple account, not a user account for you app.

According to Apple’s guidelines, you need to support the restoration of purchases on your app, so that if a user buys a new phone, they can restore their purchases on their apps. However, if your app has its own auth system, this also means that multiple users can log into your app on one phone, and restore that Apple account’s subscriptions to their accounts. To prevent users from being able to get free subscriptions this way, we built our subscription model such that each Apple subscription (identifiable via the web_order_line_item) can only have one user. If a new user tries to restore a different user’s subscriptions, the previous user will lose it.

Finally, the sour cherry on top of this shit pie is that you get to pay Apple a juicy 30% for the privilege of eating it. The good news is that Apple users are used to paying overinflated prices, so you can probably just push that cost onto them.

--

--