Implementing In-App Subscriptions in iOS & Android with no backend servers

James Montemagno - Feb 8 '22 - - Dev Community

Implementing In-App Subscriptions in iOS & Android with no backend servers

Over the years I have created several apps that I am super proud of, and for the most part, I have always put them out for free onto the app stores. More recently I have been experimenting with different monetization strategies for the apps so users can unlock features or just leave a tip. For the longest time I kept things simple with a "non-consumable" one-time purchase. The strategy is straight forward, they either purchased the item or they didn't. Based on this information you would unlock the features in the app. My nifty InAppBilling .NET plugin for iOS, Android, macOS, and Windows comes in super handy and makes it just a few lines of code to implement this logic. Over the holiday break I decided to take things a step further and dip my toes into the world of subscriptions, and what I found left my code in a complete state of madness! Before I go into details on how I decided to implement subscriptions, I recommend listening to our recent Merge Conflict episode all about subscriptions!

One of my goals of this was to not use any backend servers or accounts at all as I didn't want to add additional costs to management. That leads to even more complexity, but for me it was worth it, so let's get into it!

Setting up subscriptions

The first thing that you are going to need to do is setup your subscriptions in the App Store and Google Play. Each store has the concept of "Groups" that have multiple subscription options in them that users can upgrade/downgrade from. I am just offering a single subscription to keep things simple. The settings are straight forward:

  • How long is a subscription (1 week, 1 month, 2 months, 3 months, 6 months, 1 year)
  • How much is the subscription (as low as 0.49)
  • Name, Description, and Product ID

It is important you select the correct subscription length as you can't change this after it is approved. Additionally, you should set a unique Product ID across your apps and try to name them the same between iOS and Android for simplicity later. You can change the price later if you desire, but note that lowering it will affect all users, but if you increase you have the option to make it for all users or just for new subscriptions. I prefer to make it a bit higher to start and then adjust later as needed.

Apple and Google have great documentation if you need more information.

Purchasing subscriptions

Now that we have setup the subscription information, we can start our implementation. I mean technically you could skip the step ahead as well if you haven't prepped the app store yet, but of course you won't be able to test out things.

Implementing In-App Subscriptions in iOS & Android with no backend servers
Purchase screen for subscription

Purchasing is straight forward, and for this blog I am going to use my InAppBilling plugin that under the hood uses StoreKit and the Android Billing Library. Making the purchase is the same as any other purchase using the SDK, but the information that is returned is the most important part.

async Task PurchaseSubscription()
{
    if (IsBusy)
        return;

    IsBusy = true;
    try
    {

        // check internet first with Essentials
        if (Connectivity.NetworkAccess != NetworkAccess.Internet)
            return;            

        // connect to the app store api
        var connected = await CrossInAppBilling.Current.ConnectAsync();
        if (!connected)
            return;

        var productIdSub = "mysubscriptionid";

        //try to make purchase, this will return a purchase, empty, or throw an exception
        var purchase = await CrossInAppBilling.Current.PurchaseAsync(productIdSub, ItemType.Subscription);

        if (purchase == null)
        {
            //nothing was purchased
            return;
        }

        if (purchase.State == PurchaseState.Purchased)
        {
            Settings.SubExpirationDate = DateTime.UtcNow.AddSubTime();
            Settings.HasPurchasedSub = true;
            Settings.CheckSubStatus = true;

            // Update UI if necessary if they have 
            SetPro();

            try
            {
                // It is required to acknowledge the purchase, else it will be refunded
                if (DeviceInfo.Platform == DevicePlatform.Android)
                    await CrossInAppBilling.Current.AcknowledgePurchaseAsync(purchase.PurchaseToken);
            }
            catch (Exception ex)
            {
                Logger.AppendLine("Unable to acknowledge purcahse: " + ex);
            }
        }
        else
        {
            throw new InAppBillingPurchaseException(PurchaseError.GeneralError);
        }
    }
    catch (InAppBillingPurchaseException purchaseEx)
    {
        // Handle all the different error codes that can occure and do a pop up
    }
    catch (Exception ex)
    {
        // Handle a generic exception as something really went wrong
    }
    finally
    {
        await CrossInAppBilling.Current.DisconnectAsync();
        IsBusy = false;

    }
}
Enter fullscreen mode Exit fullscreen mode

This looks like a lot of code, but it is only four calls to the billing service itself: Connect, buy, acknowledge (on Android), disconnect. And funny enough, on iOS you don't even need to really connect or disconnect as it doesn't do anything.

Now, the real thing to pay attention to is what we do when the user makes a successful purchase. I store 3 values that I store in preferences using Essentials:

   Settings.SubExpirationDate = DateTime.UtcNow.AddSubTime();
   Settings.HasPurchasedSub = true;
   Settings.CheckSubStatus = true;
