IAP receipt validation on app start

Hello,

What is the preferred method to deal with ensuring the user still has the purchase active on app start? Should I save the product receipt to disk and load it up on app start? Should IAPListener fire a purchase event automatically on app start?

You’ll want to go through the product receipts in your that are provided during IAP intialization in your product controller. Please check the Sample IAP project here Sample IAP Project . This may help also Improved Support for Subscription Products

Thanks. The sample iap project does not store receipts on disk, correct? I see messages in my Console when I start the app:
AndroidPlayer(ADB@127.0.0.1:34999) Initializing UnityPurchasing via Codeless IAP
AndroidPlayer(ADB@127.0.0.1:34999) UnityIAP Version: 1.23.3
AndroidPlayer(ADB@127.0.0.1:34999) UnityIAP: Promo interface is available for 1 items

But OnPurchaseComplete of my IAP button or IAP listener are not called on start. Should they be automatically or should I do something on start? Google Play only by the way.

The IAPListener component is attached to an object that is present in the first scene and is not destroyed in scene transitions.

I suggest you use the script IAP to get previous purchases. For non-consumables and subscription products that have been purchased, you can get them in IStoreListener.OnInitialized.

Below is the code from IAP demo scene, you can find it in the IAP package assets: Assets → UnityPurchasing → scenes → IAP Demo.

    /// <summary>
    /// This will be called when Unity IAP has finished initialising.
    /// </summary>
    public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
    {
        m_Controller = controller;
        m_AppleExtensions = extensions.GetExtension<IAppleExtensions>();
        m_SamsungExtensions = extensions.GetExtension<ISamsungAppsExtensions>();
        m_MoolahExtensions = extensions.GetExtension<IMoolahExtension>();
        m_MicrosoftExtensions = extensions.GetExtension<IMicrosoftExtensions>();
        m_TransactionHistoryExtensions = extensions.GetExtension<ITransactionHistoryExtensions>();
        m_GooglePlayStoreExtensions = extensions.GetExtension<IGooglePlayStoreExtensions>();
        // Sample code for expose product sku details for google play store
        // Key is product Id (Sku), value is the skuDetails json string
        //Dictionary<string, string> google_play_store_product_SKUDetails_json = m_GooglePlayStoreExtensions.GetProductJSONDictionary();
        // Sample code for manually finish a transaction (consume a product on GooglePlay store)
        //m_GooglePlayStoreExtensions.FinishAdditionalTransaction(productId, transactionId);
        m_GooglePlayStoreExtensions.SetLogLevel(0); // 0 == debug, info, warning, error. 1 == warning, error only.

        InitUI(controller.products.all);

        // On Apple platforms we need to handle deferred purchases caused by Apple's Ask to Buy feature.
        // On non-Apple platforms this will have no effect; OnDeferred will never be called.
        m_AppleExtensions.RegisterPurchaseDeferredListener(OnDeferred);

#if SUBSCRIPTION_MANAGER
        Dictionary<string, string> introductory_info_dict = m_AppleExtensions.GetIntroductoryPriceDictionary();
#endif
        // Sample code for expose product sku details for apple store
        //Dictionary<string, string> product_details = m_AppleExtensions.GetProductDetails();


        Debug.Log("Available items:");
        foreach (var item in controller.products.all)
        {
            if (item.availableToPurchase)
            {
                Debug.Log(string.Join(" - ",
                    new[]
                    {
                        item.metadata.localizedTitle,
                        item.metadata.localizedDescription,
                        item.metadata.isoCurrencyCode,
                        item.metadata.localizedPrice.ToString(),
                        item.metadata.localizedPriceString,
                        item.transactionID,
                        item.receipt
                    }));
#if INTERCEPT_PROMOTIONAL_PURCHASES
                // Set all these products to be visible in the user's App Store according to Apple's Promotional IAP feature
                // https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/StoreKitGuide/PromotingIn-AppPurchases/PromotingIn-AppPurchases.html
                m_AppleExtensions.SetStorePromotionVisibility(item, AppleStorePromotionVisibility.Show);
#endif

#if SUBSCRIPTION_MANAGER
                // this is the usage of SubscriptionManager class
                if (item.receipt != null) {
                    if (item.definition.type == ProductType.Subscription) {
                        if (checkIfProductIsAvailableForSubscriptionManager(item.receipt)) {
                            string intro_json = (introductory_info_dict == null || !introductory_info_dict.ContainsKey(item.definition.storeSpecificId)) ? null : introductory_info_dict[item.definition.storeSpecificId];
                            SubscriptionManager p = new SubscriptionManager(item, intro_json);
                            SubscriptionInfo info = p.getSubscriptionInfo();
                            Debug.Log("product id is: " + info.getProductId());
                            Debug.Log("purchase date is: " + info.getPurchaseDate());
                            Debug.Log("subscription next billing date is: " + info.getExpireDate());
                            Debug.Log("is subscribed? " + info.isSubscribed().ToString());
                            Debug.Log("is expired? " + info.isExpired().ToString());
                            Debug.Log("is cancelled? " + info.isCancelled());
                            Debug.Log("product is in free trial peroid? " + info.isFreeTrial());
                            Debug.Log("product is auto renewing? " + info.isAutoRenewing());
                            Debug.Log("subscription remaining valid time until next billing date is: " + info.getRemainingTime());
                            Debug.Log("is this product in introductory price period? " + info.isIntroductoryPricePeriod());
                            Debug.Log("the product introductory localized price is: " + info.getIntroductoryPrice());
                            Debug.Log("the product introductory price period is: " + info.getIntroductoryPricePeriod());
                            Debug.Log("the number of product introductory price period cycles is: " + info.getIntroductoryPricePeriodCycles());
                        } else {
                            Debug.Log("This product is not available for SubscriptionManager class, only products that are purchase by 1.19+ SDK can use this class.");
                        }
                    } else {
                        Debug.Log("the product is not a subscription product");
                    }
                } else {
                    Debug.Log("the product should have a valid receipt");
                }
#endif
            }
        }

        // Populate the product menu now that we have Products
        AddProductUIs(m_Controller.products.all);

        LogProductDefinitions();
    }

Thank you. This is helpful. Should HasReceipt be true and product.receipt contain a receipt when using test purchases on Android through Google Play? Currently it is false and receipt is null even though the order is complete at Google Play.

What product type? Can you confirm with a test subscription?

I only have one product and it is a non-consumable (not subscription), are these unsupported?

The receipt is null and HasReceipt is false in OnInitialized, it is not false in the purchase events that fire from button etc.

I need to verify that the user still has the purchase on app start. I can store the receipts but they always validate as purchased even after they are refunded, but this is for another thread I’ve created so I’ll continue on this there: IAP Android, refunded transactions & validation - Unity Services - Unity Discussions

Dug this up trying to find > the preferred method to deal with ensuring the user still has the purchase active on app start.

It would be helpful if the docs listed what the INTENDED way to do this is, for non-consumables. All the samples seem to assume we are using a subscription for full app unlock (and maybe we should be using a subscription?)

I’ll post here if I figure it out.

Do you have access to the product receipt? You would want to check during IAP initialization. Please show your current code. Also, you can look at the Sample IAP Project v2 for an example https://discussions.unity.com/t/700293/3

I haven’t verified this works for a revoked purchase yet (can’t figure out how to do that on the store page yet), but here’s what is working for verifying they have the purchase.

First, I want to be extra clear about what problem I’m solving, for future readers:

  1. We want an in-app purchase to unlock the full game (Android only - via a non-consumable)
  2. Once they’ve purchased the IAP, we need to actually unlock the content, which is already downloaded and stored locally.
  3. We need to unlock it in such a way that on subsequent launches, the app is fully unlocked.
  4. We’d like the verification process to be Secure (not hackable) and Not require Internet

Our solution is to verify the receipt in OnInitialized, which we believe will work offline as the OS should cache the purchase. We do this on the Title screen.

private void UnlockPreviousPurchase()
{
Debug.Log("UnityIAP: Unlocking Any Previous Purchases");
var product = this.controller.products.WithID(FullGameID);
if (product.availableToPurchase && product.hasReceipt)
{
VerifyAndUnlockPurchaseForReceipt(product.receipt);
}
}

You can see the guts of VerifyAndUnlockPurchaseForReceipt in the full code below, but it’s largely just the sample code provided by Unity.

Once verified, we just set a static flag that says it’s unlocked. Since it’s static, it gets cleared on the next launch, and we re-verify the purchase again. We do not store anything about the purchase, or whether the app is unlocked. We are thinking this will be secure (not hackable).

The button to purchase the app is on the Title screen, and we hide the button once it’s unlocked.

Full code below, which is mostly taken from the Unity Learn post on IAP:

using UnityEngine;
using UnityEngine.Purchasing;
using UnityEngine.Purchasing.Security;

/// <summary>
/// This class is primarly taken from Unity's example:
/// https://learn.unity.com/tutorial/unity-iap#5c7f8528edbc2a002053b46e
/// </summary>
public class InAppPurchaser : IStoreListener
{
    public static string FullGameID = "com.redbluegames.sparklite";

    private IStoreController controller;
    private IExtensionProvider extensions;

    public event System.Action GamePurchased;

    public InAppPurchaser()
    {
        var builder = ConfigurationBuilder.Instance(StandardPurchasingModule.Instance());
        builder.AddProduct(FullGameID, ProductType.NonConsumable);

        Debug.Log("UnityIAP: Initializing.");
        UnityPurchasing.Initialize(this, builder);
    }

    public void UnlockTheGame()
    {
        BuyProductID(FullGameID);
    }

    /// <summary>
    /// Called when Unity IAP is ready to make purchases.
    /// </summary>
    public void OnInitialized(IStoreController controller, IExtensionProvider extensions)
    {
        Debug.Log("UnityIAP: Initialized");
        this.controller = controller;
        this.extensions = extensions;

        this.UnlockPreviousPurchase();
    }

    /// <summary>
    /// Called when Unity IAP encounters an unrecoverable initialization error.
    ///
    /// Note that this will not be called if Internet is unavailable; Unity IAP
    /// will attempt initialization until it becomes available.
    /// </summary>
    public void OnInitializeFailed(InitializationFailureReason error)
    {
        Debug.Log("UnityIAP: Initialize FAILED");
    }

    /// <summary>
    /// Called when a purchase completes.
    ///
    /// May be called at any time after OnInitialized().
    /// </summary>
    public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs e)
    {
        VerifyAndUnlockPurchaseForReceipt(e.purchasedProduct.receipt);

        Debug.Log(string.Format("UnityIAP: ProcessPurchase: PASS. Product: '{0}'", e.purchasedProduct.definition.id));
        return PurchaseProcessingResult.Complete;
    }

    private void UnlockPreviousPurchase()
    {
        Debug.Log("UnityIAP: Unlocking Any Previous Purchases");
        var product = this.controller.products.WithID(FullGameID);
        if (product.availableToPurchase && product.hasReceipt)
        {
            VerifyAndUnlockPurchaseForReceipt(product.receipt);
        }
    }

    private bool VerifyAndUnlockPurchaseForReceipt(string receipt)
    {
        bool validPurchase = true;

#if UNITY_ANDROID
        Debug.Log("UnityIAP: Validating Receipt: " + receipt);
        // Prepare the validator with the secrets we prepared in the Editor
        // obfuscation window.
        var validator = new CrossPlatformValidator(GooglePlayTangle.Data(),
            AppleTangle.Data(), Application.identifier);

        try
        {
            var result = validator.Validate(receipt);

            Debug.Log("UnityIAP: Receipt is valid. Contents:");
            foreach (IPurchaseReceipt productReceipt in result)
            {
                Debug.Log(productReceipt.productID);
                Debug.Log(productReceipt.purchaseDate);
                Debug.Log(productReceipt.transactionID);
            }
        }
        catch (IAPSecurityException)
        {
            Debug.Log("UnityIAP: Invalid receipt, not unlocking content");
            validPurchase = false;

#if UNITY_EDITOR
            Debug.Log("UnityIAP: Just kidding, we call invalid receipts in Editor valid because Editor throws IAPSecurityException.");
            validPurchase = true;
#endif
        }
#endif

        if (validPurchase)
        {
            Debug.Log("UnityIAP: This was a valid purchase. Unlocking content based on purchase.");
            ApplicationManager.Instance.SetFullGameUnlocked(true);
            GamePurchased?.Invoke();
        }

        return validPurchase;
    }

    /// <summary>
    /// Called when a purchase fails.
    /// </summary>
    public void OnPurchaseFailed(Product i, PurchaseFailureReason p)
    {
        Debug.Log(string.Format("UnityIAP: ProcessPurchase: FAIL. Product: '{0}', Reason: {1}", i.definition.id, p));
    }

    private void BuyProductID(string productId)
    {
        if (IsInitialized())
        {
            Product product = controller.products.WithID(productId);

            if (product != null && product.availableToPurchase)
            {
                Debug.Log(string.Format("UnityIAP: Purchasing product asychronously: '{0}'", product.definition.id));

                // Async purchase call
                controller.InitiatePurchase(product);
            }
            else
            {
                Debug.Log("UnityIAP: BuyProductID: FAIL. Not purchasing product, either is not found or is not available for purchase");
            }
        }
        else
        {
            Debug.Log("UnityIAP: BuyProductID FAIL. Not initialized.");
        }
    }

    private bool IsInitialized()
    {
        return controller != null && extensions != null;
    }
}
5 Likes

We’re using the same approach to validation on Android. The issue we’re facing with this is that signing out of a Google account and logging into a different one will pass validation and give the second account access to the paid content. I’m not sure how to get around that.

How are you “signing out of a Google account”, it uses the email signed into Google Play on the device.

@JeffDUnity, the sample IAP project you linked has a strange problem, and I am curious if anyone else has encountered the error. If you exist the the scene the IAP script is attached to and come back later, tapping any of the purchase buttons, the error of “MissingReferenceException: The object type of ‘Text’ has been destroyed but you are still trying to access it”. Sometimes, the console says that the Script itself is destroyed. The strange thing is that Debug.Log function is fine. And there is no problem to tap on restore button to access MyDebug function. Could you point out how this can be solved!

This does not sound like a problem with the Demo. After loading other scenes, the current scene will be destroyed, so you may need to deal with this situation.

It sounds like you have modified the sample. You can simply comment out the offending line otherwise.

Hello, I’m a bit confused and not sure if I got all that’s been said here.
So

  1. There is no way to check if Non-Consumable has be bought by user and then unlock the content when OnInitialized is called?
  2. The only way suggested is to have it saved to PlayerPrefs and then read from there?

But I have few issues with that

  1. We are required by Google Play Pass to check, if user is still eligible for their subscription every time user starts using the app, they say that u can use getPurchases to get this info. So how do I do that from unity IAP, or do I have to write native code to achieve this?
  2. Plus we need user to be able to use the app offline, and both IOS and Android, so PlayerPrefs could be used in this sense I guess, but is using Local receipt validation more secure and doable as @edwardrowe wrote his example?

Yes, you check the receipt. It’s a property of each of your products in your controller. You can also use the IAP SubscriptionManager. There is an example in the Sample project, and also within the Samples for In App Purchasing library in Package Manager.

Thanks for the reply! but not sure if I was clear here, we have NON-Consumable product, that we need to unlock if we find that user is subscribed to Google Play Pass, so subscription I was referring to was Google Play Pass which we cant access directly.
In their explanation they say that we should use getPurchases for durable(their name for non-consumable) IAP content.
Is it same procedure as you explained above?

Does the product have a receipt? Please share the code that you are using to check the receipt.