Node.js: Automatic Syncing of iOS Subscription Status

Managing renewals and cancellations with transaction receipts in a Node.js runtime

Image for post
Image for post

Managing subscriptions for iOS apps is critical infrastructure to ensure users have access to the correct content, and equally as important, to ensure access to app content is closed off if that subscription expires or is cancelled. This can be achieved in an automated manner in the form of a Node.js runtime service that will automatically update a subscription status, whether renew or cancellation, from your app database upon every subscription period, whether that be on a monthly, bi-yearly or annual basis.

This issue will be solved in the context of JavaScript utilising a Node.js runtime in this article. A Gist containing the full service logic is included at the end of this article, or alternatively can be viewed here on Github.

Although React Native won’t be discussed in detail, this article assumes that the reader is using a package such as react-native-iap to trigger in-app purchases that in turn generate transaction receipts. The solution discussed here also does not rely on Subscription Webhooks, or server to server notifications as the Apple docs term them. One can solely rely on transaction receipt verification to determine the state of a subscription.

Regardless of whether you are using native Swift APIs or a React Native based API such as react-native-iap to offer in-app purchases, you will still be able to adopt the Node.js based solutions discussed in this piece. The key piece of information is the transaction receipt that’ll be used to fetch subscription state and thus determine the current subscription status.

From here the Node.js runtime will be referred to as the Subscription Manager, given that it essentially acts as the middleware between the subscription state on Apple’s servers and the subscription state on your own servers, providing the means to keep your database in sync with Apple’s.

If the reader would like to start from the beginning of the in-app purchase journey and set up subscription plans in a React Native app, I have published a piece on how do so using react-native-iap. That article can be found here: React Native: Subscriptions with In-App Purchases.

Another recommended piece centres around the process of validating and persisting iOS transaction receipts as in-app purchases are made and securely storing them in your app database. The article can be read here: Validating iOS Subscription Receipts in React Native & Node.js. With transaction receipts readily available, they can be leveraged by the Subscription Manager service discussed in this piece.

Before diving into the code, the following section will discuss the service in more detail by breaking it down into three stages that will effectively automatically renew or cancel a subscription accordingly.

How the Subscription Manager service works

The service is coded as a JavaScript file that should be run with a process manager such as PM2, although one could also simply test with node. The service goes through the following stages:

The script periodically checks (every hour in the demos to follow) which subscriptions have come to the end of their renewal period. This is determined by the expiry timestamp that is derived from a transaction receipt. This timestamp should already be persisted in a user’s account with other metadata pertaining to the subscription state.

My receipt validation article discusses how to derive the expiry timestamp from the initial in-app purchase of the subscription, that can then be persisted to a user account’s metadata. The article uses MongoDB as the database engine.

The service will then verify the original transaction receipt pertaining to each of these subscriptions to check whether they are still active. When we verify a transaction receipt with a service like node-apple-receipt-verify, the latest subscription purchase is returned along with every detail related to the subscription, including timestamps, auto renew status, the subscription plan in question, and so on. Subscription Manager can use this data to determine whether a new transaction has been made since the previous expiry timestamp.

If a subscription is still active, the expiry timestamp of the subscription will be updated to the end of the current period, as reflected in the most recent purchase. If not, the subscription will be cancelled and the user will be reverted to the “free tier” status of your app.

If this workflow sounds blurry now, do not worry — we’ll discuss the concept in depth further down as the code is examined.

What is interesting in this solution is that webhooks are not required to get the Subscription Manager working, as the runtime service relies predominantly on validating transaction receipts to derive the subscription status. Even though webhooks can be useful to get the real-time status of a subscription, they are not 100% reliable — your endpoints could break and miss a cancellation for example. From this perspective, it is valuable to have alternative means of verifying a state of a subscription in place.

Apple made some enhancements to webhooks at WWDC 2020 pertaining to refund notifications, suggesting that they are actively attempting to improve webhook functionality.

Let’s firstly explore the structure of the Subscription Manager runtime and how the service can run on an hourly basis. We’ll then dive into the code in detail and break down the three tasks described above.

Subscription Manager Runtime Setup

We’ll be relying on a couple of dependencies to get this working, namely moment, mongodb and node-apple-receipt-verify to facilitate working with timestamps, the database and receipt validation respectively. Initiate a new project and install these dependencies:

cd project_directory && yarn init
yarn add moment mongodb node-apple-receipt-verify

The structure of a runtime should be one that supports not just one task (e.g. our subscription manager task), but the ability to support multiple tasks in a modular way. With this consideration we will aim to get the manager working by defining it in its own file and with its own exports, and import it into a central init.js file responsible for housing all the services of the runtime. The following file structure mirrors this aim:

// file structure of runtime projectinit.js <- executable file 
iap-manager.js <- subscription manager module
mongo.js
package.json
node_modules/

The logic of the service will be coded within iap-manager.js and imported into init.js, with init.js acting as the runtime executable file. The other file, mongo.js, is simply a utility function that connects to a MongoDB instance.

MongoDB is the database of choice for this piece; it is widely adopted and is well suited for managing app data due to the heavy usage of document-based collections, that conform nicely with heavy reliance on JSON we’ve come to use for many APIs in general.

With only one service to be imported and started, init.js is another light-weight file. Note that only one database instance is being initialised and is passed into each service as an argument. This approach will keep database connections to a minimum as your runtime scales with many services:

// `init.js` initialise db client and start servicesconst mongo = require('./mongo');
const iapManager = require('./iap-manager');
async function init () { // connect to database
const client = await mongo.client();
// start services
iapManager.init(client);
}
init();

As we can see, an init() method belonging to iapManager is called to start the service. The service is run in an indefinite loop, and therefore will only terminate if the runtime terminates. To get the complete picture of this setup, let’s now examine the structure of iap-manager.js:

// `iap-manager.js` file structure// import dependencies
const moment = require('moment');
const appleReceiptVerify = require('node-apple-receipt-verify')
// initialise receipt-verify instance
appleReceiptVerify.config({
secret: process.env.APPLE_APP_STORE_SECRET,
environment: [process.env.APPLE_APP_STORE_ENVIRONMENT],
excludeOldTransactions: true,
ignoreExpired: true,
});
// define service interval to 1 hour
const interval = 60 * 60 * 1000;
module.exports = { // define service loop and initiate service
init: async function (client) {
module.exports.renewOrCancelSubscriptions(client);
setInterval(async function () {
module.exports.renewOrCancelSubscriptions(client);
}, interval)
},
// bulk of logic will be coded in this method
renewOrCancelSubscriptions: async function (client) {
/* The service logic will be coded here */
}
};

Hopefully the above file is relatively self-explanatory, but here are a couple of key points about its setup:

  • Like the database initialisation of init.js, an appleReceiptVerify object has also been initialised outside of the module’s exports, as to not have duplicate instances of the object.
  • Method init() calls the renewOrCancelSubscriptions() service method immediately, and then defines an interval where the service is called every hour indefinitely. Notice that the Mongo client is being passed straight down into renewOrCancelSubscriptions() in keeping with our desire to only use one database connection.

With these files in place, our attention can now be drawn to the bulk of renewOrCancelSubscriptions() where the service logic itself will be defined. This will be the focus of the next section, where the three steps documented at the start of the article will be implemented.

Subscription Manager Service Logic

Referring to what was discussed in the introduction of this piece, the subscription manager must check which subscriptions are past their expiry timestamp relative to your app’s database and the current time. Once those accounts have been fetched we can determine whether their subscription renewed or whether it was cancelled.

Let’s start by fetching those accounts with a MongoDB query. We’ll refer to the expiry timestamp that should have been persisted upon the initial in-app purchase of the subscription, along with linking a user ID or other account identifier with the saved receipt.

This was discussed heavily in my piece of receipt validation, whereby data retrieved from receipt verification can be saved in a range of collections, such as embedding subscription metadata with a user account document, or saving the entire purchase in a standalone collection.

If we imagine a scenario where a subscription is renewed every month, the expiry timestamp of the active subscription will be 30 days from the previous renewal time (or from the initial purchase if a renewal has yet to have taken place). Therefore every month, this expiry timestamp is updated with its corresponding renewal time. It is Subscription Manager’s job to update this expiry timestamp in your own app database. In actuality, this expiry timestamp serves two purposes:

  • To keep subscription status and renewal timestamps in sync with what is on Apple’s servers, and therefore reflect that state in your app.
  • It acts as part of a condition that Subscription Manager utilises to find the subscriptions that need updating, by checking whether expiry timestamp < the current time, indicating the subscription is outdated. As we’ll see further down, the expiry timestamp is either updated to the next renewal period if it is still active or removed entirely from the account record if the subscription has been cancelled, therefore preventing Subscription Manager from continuously re-checking the subscription status.

Remember that renewOrCancelSubscriptions() is called every hour, so everything we do here will be repeated indefinitely as long as the runtime is actively running. The following example assumes that a plan (a term used to represent a subscription) metadata is embedded in the user account collection, where the expiry timestamp is fetched:

// fetching subscriptions that are due a renewal based on expiry timestamprenewOrCancelSubscriptions: async function (client) {
try {
const db = client.db('my-app-db');
// get accounts past renewal date
const accounts = await db
.collection('users')
.find({
'plan.expiryTimestamp': {
$lte: moment().unix()
}

})
.toArray();
if (accounts.length === 0) {
return;
}
... } catch (e) {
console.log(e);
}
}

Note that if there are no accounts to check, the function simply returns and will not perform anything further until the next hour. The entirety of renewOrCancelSubscriptions() has been wrapped in a try-catch block as to limit exceptions to the function itself, and not to effect the entire runtime that could result in a process restart if exceptions are not contained, effecting other services in the process that may be in mid-execution.

The next step is to loop through these accounts and re-verify the receipt to determine the subscription status. node-apple-receipt-verify will fetch the latest purchase made along with the current subscription status.

Let’s firstly validate the receipt of the original in-app purchase:

// for each over-due account
for (let account of accounts) {
// `expiryTimestamp` will be used further down
const expiryTimestamp = account.plan.expiryTimestamp;
// get latest transaction from receipt
const receipt = await db
.collection('iap-receipts')
.find({
user_id: account._id,
})
.sort({ timestamp: -1 })
.toArray();
try {
// get latest subscription receipt
const record = receipt[0];
// re-verify receipt to get the latest subscription status
const purchases = await appleReceiptVerify.validate({
receipt: record.receipt
});
... } catch (e) {
console.log(e);
}
...
}

We are now fetching the original transaction receipt for each account and re-verifying it, that in-turn returns the current status as the purchases constant.

The MongoDB query above attempts to assume as little as possible about how the reader may structure the iap-receipts table. One developer may opt to save all receipts in one collection, whereas another may separate expired receipts from active ones. The above example uses Mongo’s sort() operation to fetch a user’s latest transaction receipt based on its timestamp.

Note here also that we’re wrapping the receipt database record and receipt validation in another try-catch block, as to limit errors to the account in question, and thus errors will not affect subsequent accounts being looped. This block will raise an exception if receipt[0] does not exist, or if the receipt is not successfully verified.

From here it is straight forward to determine whether the subscription is still active — purchases will be empty (an array with a length of zero). Concretely, appleReceiptVerify.validate() ignores expired purchases — and this is key to understanding the logic used here. If the subscription was still active then a purchased product list (documented here) will be returned.

Remember we included the ignoreExpired: true property when initialising appleReceiptVerify, making this conditional check possible. Scroll back up to check that snippet if needed, or refer to the Gist of the full solution.

With all this being considered, the database can be updated to remove any subscription privileges a user may have:

// no active purchases, subscription either expired or cancelledif (purchases.length === 0) {  // change to free tier and remove plan metadata from user record
await db
.collection('users')
.updateOne({
_id: account._id
}, {
$unset: {
'plan.expiryTimestamp': true,
},

$set: {
'plan.planId': 'free'
}

});
}

We are using multiple MongoDB operations within this query to set and unset fields concurrently for the user record in question.

This is all that’s needed for a cancellation logic flow, although the reader can insert more queries and database updates at this stage to reflect the cancelled subscription.

Let’s now turn our attention to the scenario where the subscription is still active, in which case the purchases object will contain purchases, and more importantly, the latest subscription state. Let’s refer to that now and update the database accordingly with the updated expiryTimestamp:

// updating database with latest expiryTimestamp of renewed subscriptionif (purchases.length !== 0) {

// get the latest purchase from receipt verification
const latestPurchase = purchases[0];
// reformat the expiration date as a unix timestamp
let latestExpiryTimestamp = latestPurchase.expirationDate;
let productId = latestPurchase.productId;
latestExpiryTimestamp = Math.round(latestExpiryTimestamp / 1000);
// update renewal date if more than current one
if (latestExpiryTimestamp > expiryTimestamp) {
await db
.collection('users')
.updateOne({
_id: account._id
}, {
$set: {
'plan.planId': productId,
'plan.nextRenewal': latestExpiryTimestamp
}

});
}

Although we have not discussed subscription tier updates, this code also takes such updates into consideration, persisting the latest productId of the active subscription. Beyond this, the subscription renewal timestamp has now been updated in the event a subscription rolled-over to the new renewal period.

And this is all to it — simply run the init.js file with node or with the PM2 process manager and you are good to go:

// starting the service as a node runtime, not forgetting your environment variablesAPPLE_APP_STORE_ENVIRONMENT=production <other_env_variables> pm2 start iap-manager --name 'IAP Manager'

As promised at the beginning of this article, here is the entirety of the service logic in a Github Gist:

In Summary

This article represented a solution for developers to keep their users’ iOS subscription status in sync with the true source of data — being on Apple’s servers. The solution discussed here does not rely on Subscription Webhooks or other means to determine a subscription state other than receipt verification, a secure and trusted way of fetching purchases and subscription state without relying on notifications.

For further reading on integrating in-app purchases in React Native and Node.js check out my other articles that not only add background knowledge to this piece, but can help the reader integrate their own in-app purchases that they can then base the solution of this piece on:

Programmer and Author. Director @ JKRBInvestments.com. Creator of ChineseGrammarReview.app for iOS.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store