Enter fullscreen mode Exit fullscreen mode
  • SubExpirationDate : This value lets me check if the subscription is still valid and when to refresh the subscription status next.
  • HasPurchasedSub : A simple value to determine if they have EVER purchased a subscription. Just valuable information that I will use later.
  • CheckSubStatus : This value I use to determine if I have prompted the user to refresh the subscription status, it is true if the subscription is valid, and I have never prompted them.

That AddSubTime is a nice little extension method I made throughout my app to make it easier when checking if subscriptions are valid or not.

public static DateTime AddSubTime(this DateTime dateTime)
            => dateTime.AddMonths(1).AddDays(5);
Enter fullscreen mode Exit fullscreen mode

I decided to add not only a month, but also five extra days just in case there is some weird billing issue. I figure what an extra five days anyway.

Now throughout my app I can run a single check to see if they have purchased a subscription and if it is valid:

public static bool IsSubValid => HasPurchasedSub && SubExpirationDate > DateTime.UtcNow;
Enter fullscreen mode Exit fullscreen mode

Handling subscription renewals and cancelations

At this point, the user now has access to the subscription for a full month (and 5 days)! What happens when it is time to refresh that subscription status? I went with a simple approach on launch of the application that notifies the user that the subscription status needs to be refreshed. I could do this in the background, but I made it very transparent to the users.

