Implementing In app subscriptions for IOS and Android.

Chris Eelmaa
14 min readDec 14, 2019

I have implemented in app subscription model for IOS & Android successfully and have had it out in production for a while (using NodeJS).

I have to admit, it wasn’t easy at all, but it really should be. I’ve had to google a lot and read over 100+ different sources to make sense of the madness that is App store & Play store subscriptions. My brain hurts. In play store defense, it’s actually quite decent compared to Apple, but there were still some things I had to learn.

Hoping to compile everything that I know to one long post, a brain dump if you will — my brain is very fresh right now. Fingers crossed that this saves someone time. This post goes to deeper details quite fast so general knowledge is expected. In fact, if you go to very end of the post, there are some sources, perhaps it is better to start with these, and come back to this to really ensure you’re on the right track.

Please note that this post does not include the front-end part of the things. You need to figure out that yourself — most complexity comes from backend. This post will be solely focused on explaining how to handle subscriptions in the backend, that includes refreshing, and other smaller gotchas. Both apple and Android have native transactional payment queues that you can listen to in frontend. You should listen to them, and mark transactions acknowledged / finished ONCE they reach your backend.

Every time your app opens again, the transactions should be read off from the queue and try to be re-sent to server if they have not yet been processed.

The end goal of this is to have very robust in app subscription infrastructure where no user will ever email to support to say “hey I bought premium, but my account wasn’t credited?” or “hey I renewed but my account doesn’t have premium anymore??”.

Once you receive purchase in the front-end, you can send the “receipt” for server to process. If you plan to have in app subscriptions, you must have backend that holds all the information and processed receipts.

Note the definition of “receipt” is different between Android and iOS. We will define the receipt in following way.

Android
note that there is no such thing as receipt on Android, but we can define it as an object with the following structure:

{ 
packageName: purchase.packageName,
productId: purchase.productId,
purchaseToken: purchase.purchaseToken,
subscription: true
};

iOS
This is base64 encoded “receipt” string that you receive from Apple. This can be used in the future to query ALL the information relating to that iCloud user and his purchases, for your application (imagine this as kind of user access token for his purchases). You should always store that in the database so that in the future you can use these “receipts” to generate full picture of what is going on. Your churn rate, revenue, refunds, etc.

After our backend has received the receipt along with userId (assuming you have one — I mean you really should have one!), we will first, have to verify that this is actually a valid receipt. I recommend you not to write your own code, but to use existing library. Either way; I recommend https://github.com/voltrue2/in-app-purchase

It handles app store sandbox receipts as well production ones. It also is intelligent enough to handle some different responses from App store. I am sure there are some libraries for your language as well.

Essentially, when you have configured the library correctly (use google Service account for Android!), you feed in the “receipt” and you get back the information about your subscription.

This includes stuff such as, what is the payment state, expiration date. Note that voltrue2/in-app-purchase does some logic to parse & unify the output format. For example there is no cancellationDate for Android, but there is artificial one when cancellationReason has been set and is not zero (expiryTimeMillis is used).

If the receipt was validated successfully, I recommend you to create a table called Subscriptions.

First we need to come up with an imaginary transactionId that is used as primary key in our table, using that we can either update or create a new subscription in it.

Android
use the purchaseToken as transactionId. It does not change when user subscription automatically renews.

Google Play Billing tracks products and transactions using purchase tokens and order IDs.

A purchase token is a string that represents a buyer’s entitlement to a product on Google Play. It indicates that a Google user has paid for a specific product, represented by a SKU.

An order ID is a string that represents a financial transaction on Google Play. This string is included in a receipt that is emailed to the buyer, and third-party developers use the order ID to manage refunds in the Order Management section of the Google Play Console. Order IDs are also used in sales and payout reports.

For one-time products and rewarded products, every purchase creates a new token and a new order ID.

For subscriptions, an initial purchase creates a purchase token and an order ID. For each continuous billing period, the purchase token stays the same and a new order ID is issued. Upgrades, downgrades, and re-signups all create new purchase tokens and order IDs.

Source: https://developer.android.com/google/play/billing/billing_overview

iOS
use the originalTransactionId as transactionId. It does not change when user subscription renews.

https://forums.developer.apple.com/thread/9920
https://developer.apple.com/documentation/appstorereceipts/original_transaction_id

Now that is done, you can now update/insert the following structure in your database:

transactionId,
userId
cancellationDateMs
,
expirationDateMs,
disabled, (this will be true if this subscription is disabled by OUR backend)
platform
, (either android or ios)
receipt (ios=base64 string, android=the object structure we defined above),
rawData (this is data you get back when you verify receipts — just store it for debugging, and for future)

In some cases, when you update an existing record, the userId might change for the same transactionId, this can happen when user restores purchases under a different account, and my recommendation is just to transfer the ownership of the purchase(s) to new account.

If you do not have userId when you are updating the table, just abandon it silently. It can happen when Google or Apple send server notification first before you have had chance to process the front-end user transaction.

Essentially, you should update ALL the fields on subscription table when processing a receipt (if it happens to already exist that is)

If you manage to store this information, this will be enough to have clear picture who is active subscriber and who is not.

How do we know who is an active subscriber?

You check for the following conditions.
- cancellationDateMs is NULL and
- expirationDateMs is in the future and
- disabled is NOT true.

You should check this information very frequently as the state could change. For example, in the app, you can poll for your server every 10 seconds and ask if an user is subscribed or not.

Server hooks and receipt refreshing
It is required that you react to server notifications that come from Google and Apple, essentially refreshing a receipt.

Android

receipt = {                   
packageName: dn.packageName,
productId: dn.subscriptionNotification.subscriptionId, purchaseToken: dn.subscriptionNotification.purchaseToken, subscription: true
};

await processPurchaseReceipt({
receipt: receipt,
userId: null
});

IOS

if (req.body) { 
if (req.body.latest_receipt) { receipt = req.body.latest_receipt; } if (req.body.latest_expired_receipt) { receipt = req.body.latest_expired_receipt; }
}
await processPurchaseReceipt({
receipt: receipt,
userId: null
});

Now that your server hooks are done, what remains is to refresh the receipts. Do it every day ONCE. Just refresh all of the receipts you have at midnight. Android says that you should not poll receipts, and I kind of agree — there is no reason to, their system is pretty solid in my experience. You can do the daily refreshing only for IOS receipts. I do them for both platform, because it helps me to sleep better.

Also create a CRON job that runs every hour, and refreshes all the receipts that have [1day in the past ≥ expirationDate ≤1 day in the future]. This ensures that you will catch renewed expirationDate. I suggest for first implementation to disable billing grace period — it just brings extra complexity you don’t need for initial implementation.

It is also important to note that this cron job should run every minute if you are doing sandbox testing and submitting app to be reviewed, as the times are shorter in sandbox. (1 month subscription is actually 5min subscription that renews 6 times — you do not get apple server notification if subscription renews, this is why polling is necessary with correct timings).

In my experience, Apple will renew the receipt one day before it expires, but that means you need to continously poll it every hour. On Android, it the renewal happens quite close to the actual expiration date, and is delivered by the server hook.

Android pausing subscriptions
Note users can pause subscriptions on Android if they want, but this is disabled by default. Our logic should still handle everything well because all it does is essentially manipulate the expirationDate (I think!), but have mercy on your soul if you need to have this enabled.

Android different situations

Now this has happened very infrequently, but some of the interesting cases out in the wild:

  1. Android charges twice — I have two active subscriptions, one on weekly plan, and second on monthly plan, bought by the same user with 3 minute differences. They are both active subscriptions, and both paid. This is definitely incorrect, and one of the orders will have to be refunded. This can’t happen in iOS due to subscription groups. Either way, I am not 100% sure what the correct approach here will be — ideally your backend is smart enough to understand that there is already one subscription going on for this specific user, and maybe show error dialog / refund automatically the previous subscription? This is slippery slope as if done incorrectly can result in mayhem. For now I refund manually such situations, which are very rare anyways.

Android acknowledge transaction purchase

As part of your subscription processing, you will need to acknowledge that you’ve processed the subscription, and you need to let Google servers know that you have given user the premium features by calling ack() on the server side ( https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products/acknowledge )

This is best done on the server side

** If you don’t do this, all your transactions will be automatically refunded after 3 days!!!

Android developerPayload

Android has cool functionality where you can attach metadata to the purchase your front-end is making. You can attach currently logged in userId to it as well just in case.

The metadata can be later accessed through .developerPayload field

Android linkedPurchaseToken

On Android, you need to implement linkedPurchaseToken correctly to avoid duplicate subscriptions and fraud. Here is the necessary information: https://medium.com/androiddevelopers/implementing-linkedpurchasetoken-correctly-to-prevent-duplicate-subscriptions-82dfbf7167da

In our case, if you encounter linkedPurchaseToken while processing a receipt for Android, just mark an existing transaction with transactionId=linkedPurchaseToken as disabled, and you will be good to go.

Sometimes Android and IOS fails to process receipts
It is very uncommon but from time to time, Android/IOS fails to process receipt for whatever reason, returning “internal server error” from their infrastructure, leaving the user in limbo. In this case, I implemented a receipt processing queue. If receipt processing fails for whatever reason (be it network connection, apple fault, your own fault), you should store the receipt in some kind of table, and try to re-process it, a long with userId if you have one.

In my case, a cron job that runs every minute will check if there is anything in the queue table that has retriesLeft > 0 and then tries to re-process the receipt. I monitor that table from time to time to see if anything has retriesLeft==0 that has not been processed. Could indicate some kind of bug, and I have easy way to recover that receipt in the future if I do fix a bug. (Just set retriesLeft=1 and it will be re-processed)

Prices of your products / financial data for future

Do not change the prices of your existing products, it’s hard to make any sense of your financial data at later stage, on iOS. If you must change price of your product, create new products with new price.

The reason is, iOS does not expose pricing information, there is no “charged amount” on transaction, so you don’t really know what is going on. Now there are ways to try and figure out the financial data, but you should not change the price of products, or you will have very hard time figuring out what is going on (if not impossible time).

Note that since there is no pricing information on the transactions, technically speaking it is not possible to say for sure how much revenue you have generated looking at transactions. Only Apple knows that. However, if you have not changed price of your products, we can calculate quite good estimates that are decent to have big picture that reflects reality (Although I wouldn’t use it for tax reporting for goverment).

You should store the exact information you are storing in Subscription table, but duplicate it and use different primary key in order to “gather more relevant information”. You need to collect a little more information so you can calculate financial data in the future.

Call the table SubscriptionPurchaseHistory, and use the following for primary key Id:

For Android: use orderId
For iOS: use [purchaseData.productId]-[purchaseData.expirationDate]-[purchaseData.originalTransactionId]-[purchaseData.originalPurchaseDate]

Along with the same information you store in Subscription, store the current price of the subscription as well, this you have to do statically on your server and map productId to the current price.

This is essentially trying to create the “actual” payment transactions that have
happened.

For Android; sum up everything using rawData.paymentState=1 (payment successful). You can use rawData.priceAmountMicros to get the real amount

For iOS; sum up everything where cancellationDate does not exist

Backup your subscriptions

I urge you to take backups of your Subscription table (your database should be backed up anyways), but it doesn’t hurt to spend 10 minutes of your life to back up the table Subscriptions and call it SubscriptionsBackup or whatever.

Refunds

It is possible that user has requested refund, in which case you will get server hook notification.

In case of Android, expirationDateMs will be adjusted to be in the past, so you don’t need to do anything special. You can also test this scenario in sandbox.

In case of IOS, cancellationDateMs will be set (it does not matter to what it will be set, if it exists, the user has been refunded — no need to do any date checks, but if you want to be consistent, it does not hurt to do date checks on it as well). You can’t test this scenario on sandbox. Once your app is live, you should test this scenario yourself, and see if the logic is working correctly. Go to reportaproblem.apple.com and request a refund.

Also, don’t be mislead by the name, “cancellationDate” could be better named as “refundedDate”, it will not be set when user manually cancels the subscription — that is normal behaviour. The only way cancellationDate will be set is when user goes through apple and requests refund, in which case his subscription should not be active anymore even if expirationDate is in future.

Refunds in production

If your customer wants to have refund, there are different ways for diff platforms.

iOS
1) they can go to http://reportaproblem.apple.com although be warned that not everyone will get refund, even if law says that if you are in EU then you can get refund in 14 days. It is up to apple to decide if to refund the user or not. If it is users first time requesting refund, they should get it.
2) If that does not work, user can escalate it further and contact customer support, it is also possible to get refund from there if you request senior person https://getsupport.apple.com/?caller=home&PRKEYS= be warned that it can take 1–2 hours on a phone.
3) Some users have gotten their money back by calling their banks and voiding the transactions but be warned, Apple might ban users credit card for too many voided transactions.

Android
1) Every user can request refund in 48 hour timeframe after subscription starts. After the time expires, the user has to contact google in which case it is up to google to see if they refund or not.
2) As an admin, you ALSO have option to refund if you choose to do so whenever you want.

Testing

