How to deploy a server to test In-app purchases in 60 minutes


Today I'll show you how to deploy a server to test In-app Purchase and In-app Subscription on iOS and Android (server-server validation).

There's an article on Habr from 2013 about server-based purchase testing. The article says that testing is primarily needed to prevent access to paid content through jailbreak and other software. In my opinion, this problem is not so relevant in 2020, and a server with purchase testing is necessary for the synchronization of purchases within one account on several devices.

There is no technical difficulty in checking purchase receipts, in fact, the server simply "proxies" the request and saves the purchase data.

So, the work of such a server can be divided into 4 stages:

  • Receiving a request with the receipt sent by the app after the purchase
  • Requesting Apple/Google to check the receipt
  • Saving the transaction data
  • Responding to the app

We will skip the 3rd point in this article, because it is strictly individual.

The code in this article will be written in Node.js, but in fact, the logic is universal and you can use it to write a validation in any programming language.

«What you need to know about testing App Store receipts», is another great article from people who are creating a service to work with subscriptions. The article describes in detail what a receipt is and why you need to test purchases.

Let me tell you right away that the snippets of code use auxiliary classes and interfaces, all the code is available in the repository at https://github.com/denjoygroup/inapppurchase. In the code snippet below, I tried to name the used methods in such a way as to make references to these functions

iOS

For testing, you need Apple Shared Secret, which is a key you have to get from iTunes Connect in order to check your receipts.

st of all, let's set the parameters to create queries:

 apple: any = {
    password: process.env.APPLE_SHARED_SECRET, // key, enter yours
    host: 'buy.itunes.apple.com',
    sandbox: 'sandbox.itunes.apple.com',
    path: '/verifyReceipt',
    apiHost: 'api.appstoreconnect.apple.com',
    pathToCheckSales: '/v1/salesReports'
 }
 

Now let's create a function to send a request. Depending on the environment you're working in, you should send the request either to the test environment sandbox.itunes.apple.com to test purchases, or to the production environment buy.itunes.apple.com.

/**
* receiptValue - the receipt you're checking
* sandBox - development environment
**/
async _verifyReceipt(receiptValue: string, sandBox: boolean) {
    let options = {
        host: sandBox ? this._constants.apple.sandbox : this._constants.apple.host,
        path: this._constants.apple.path,
        method: 'POST'
    };
    let body = {
        'receipt-data': receiptValue,
        'password': this._constants.apple.password
    };
    let result = null;
    let stringResult = await this._handlerService.sendHttp(options, body, 'https');
    result = JSON.parse(stringResult);
    return result;
}

If the request is successful, then you will receive your purchase data in the status field of the Apple server response.

There are several possible statuses, according to which you have to process the purchase

21000 – The request to the App Store was not made using the HTTP POST request method.

21002 – The data in the receipt-data property was malformed or the service experienced a temporary issue. Try again.

21003 – Incorrect receipt. The purchase has not been validated.

21004 – The Shared Secret you provided does not match the shared secret on your receipt.

21005 – The Apple server was temporarily unable to provide the receipt. Try again

21006 – This receipt is invalid.

21007 – This receipt is from the test environment, but it was sent to the production environment for verification.

21008 – This receipt is from the production environment, but it was sent to the test environment for verification.

21009 – The Apple server was temporarily unable to provide the receipt. Try again

21010 – The user account cannot be found or has been deleted.

0 – The purchase is valid

Here is an example of the response from iTunes Connect

{
    "environment":"Production",
    "receipt":{
        "receipt_type":"Production",
        "adam_id":1527458047,
        "app_item_id":1527458047,
        "bundle_id":"BUNDLE_ID",
        "application_version":"0",
        "download_id":34089715299389,
        "version_external_identifier":838212484,
        "receipt_creation_date":"2020-11-03 20:47:54 Etc/GMT",
        "receipt_creation_date_ms":"1604436474000",
        "receipt_creation_date_pst":"2020-11-03 12:47:54 America/Los_Angeles",
        "request_date":"2020-11-03 20:48:01 Etc/GMT",
        "request_date_ms":"1604436481804",
        "request_date_pst":"2020-11-03 12:48:01 America/Los_Angeles",
        "original_purchase_date":"2020-10-26 19:24:19 Etc/GMT",
        "original_purchase_date_ms":"1603740259000",
        "original_purchase_date_pst":"2020-10-26 12:24:19 America/Los_Angeles",
        "original_application_version":"0",
        "in_app":[
            {
                "quantity":"1",
                "product_id":"PRODUCT_ID",
                "transaction_id":"140000855642848",
                "original_transaction_id":"140000855642848",
                "purchase_date":"2020-11-03 20:47:53 Etc/GMT",
                "purchase_date_ms":"1604436473000",
                "purchase_date_pst":"2020-11-03 12:47:53 America/Los_Angeles",
                "original_purchase_date":"2020-11-03 20:47:54 Etc/GMT",
                "original_purchase_date_ms":"1604436474000",
                "original_purchase_date_pst":"2020-11-03 12:47:54 America/Los_Angeles",
                "expires_date":"2020-12-03 20:47:53 Etc/GMT",
                "expires_date_ms":"1607028473000",
                "expires_date_pst":"2020-12-03 12:47:53 America/Los_Angeles",
                "web_order_line_item_id":"140000337829668",
                "is_trial_period":"false",
                "is_in_intro_offer_period":"false"
            }
        ]
    },
    "latest_receipt_info":[
        {
            "quantity":"1",
            "product_id":"PRODUCT_ID",
            "transaction_id":"140000855642848",
            "original_transaction_id":"140000855642848",
            "purchase_date":"2020-11-03 20:47:53 Etc/GMT",
            "purchase_date_ms":"1604436473000",
            "purchase_date_pst":"2020-11-03 12:47:53 America/Los_Angeles",
            "original_purchase_date":"2020-11-03 20:47:54 Etc/GMT",
            "original_purchase_date_ms":"1604436474000",
            "original_purchase_date_pst":"2020-11-03 12:47:54 America/Los_Angeles",
            "expires_date":"2020-12-03 20:47:53 Etc/GMT",
            "expires_date_ms":"1607028473000",
            "expires_date_pst":"2020-12-03 12:47:53 America/Los_Angeles",
            "web_order_line_item_id":"140000447829668",
            "is_trial_period":"false",
            "is_in_intro_offer_period":"false",
            "subscription_group_identifier":"20675121"
        }
    ],
    "latest_receipt":"RECEIPT",
    "pending_renewal_info":[
        {
            "auto_renew_product_id":"PRODUCT_ID",
            "original_transaction_id":"140000855642848",
            "product_id":"PRODUCT_ID",
            "auto_renew_status":"1"
        }
    ],
    "status":0
}

Also, before sending a request and after it, remember to compare the id of the product that the client asks for and the one we get in the response.

The information we need is contained in the in_app and latest_receipt_info properties. At first glance the contents of these properties are identical, but:

latest_receipt_info contains all purchases.

in_app Non-consumable and Non-Auto-Renewable purchases.

We will be using latest_receipt_info, therefore in this array we search for the required product by its product_id property and check the date, if it is a subscription. Of course it's also worth checking if the user has already been credited with the purchase, especially for Consumable Purchases. You can check it by the original_transaction_id, property, saving it in the database beforehand, but we won't do it in this guideline.

/**
* product - purchase id
* resultFromApple - the response from Apple
* productType - type of purchase (subscription, consumable or non-consumable)
* sandBox - test environment or not
*
**/
async parseResponse(product: string, resultFromApple: any, productType: ProductType, sandBox: boolean) {
    let parsedResult: IPurchaseParsedResultFromProvider = {
        validated: false,
        trial: false,
        checked: false,
        sandBox,
        productType: productType,
        lastResponseFromProvider: JSON.stringify(resultFromApple)
    };
    switch (resultFromApple.status) {
        /**
        * Valid
        */
        case 0: {
            let currentPurchaseFromApple = this.getCurrentPurchaseFromAppleResult(resultFromApple, product!, productType);
            if (!currentPurchaseFromApple) break;

            parsedResult.checked = true;
            parsedResult.originalTransactionId = this.getTransactionIdFromAppleResponse(currentPurchaseFromApple);
            if (productType === ProductType.Subscription) {
                parsedResult.validated = (this.checkDateIsAfter(currentPurchaseFromApple.expires_date_ms)) ? true : false;
                parsedResult.expiredAt = (this.checkDateIsValid(currentPurchaseFromApple.expires_date_ms)) ?
                this.formatDate(currentPurchaseFromApple.expires_date_ms) : undefined;
            } else {
                parsedResult.validated = true;
            }
            parsedResult.trial = !!currentPurchaseFromApple.is_trial_period;
            break;
        }
        default:
            if (!resultFromApple) console.log('empty result from apple');
            else console.log('incorrect result from apple, status:', resultFromApple.status);
    }
    return parsedResult;
}