if(Settings.CheckSubStatus && Settings.HasPurchasedSub && !Settings.IsSubValid)
{
    Settings.CheckSubStatus = false;
    if(await DisplayAlert("Subcription Status Refresh", "Looks like it is time to update your subscription status. If you canceled and resumed, you can always refresh your status on the settings page by restoring purchases.", "Refresh status", "Maybe Later"))
    {
        if (Connectivity.NetworkAccess != NetworkAccess.Internet)
        {
            Settings.CheckSubStatus = true;
            await DisplayAlert("No internet", "Please check internet connection and try restore purchases again in settings.", "OK");
            return;
        }
        //refresh status here
        await RestorePurchases();
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, I check to see if they ever purchased a subscription, if it is no longer valid, and if I should check the subcription status with a prompt. If they want to refresh the status, I call the RestorePurchase method.

Now, this is where things get FUNKY and weird because iOS and Android differ so much.

  • iOS : returns every single transaction including the original purchase and all renewals with proper transaction date time stamps!
  • Android : Only returns current valid subscriptions and the time stamp is always the original purchase date of the subscription, not the renewals.

I personally prefer the iOS mechanism for this as it makes the code much cleaner. All you must do is order the purchases descending by transaction date and compare dates to see if a subscription is valid. On Android, if it returns a subscription then it is valid and you must figure out the expiration date manually by adding time over and over to the original transaction date, which seems problematic. However, after much testing, here is what I came up with to check to see if the subscription has been renewed:

async Task RestorePurchases()
{
    if (IsBusy)
        return;
    IsBusy = true;

    try
    {

        var connected = await CrossInAppBilling.Current.ConnectAsync();
        if (!connected)
            return;

        var foundStuff = false;

        var subs = await CrossInAppBilling.Current.GetPurchasesAsync(ItemType.Subscription);

        if (subs?.Any(p => p.ProductId == productIdSub) ?? false)
        {
            var sorted = subs.Where(p => p.ProductId == productIdSub).OrderByDescending(i => i.TransactionDateUtc).ToList();
            var recentSub = sorted[0];
            if (recentSub != null)
            {
                // On Android as long as you have one here then it is valid subscription
                if (DeviceInfo.Platform == DevicePlatform.Android)
                {
                    foundStuff = true;
                    Settings.HasTippedSub = true;
                    Settings.CheckSubStatus = true;

                    //loop through transactions and keep adding a month until valid
                    var date = recentSub.TransactionDateUtc;
                    while (date < DateTime.UtcNow)
                        date = date.AddMonths(1);

                    Settings.SubExpirationDate = date.AddDays(5);

                    SetPro();
                }
                else
                {
                    if (recentSub.TransactionDateUtc.AddSubTime() > DateTime.UtcNow)
                    {
                        foundStuff = true;
                        Settings.HasTippedSub = true;
                        Settings.CheckSubStatus = true;
                        Settings.SubExpirationDate = recentSub.TransactionDateUtc.AddSubTime();
                        SetPro();
                    }
                }

                if (DeviceInfo.Platform == DevicePlatform.Android && !recentSub.IsAcknowledged)
                {
                    try
                    {
                        await CrossInAppBilling.Current.AcknowledgePurchaseAsync(recentSub.PurchaseToken);
                    }
                    catch (Exception ex)
                    {
                        Logger.AppendLine("Unable to acknowledge purchase: " + ex);
                    }
                }
            }
        }

        if (!foundStuff)
        {
            await DisplayAlert("Hmmmm!", $"Looks like we couldn't find any subscription renewals, check your purchases and restore them in settings. Don't worry, all of your ride data will be saved.", "OK");
        }
        else
        {
            await DisplayAlert("Status Refreshed!", $"Thanks for being awesome and subscribing for another month of My Cadence Pro!", "OK");
        }

    }
    catch (Exception ex)
    {
        Debug.WriteLine("Issue connecting: " + ex);
        await DisplayAlert("Uh Oh!", $"Looks like something has gone wrong, please check connection and restore in the settings. Code: {ex.Message}", "OK");
    }
    finally
    {
        IsBusy = false;
        SetPro();
        await CrossInAppBilling.Current.DisconnectAsync();
    }
}
Enter fullscreen mode Exit fullscreen mode

It is not pretty, but totally working well. Note that for iOS I take the most recent subscription, add time, and compare it with the current UTC time: recentSub.TransactionDateUtc.AddSubTime() > DateTime.UtcNow and if it is still valid then I update the expiration date.

Verifying & restoring a subscription

One important feature that your app must implement is a "Restore Purchases" feature for in-app purchases and subscriptions. This is important if your user gets a new device, has multiple devices, or uninstalls and re-installs the app. I add a button directly on the settings/upgrade screen to do this. The application logic is actually the same as the renewal check above! Code re-use to the max!!! You may have other in-app purchases that you may want to put in there as well, so be sure to restore all your purchases!

Receipt validation

At this point we have finished all the in-app code for subscriptions, renewals, and restoration. See, it wasn't that bad :).... well except that we haven't done any receipt validation, which means the user could have cancelled, asked for a refund, paused a subscription, or who knows what else. Oh, and receipt validation also ensures that users haven't hacked your app in some way to intercept the calls to get free stuff. Look how easy this looks:

Implementing In-App Subscriptions in iOS & Android with no backend servers

Receipt validation is super important, and I recommend you checkout my documentationwith tons of links to other important resources including how to use Azure Functions as a receipt validation service. For this blog, and in my apps currently I am not doing receipt validation. I haven't personally had any issues, but my apps are only downloaded a few thousand times, not millions of times. So, I am going to leave this up to you based on your application.

Testing subscriptions

Now is the fun part of testing!!!!!!!

Implementing In-App Subscriptions in iOS & Android with no backend servers

This is the worst part because when you test everything is different in a sandbox, and each platform is different. I ended up using TestFlight and Google Play Internal rings for testing with my normal account. When you subscribe on iOS and Android you aren't charged anything during testing, but they do renew differently for testing purposes. Android will just continue to renew every 5 minutes (for a 1 month sub, longer for others) and then you have to manually cancel the subscription. I like this approach a lot as it doesn't clutter up all my transactions and lets me do more realistic world testing. iOS is a bit of an interesting one as it also will renew the subscription every 5 minutes but will do it up to 12 times and then stop renewing. This is a fine approach, but I just wish they worked the same.

I could write for days on testing and all the complexity, but there is an amazing blog for iOS testing from RevenueCat that saved my life and outlines everything you need to know. Google actually has pretty good documenation for this and really helped me get through the process. Take your time on testing and make sure to test all of the different scenarios. One pro tip that I can offer is to change the amount of time for a valid subscription to 5 minutes (instead of my 1 month & 5 days) so you can test out the duration that Apple/Google renew on. Once you have validated that you can change it back and ship to the store.

In-App Subscriptions SaaS... RevenueCat

Implementing in-app subscriptions can be extremely complicated and can get even more complicated when you throw in user accounts, third party integrations, and subscriptions on the web. After reading the testing blog on RevenueCat, I realized that it was an entire SaaS solution for in-app subscriptions and is genius.

Implementing In-App Subscriptions in iOS & Android with no backend servers

If you are really looking for a full solution this may be a good option for you to investigate. I have still decided to roll my own and use the raw APIs of the platform, but I have to say RevenueCat is pretty awesome and just shows how complex these APIs and testing matrixes are.

Other Considerations

In-app subscriptions are pretty neat as they offer a lot of flexibility over a single IAP or up-front app purchase. For example you can:

  • Offer a free trial for a month
  • Offer discounted rate for the first X months
  • Offer discounts for longer subscriptions
  • Promote them in the app store as another entry (iOS)

It is true that we are becoming a subscription based society and some people hate subscriptions. However, if you have great features and want to help your long-term development it is a great option to generate revenue. One option that I have in my apps is the option to do a "life time" subscription purchase. This is actually not a subscription, but a non-consumable in-app purchase. This adds a bit of complexity to the mix, but I think it is worth it at the end of the day. I hope that you found this blog a bit interesting and helpful on your IAP journey!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .