Android in-app purchases, part 5: server-side purchase validation
Why validate purchases
It should be noted that server-side validation isn’t mandatory — in-app purchases will still work without it. There are some significant benefits to it, though:
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-side 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 payment issues, and so on.
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.
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.
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-side purchase processing.
We can summarize Android payment validation with this scheme:
Authentication for Google Play Developer API requests
To work with Google Play Developer API, you’ll first need to generate a key to sign requests. First, you’ll have to link your Google Play Console account (where you manage your app) to your Google Cloud account (where you’ll generate a key for request signing). Once everything’s configured, you’ll have to grant the user purchase management rights. It’d take a dedicated article to describe this process. Luckily, we’ve already covered it in a step-by-step guide found in Adapty documentation.
Note that usually, you’ll have to wait for 24 hours or more after generating a key for it to start working. To avoid that, just update the description for any in-app product or subscription, which will instantly activate the key.
We use the official google-api-python-client library to work with Google Play Developer API. This library is available for the majority of popular languages, and I recommend using it as it supports all the methods you might need.
Validation for subscription transactions
Unlike iOS server-side validation, in Android, both subscription and other product validation are implemented using a variety of methods. Therefore, when validating a transaction, you need to know whether you’re dealing with a product or a subscription. In practice, this means you’ll need to transfer this data from the mobile app, as well as keep the flag in the database in case token re-validation will be necessary.
The second important difference is that while each transaction has its own token in Android, all iOS transactions use an app-specific shared secret to store the entire transaction history. This means that if you want to be able to restore the user’s purchases at any moment, you’ll need to store all purchase tokens, as opposed to picking out an arbitrary single one.
subscriptionId: Subscription identifier for the subscription that’s to be validated, e.g., com.adapty.sample_app.weekly_sub.
token: Unique transaction token. It appears once the purchase is processed on the mobile app side.
First, let’s look at the error messages you need to take care of to make sure everything works as intended:
400, Invalid grant: account not found: This error message means that the request authentication key was generated incorrectly. Make sure that your accounts are linked, you’re using the right one that has enough permissions, and all the required APIs are activated. See the section below for a guide on how to configure everything. Note the tip on product description update.
400, The purchase token does not match the package name: This error message is usually encountered in fraudulent transactions. If you see it while testing, make sure you aren’t using an app purchase token that belongs to a different app.
403, Quota exceeded for quota metric 'Queries' and limit 'Queries per day' of service 'androidpublisher.googleapis.com': This means the Google API requests daily quota was exceeded. By default, you can execute up to 200,000 requests per day. This quota can be increased, but it should suffice for most apps. If you come up against this limit, you should probably double-check your app’s logic and make sure everything’s right.
410, The subscription purchase is no longer available for query because it has been expired for too long: This error message appears in transactions where the subscription has expired more than 60 days ago. It’s not an actual error message and shouldn’t be processed as such.
If the validation was successful, you’ll receive transaction data as your response.
To understand whether the user can access the premium options offered by the app — that is, whether they have an active subscription — you need to:
Check the startTimeMillis and expiryTimeMillis parameters. The current time should be between these.
What’s more, you have to make sure the paymentState parameter doesn’t have the ‘1’ value. This would mean the subscription purchase’s still pending, therefore, there’s no need to grant the user premium function access just yet.
If the transaction has the autoResumeTimeMillis property, then the subscription is paused. This means that the user shouldn’t be granted premium function access before the specified date.
Let’s see the key properties of a subscription transaction:
kind: Transaction type. For subscription, it always has the androidpublisher#subscriptionPurchase value. With this parameter, you can understand whether you’re dealing with a subscription or a product, and choose the processing logic accordingly.
paymentState: Payment status. This property isn’t present for expired transactions. The possible values are: 0: This purchase hasn’t been processed yet. In some countries, the user can pay for the subscription onsite. That is, the user will initiate the subscription purchase from their device and pay for it at a nearby terminal. Overall, it’s a quite rare case, but it should still be kept in mind. 1: Subscription was purchased. 2: Subscription is in the trial period. 3: Subscription will be up- or downgraded in the next period. This means the subscription plan is to change.
acknowledgementState: Purchase confirmation status. It’s an important parameter that acknowledges whether the user has received the access to what they paid for. The ‘0’ value means they haven’t, and ‘1’ means they have. The developer is responsible for defining this status, which can be done on both the mobile app and the server sides. If you don’t acknowledge the purchase within 3 days after it’s been made, it’ll be automatically be refunded. I recommend implementing this logic: once you receive a transaction containing acknowledgementState=0, the parameter gets changed by the server. I’ll discuss how to do that below.
orderId: Unique transaction identifier. Each subscription purchase or renewal will have its own identifier, which can be used to learn whether this transaction has already been processed earlier. Each renewal identifier will have a constant first half, to which two dots and the subscription renewal count (which starts with 0) are appended. If the subscription had the GPA.3382-9215-9042-70164 identifier when activated, then the first renewal will be identified by GPA.3382-9215-9042-70164..0, the second one by GPA.3382-9215-9042-70164..1, etc. This way, you can build transaction chains and keep track of renewal count.
startTimeMillis: Subscription start date.
expiryTimeMillis: Subscription expiration date.
autoRenewing: Flag showing whether or not the subscription is to be renewed into the next period.
priceCurrencyCode: Purchase currency in a three-letter format, e.g., USD.
priceAmountMicros: Purchase price. To get the normal price value, divide this value by 1000000. That is, 1990000 actually means 1,99.
countryCode: Purchase country in two-letter format, e.g., US.
purchaseType: Purchase type. This key won’t be present in most cases. It’s still important to account for, because it helps you understand whether the purchase was made in Sandbox environment. The possible values are: 0: The purchase was made in Sandbox environment, therefore, it shouldn’t be included in the analytics data. 1: The purchase was made with a promo code.
autoResumeTimeMillis: Subscription renewal date. It’s only present for subscriptions that have previously been paused. If this parameter is present, you don’t need to grant the user premium function access before the given date.
cancelReason: The reason why the subscription won’t be renewed. The possible values are: 0: The user canceled the subscription auto-renewal. 1: Subscription was canceled by the system. This is most often caused by a billing issue. 2: The user switched to a different subscription plan. 3: The developer canceled the subscription.
userCancellationTimeMillis: Subscription renewal cancellation data. It’s only present if cancelReason is 0. The subscription can still be active — to make sure, see the expiryTimeMillis parameter’s value.
cancelSurveyResult: Object that stores the reason behind subscription cancellation, which will be present if the user has left any feedback on this matter.
introductoryPriceInfo: Object that stores introductory price data. For example, this can be a special offer of 1 month with 50% off.
promotionType: Promo code type that was used to activate the subscription. The possible values are: 0: One-time promo code. 1: Custom promo code that can be applied by multiple customers. Such codes are usually used for blogger partnerships.
promotionCode: Custom promo code that was used to activate the subscription. This parameter isn’t present for one-time promo codes.
priceChange: Object that stores future price change data, as well as whether the user has agreed to it.
As it was already mentioned above, in case the subscription isn’t acknowledged within 3 days after the purchase is made, it’ll get canceled and refunded automatically. To be honest, I don’t really understand the logic behind it, and I’ve never encountered it in any other payment processing systems, the iOS one included. Still, if you receive a transaction containing acknowledgementState=0, you are to acknowledge the subscription.
The parameters are the same as for request validation. If the request is successfully executed, the subscription will get acknowledged, which means you won’t lose your money.
If the subscription hasn’t been fully purchased yet, there’s no need to acknowledge it.
Subscription renewal cancellation, revocation, refund, and deferral
Apart from subscription validation and acknowledgement, Google Play Developer API can also be used for other subscription operations. It should be noted that these are quite rare and, with the exception of renewal, are supported by Google Play Console. I’ll still list them to give you a general understanding of API solutions scope. All these requests have the same parameters required as the previously mentioned methods, namely, packageName, subscriptionId, and token.
Renewal cancellation. The purchases.subscriptions.cancel method. It cancels auto-renewal for the selected subscription. However, the subscription will still be available throughout the current billing period.
Subscription refund. The purchases.subscriptions.refund method. It refunds the subscription. However, the user will still retain subscription access, and it will be automatically renewed in the next period. In most cases, you should also revoke the subscription when issuing a refund.
Subscription revocation. The purchases.subscriptions.revoke method. It immediately revokes the subscription, which means the user will be unable to access the premium functions. The subscription won’t be renewed. This method is usually invoked along with issuing a refund.
Subscription purchase deferral. The purchases.subscriptions.defer method. It extends the subscription up to the specified date. In the request, specify the subscription expiry date, as well as the date you want to replace it with. The latter must result in a longer subscription period than the former.
Product (not subscription) validation
Product validation is similar to subscription validation. You need to invoke the purchases.products.get method to execute the GET request.
Product transactions include much fewer properties than subscription transactions. Let’s take a look at some important ones:
kind: Transaction type. In products, it always has the androidpublisher#productPurchase value. With this parameter, you can understand whether you’re dealing with a subscription or a product, and choose the processing logic accordingly.
purchaseState: Payment status. Note that the key values here are different from the paymentState parameter for subscriptions. The possible values are: 0: Purchase was completed. 1: Purchase was canceled. This means that the purchase was pending, but the user never paid for it. 2: Purchase is pending. In some countries, the user can pay for the subscription onsite. That is, the user will initiate the subscription purchase from their device and pay for it at a nearby terminal. Overall, it’s a quite rare case, but it should still be kept in mind.
acknowledgementState: Purchase confirmation status. It’s an important parameter that acknowledges whether the user has received the access to what they paid for. The ‘0’ value means they haven’t, and ‘1’ means they have. The developer is responsible for defining this status, which can be done on both the mobile app and the server sides. If you don’t acknowledge the purchase within 3 days after it’s been made, it’ll automatically be refunded. I recommend implementing this logic: once you receive a transaction containing acknowledgementState=0, the parameter gets changed by the server. I’ll discuss how to do that below.
consumptionState: Product consumption status. That’s what iOS calls a ‘consumable’. It’s defined on the mobile app side. If it has the ‘0’ value, it means the product wasn’t consumed; if it’s ‘1’, then it was. If you’re selling lifetime access to your app or some specific premium feature, then such a product shouldn’t be consumed, that is, they should have the 0 status. If you’re selling coins the user can buy time and time again, such products should be consumed, that is, they should have the 1 status. consumptionState=0 means the product can only be purchased once, whereas consumptionState=1 means it can be purchased many times.
orderId: Unique transaction identifier. Each subscription purchase or renewal will have its own identifier, which can be used to learn whether this transaction has already been processed earlier.
purchaseTimeMillis: Purchase date.
regionCode: Purchase country in two-letter format, e.g., US. Note that this parameter’s name is different from the one in subscriptions, where it’s named countryCode.
purchaseType: Purchase type. This key won’t be present in most cases. It’s still important to account for, because it helps you understand whether the purchase was made in Sandbox environment. The possible values are: 0: The purchase was made in Sandbox environment, therefore, it shouldn’t be included in the analytics data. 1: The purchase was made with a promo code. 2: The purchase was granted for a target action, e.g., watching an in-app ad in place of payment.
As you can see, product validation is quite similar to subscription validation. There are some points to take note of, though:
The price doesn’t get returned, although that would be quite handy for analytics.
The purchaseState parameter’s values are significantly different from the values of the paymentState parameter found in subscriptions. It will cause bugs if not accounted for.
regionCode gets returned, even though it’s named countryCode for subscriptions.
Just as subscription purchases, product purchases need to be acknowledged. To do so, invoke the purchases.products.acknowledge method which will execute a POST request
If the purchase hasn’t yet been completed, there’s no need to acknowledge it.
Refund tracking for subscriptions and products
High-quality analytics is impossible without accounting for refunds. Unfortunately, refund data is neither present in the transaction nor gets prompted as a separate event, as it works in iOS. To receive the list of refunded transactions, you’ll need to invoke the purchases.voidedpurchases.list on a regular basis — for example, once per day. This method will execute a GET request:
In response to the request, you’ll receive the list of all refunded transactions. I recommend searching for transactions in the database by the orderId parameter, as opposed to the purchaseToken one. First, this will take less time. Second, all subscription renewals will share the same token, and you’ll just need to fetch the most recent one.
Server notifications for transactions
Server notifications (Real-time developer notifications) help you learn of events that occurred on Google’s side, on your server, and almost live. Once these are configured, you’ll be notified about new purchases, renewals, payment issues, etc. This can help you collect better analytics, as well as make subscriber status management much easier.
To start receiving server notifications, you need to create a Google Cloud Pub/Sub topic, which will send notifications to your desired address. This topic should then be indicated in the Monetization setup section of the Google Play Console. For a detailed guide with screenshots included, see Adapty docs.
We’re mostly concerned with the data key that contains transaction data encoded with base64. The messageId key can be used for message deduplication, so that you don’t need to process duplicate messages.
The packageName key helps you understand which app this event belongs to. The subscriptionId key tells you which subscription is involved, and purchaseToken helps you find the specific transaction. With subscriptions, you’ll always be looking for the last transaction in the renewal chain, as it’s the one this event will belong to. The notificationType key contains the event type. In my opinion, these are the most handy ones for subscriptions:
(2) SUBSCRIPTION_RENEWED: The subscription has successfully been renewed.
(3) SUBSCRIPTION_CANCELED: The user has disabled subscription auto-renewal. If auto-renewal is disabled, you’ll need to try to get the user back as an active subscriber.
(5) SUBSCRIPTION_ON_HOLD, (6) SUBSCRIPTION_IN_GRACE_PERIOD: The subscription couldn’t have been renewed because of payment issues. You should notify the user about it so that their subscription doesn’t get canceled automatically.
(12) SUBSCRIPTION_REVOKED: the subscription has been revoked. This means the user should lose access to the premium functions previously granted by the subscription.
In products (not subscriptions), you’ll receive oneTimeProductNotification in place of the subscriptionNotification key. It will also contain the sku key instead of the subscriptionId key. Moreover, you’ll only ever receive 2 types of events for products:
(2) ONE_TIME_PRODUCT_CANCELED: Product purchase was canceled, as the user hadn’t paid for it.
Server-side validation supercharges the analytics you’ll be able to collect for your app. It makes it harder for fraudsters to access the premium content and can be used to implement cross-platform subscriptions. However, server-side validation can take quite a while to implement, especially if high data accuracy is a must. To provide high-quality data, you’d need to account for a multitude of side cases, such as subscription upgrade, subscription crossgrade, trial periods, promo and introductory offers, grace period, refunds, etc. You’d also have to know of and account for all the policy minutiae, such as Google only charging a 15% (as opposed to 30%) commission on subscriptions that get renewed for more than a year.