You should ensure that the automatic renewal is working in IOS and Android in sandbox environment. If you manage to get this right, in my experience, everything else will fall into place itself.

Of course, you should think about different situations and test them all if possible.

From top of my head, here are different things that could happen:

  1. LinkedPurchaseToken abuse https://stackoverflow.com/questions/51808268/google-renewable-subscriptions-abuse
  2. What happens if user upgrades/downgrades/crossgrades a subscription, are you handling it correctly? Do the expirationDate change correctly? https://www.revenuecat.com/2018/06/28/ios-subscription-groups-explained
  3. Have you tested “Restore purchases” logic? Your app should have “restore purchases” button in it. You should subscribe, and then try to do restore purchases under different account. Does the userId change correctly and ownership changes? You need to be careful what you give to the user if the userId changes. There are situations where you want to credit the account with features when you pay. For example, take Tinder. They have SuperLikes — if you become premium member, you get 5 super likes. You should not get 5 super likes if you log out and log to different account, and do restore purchases.
    In fact, it looks like Bumble and Tinder no longer have “Restore purchases” buttons. They have sneakily removed them, and now you need to contact customer support?
  4. What happens if you use multiple different accounts in your app but you have already bought the subscription and you re-try to buy the subscription?
  5. Triple check that your prices and every productId is working as expected
  6. In Android you can go to admin panel and cancel and refund test orders, see what your system does.
  7. StoreKit credit card expired flow — have I mentioned you that if user buys a product, then the transaction queue will have “payment failed” transaction on it, and user will see native payment expired dialog where he will fill new card details, the queue will have payment successful transaction, but the client doesn’t get premium features? Super fun https://forums.developer.apple.com/thread/6431
    It is not possible to test credit card expiration in sandbox, but it MIGHT be possible to simulate the exact same scenario, but with “parental permissions” — create IAP product that requires parental permissions, and make sure your account is “child” or “limited” — it should have similar flow like expired credit card. Although I have not tested it, but worth a shot. Note that it is important to test it at least in production because this has been a problem for some people — it can lead easily to situation where your customer pays, but does not get credited with features.
  8. What happens if your customer says that they bought something but your server has no record of it whatsoever? That’s why I suggest you to log everything on the client side (verbose) including receipt, as then it is easier to connect customer to receipt, and manually add the receipt to backend. For Android, the customer can give you order iD and you can manually “create” subscription in your backend.

On IOS, once you get through app review stage, you can also test the app in production environment without actually releasing it. You can do so using promo codes. You are going to spend real money there.

If there ever is time to test, this is it. It is going to be real money, and if you get something wrong, you will take massive hit with your app ratings, and financially as well.

Make sure you test everything, and you feel very confident that your solution is going to hold up in the production.

Also think about the strategies that can help you diagnose if something has gone wrong. I do massive amount of logging (verbose/error) when processing anything related to receipts, so it will be easy to see what has gone wrong if anything, and easy to recover from it. This includes front-end and back-end. Did user click on “buy 1 monthly subscription”? Log that as verbose along with user id! You want to have as many details as possible.

Good sources to read

https://www.revenuecat.com/2019/07/16/altconf-subscriptions-are-hard RevenueCat blog is awesome (read everything!), they explain IOS details and for sure you learn new things.

https://medium.com/wolox-driving-innovation/3b361da0f038 Some information how to implement the front end side of things.

https://medium.com/@AlexFaunt/auto-renewing-subscriptions-for-ios-apps-8d12b700a98f very good starting point to get started with auto renewing subscriptions and what is wrong on IOS.

https://developer.android.com/google/play/billing/billing_overview read everything under google play billing.

https://developers.google.com/android-publisher/api-ref/purchases/subscriptions become friends with google play developer api reference

https://developer.apple.com/documentation/storekit/in-app_purchase read everything here that looks remotely useful

https://developer.apple.com/documentation/appstorereceipts some information about receipts

https://blog.apphud.com/app-store-approval/ — getting app store approval (Read everything by apphud blog!)

https://blog.apphud.com/subscriptions-notifications/ — explanation of ioS notifications

https://developer.apple.com/library/archive/technotes/tn2387/_index.html#//apple_ref/doc/uid/DTS40014795 — they have deprecated the information, but it is still valid and useful. I recommend you to go through this article AND stuff under “Additional resources”

https://developer.apple.com/library/archive/technotes/tn2413/_index.html#//apple_ref/doc/uid/DTS40016228

Godspeed

--

--