After that we can return the response to the client on our purchase, which is stored in the parsedResult variable. You can form the structure of this object as you like, depending on your needs, but the most important thing is that at this step we already know if the purchase is valid or not, and information about it is stored in parsedResult.validated.

If you're interested, I can write a separate article on how to handle iTunes Connect response for each property, because this is far from a trivial task. Also you may be interested in how to work with auto-renewable purchases, when to check them and how, because it is not enough to run the cron by the subscription expiry time - there will definitely be problems and the user will be left without the paid purchase, and in this case there will be one-star reviews in mobile app stores at once.

Android

For Google, the request format is quite different, since you have to first authorize through OAuth OAuth and then send a request for purchase verification.

For Google we need more input parameters:

google: any = {
    host: 'androidpublisher.googleapis.com',
    path: '/androidpublisher/v3/applications',
    email: process.env.GOOGLE_EMAIL,
    key: process.env.GOOGLE_KEY,
    storeName: process.env.GOOGLE_STORE_NAME
}

Okay, Google, take a request:

/**
* product - product name
* token - receipt
* productType – type of purchase, subscription or not
**/
async getPurchaseInfoFromGoogle(product: string, token: string, productType: ProductType) {
    try {
        let options = {
            email: this._constants.google.email,
            key: this._constants.google.key,
            scopes: ['https://www.googleapis.com/auth/androidpublisher'],
        };
        const client = new JWT(options);
        let productOrSubscriptionUrlPart = productType === ProductType.Subscription ? 'subscriptions' : 'products';
        const url = `https://${this._constants.google.host}${this._constants.google.path}/${this._constants.google.storeName}/purchases/${productOrSubscriptionUrlPart}/${product}/tokens/${token}`;
        const res = await client.request({ url });
        return res.data as ResultFromGoogle;
    } catch(e) {
        return e as ErrorFromGoogle;
    }
}

For authorization we’ll use the google-auth-library and the JWT.

Google's response looks something like this:

{
    startTimeMillis: "1603956759767",
    expiryTimeMillis: "1603966728908",
    autoRenewing: false,
    priceCurrencyCode: "USD",
    priceAmountMicros: "499000000",
    countryCode: "US",
    developerPayload: {
        "developerPayload":"",
        "is_free_trial":false,
        "has_introductory_price_trial":false,
        "is_updated":false,
        "accountId":""
    },
    cancelReason: 1,
    orderId: "GPA.3335-9310-7555-53285..5",
    purchaseType: 0,
    acknowledgementState: 1,
    kind: "androidpublisher#subscriptionPurchase"
}

Now let's move on to purchase verification

parseResponse(product: string, result: ResultFromGoogle | ErrorFromGoogle, type: ProductType) {
    let parsedResult: IPurchaseParsedResultFromProvider = {
        validated: false,
        trial: false,
        checked: true,
        sandBox: false,
        productType: type,
        lastResponseFromProvider: JSON.stringify(result),
    };
    if (this.isResultFromGoogle(result)) {
        if (this.isSubscriptionResult(result)) {
            parsedResult.expiredAt = moment(result.expiryTimeMillis, 'x').toDate();
            parsedResult.validated = this.checkDateIsAfter(parsedResult.expiredAt);
        } else if (this.isProductResult(result)) {
            parsedResult.validated = true;
        }
    }
    return parsedResult;
}

Everything here is quite trivial. We also get a parsedResult. The most important thing for us - whether the purchase was validated or not - can be found in the validated property.

Summary

Basically you can check your purchase in just 2 steps. A repository with the full code is available at https://github.com/denjoygroup/inapppurchase (code author is Alexey Gevorkian)

Of course, we skipped a lot of nuances of purchase processing that are worth considering when dealing with real purchases.

These are two useful services for receipt verification: https://adapty.io/ and https://apphud.com/. However, for some categories of applications you can't pass data to third parties, plus if you want to deliver paid content dynamically as the user makes a purchase, you have to deploy your own server.

P.S.

And, of course, the most important thing in server development is scalability and stability. If you have a large audience and your server can't handle the load, it's better not to check for purchases yourself, but send requests to iTunes Connect and Google API, otherwise your users will get very frustrated.