How to Get Cross-Platform Subscriptions Right
Common issues, inconsistencies, and edge cases
There are three major platforms that most app developers want to support: iOS, Android, and the web. To provide subscriptions across these platforms, you’ll need to interface with Apple’s StoreKit, the Google Play Billing Library, and Stripe (or other web payment providers). Each of these systems is completely different and has many edge cases and intricacies that you may not be aware of — until the moment when you’re dealing with a sudden issue in your backend code. Or worse yet, a random bug deep in your app that requires a pass through the app review gauntlet before you can get a fix to customers.
These are the problems that tend to rear their head at the worst possible time, like the launch of your app, or the release of big new features that you’re heavily marketing. As you introduce more subscription tiers, do price testing, and work on other growth initiatives, your cross-platform code tends to become exponentially more complicated. And if you ever want to support another platform down the road (like Amazon, for example), that is going to add a whole new layer of complexity.
In this article, we’ll describe the major pain points to watch out for. But first, let’s take a look at how and why the billing systems for each platform are so different.
Different platforms, no common standards
When implementing app subscriptions, it takes a significant engineering investment to master the nuances of just one platform. Apple’s StoreKit was built on legacy architecture (the iTunes Music Store, which was built in the early 2000s), and as a result it’s missing information that most developers would expect from a modern payment processor. The Google Play Store Billing Library came along a bit later, but there are still many gaps in the data it provides, and you need to be careful not to exceed the Play Store API quotas. Stripe is known for being very developer-friendly, but the web comes with its own set of issues: for example, you need to handle charging for all the right VATs and sales taxes yourself.
Since there’s no standard way to do app subscriptions, every system evolved its own logic and edge cases. This means that when you start managing multiple platforms, the complexity grows exponentially rather than linearly, because you need to track down and handle all the differences. To name just a few:
- With Apple, subscription transaction price and other important data just isn’t available.
- The Play Store and Stripe let you issue refunds, but Apple does not.
- Apple and Stripe provide a full transaction history — Google only provides the status of active subscriptions. Data from subscriptions that have been expired more than 90 days are not included in the receipt and cannot be retrieved via Play Store APIs.
- Apple handles upgrades, downgrades, and crossgrades automatically. Google and Stripe require developers to handle the logic themselves.
- Apple has the concept of Family Sharing for in-app purchases, but Google and Stripe don’t.
- When verifying receipts, we’ve found issues where StoreKit in particular can be fickle and prone to failures and downtime.
It would be one thing if these systems were just architecturally different and limited in their own ways. But the inconsistencies run much deeper than that: There ’s not even agreement on what certain subscription events mean. For example “trial conversion” is a term we all know, but it doesn’t have a standard definition. If a subscription lapses for a while before the customer pays, is that still a conversion? What if they have a free trial for a monthly plan then move to a paid annual plan — is that a conversion of the monthly product? Each platform will give you different answers for these questions, and there are many examples like this one.
Sample receipt data
Perhaps the best way to illustrate the discrepancies between Apple, Google, and Stripe is to look at the JSON data that each platform returns when a customer makes a purchase.
Here is a sample receipt from Apple:
1{
2 "environment": "Production",
3 "latest_receipt": "receipt_token",
4 "latest_receipt_info": [
5 {
6 "expires_date": "2021-04-10 18:05:37 Etc/GMT",
7 "expires_date_ms": "1618077937000",
8 "expires_date_pst": "2021-04-10 11:05:37 America/Los_Angeles",
9 "in_app_ownership_type": "PURCHASED",
10 "is_in_intro_offer_period": "false",
11 "is_trial_period": "true",
12 "original_purchase_date": "2021-04-07 18:05:37 Etc/GMT",
13 "original_purchase_date_ms": "1617818737000",
14 "original_purchase_date_pst": "2021-04-07 11:05:37 America/Los_Angeles",
15 "original_transaction_id": "0000000000",
16 "product_id": "product_id",
17 "purchase_date": "2021-04-07 18:05:37 Etc/GMT",
18 "purchase_date_ms": "1617818737000",
19 "purchase_date_pst": "2021-04-07 11:05:37 America/Los_Angeles",
20 "quantity": "1",
21 "subscription_group_identifier": "00000000",
22 "transaction_id": "0000000000000",
23 "web_order_line_item_id": "0000000000000",
24 }
25 ],
26 "pending_renewal_info": [
27 {
28 "auto_renew_product_id": "product_id",
29 "auto_renew_status": "1",
30 "original_transaction_id": "000000000000",
31 "product_id": "product_id",
32 }
33 ],
34 "receipt": {
35 "adam_id": 0,
36 "app_item_id": 0,
37 "application_version": "1.0",
38 "bundle_id": "com.sample.app",
39 "download_id": 1234567890,
40 "in_app": [
41 {
42 "expires_date": "2021-04-10 18:05:37 Etc/GMT",
43 "expires_date_ms": "1618077937000",
44 "expires_date_pst": "2021-04-10 11:05:37 America/Los_Angeles",
45 "in_app_ownership_type": "PURCHASED",
46 "is_in_intro_offer_period": "false",
47 "is_trial_period": "true",
48 "original_purchase_date": "2021-04-07 18:05:37 Etc/GMT",
49 "original_purchase_date_ms": "1617818737000",
50 "original_purchase_date_pst": "2021-04-07 11:05:37 America/Los_Angeles",
51 "original_transaction_id": "000000000",
52 "product_id": "product_id",
53 "purchase_date": "2021-04-07 18:05:37 Etc/GMT",
54 "purchase_date_ms": "1617818737000",
55 "purchase_date_pst": "2021-04-07 11:05:37 America/Los_Angeles",
56 "quantity": "1",
57 "transaction_id": "000000000",
58 "web_order_line_item_id": "000000000",
59 }
60 ],
61 "original_application_version": "1.0",
62 "original_purchase_date": "2021-04-07 18:04:36 Etc/GMT",
63 "original_purchase_date_ms": "1617818676000",
64 "original_purchase_date_pst": "2021-04-07 11:04:36 America/Los_Angeles",
65 "receipt_creation_date": "2021-04-07 18:05:38 Etc/GMT",
66 "receipt_creation_date_ms": "1617818738000",
67 "receipt_creation_date_pst": "2021-04-07 11:05:38 America/Los_Angeles",
68 "receipt_type": "Production",
69 "request_date": "2021-04-07 18:05:58 Etc/GMT",
70 "request_date_ms": "1617818758965",
71 "request_date_pst": "2021-04-07 11:05:58 America/Los_Angeles",
72 "version_external_identifier": 839248731,
73 },
74 "status": 0,
75}
Now, compare that to a receipt from the Play Store for the exact same thing (a trial subscription):
1{
2 "receipt_data": {
3 "acknowledgementState": 1,
4 "autoRenewing": true,
5 "countryCode": "CA",
6 "developerPayload": "",
7 "expiryTimeMillis": "1618085008677",
8 "kind": "androidpublisher#subscriptionPurchase",
9 "orderId": "GPA.3314-0849-3356-76418",
10 "paymentState": 2,
11 "priceAmountMicros": "27990000",
12 "priceCurrencyCode": "CAD",
13 "startTimeMillis": "1617818646564",
14 }
15}
16
Finally, here is the same type of receipt from Stripe:
1{
2 "application_fee_percent": null,
3 "billing": "charge_automatically",
4 "billing_cycle_anchor": 1622160021,
5 "billing_thresholds": null,
6 "cancel_at": null,
7 "cancel_at_period_end": false,
8 "canceled_at": null,
9 "collection_method": "charge_automatically",
10 "created": 1619568021,
11 "current_period_end": 1622160021,
12 "current_period_start": 1619568021,
13 "customer": "cus_**",
14 "days_until_due": null,
15 "default_payment_method": null,
16 "default_source": null,
17 "default_tax_rates": [],
18 "discount": null,
19 "ended_at": null,
20 "id": "sub_**",
21 "invoice_customer_balance_settings": {
22 "consume_applied_balance_on_void": true,
23 },
24 "items": {
25 "data": [
26 {
27 "billing_thresholds": null,
28 "created": 1619568022,
29 "id": "si_**",
30 "metadata": {},
31 "object": "subscription_item",
32 "plan": {
33 "active": true,
34 "aggregate_usage": null,
35 "amount": 1990,
36 "amount_decimal": "1990",
37 "billing_scheme": "per_unit",
38 "created": 1507048460,
39 "currency": "eur",
40 "id": "**",
41 "interval": "month",
42 "interval_count": 1,
43 "livemode": true,
44 "metadata": {},
45 "nickname": null,
46 "object": "plan",
47 "product": "prod_**",
48 "tiers": null,
49 "tiers_mode": null,
50 "transform_usage": null,
51 "trial_period_days": null,
52 "usage_type": "licensed",
53 },
54 "price": {
55 "active": true,
56 "billing_scheme": "per_unit",
57 "created": 1507048460,
58 "currency": "eur",
59 "id": "**",
60 "livemode": true,
61 "lookup_key": null,
62 "metadata": {},
63 "nickname": null,
64 "object": "price",
65 "product": "prod_**",
66 "recurring": {
67 "aggregate_usage": null,
68 "interval": "month",
69 "interval_count": 1,
70 "trial_period_days": null,
71 "usage_type": "licensed",
72 },
73 "tiers_mode": null,
74 "transform_quantity": null,
75 "type": "recurring",
76 "unit_amount": 1990,
77 "unit_amount_decimal": "1990",
78 },
79 "quantity": 1,
80 "subscription": "sub_**",
81 "tax_rates": [],
82 }
83 ],
84 "has_more": false,
85 "object": "list",
86 "total_count": 1,
87 "url": "/v1/subscription_items?subscription=sub_**"
88 },
89 "latest_invoice": {... // a lot of additional data in here
90 },
91 "quantity": 1,
92 "schedule": null,
93 "start": 1619568021,
94 "start_date": 1619568021,
95 "status": "trialing",
96 "tax_percent": null,
97 "transfer_data": null,
98 "trial_end": 1622160021,
99 "trial_start": 1619568021,
100}
As you can see, the data is structured completely differently across the three platforms. And there are many gaps that developers need to fill in to make sense of the data and use it in any unified way. Note, for example, that Apple doesn’t provide the transaction price, and Google doesn’t provide the purchase date in an easily digestible format.
Now that we’ve discussed some of the key differences across the platforms, let’s take a look at how these can turn into pain points for teams.
Pain point #1: Identity validation
How do you make sure that a subscription is matched to the correct user, and that this mapping remains consistent no matter whether the user is logging in on their phone, laptop, or TV? Identity validation is essential to provide a secure, reliable experience — and it’s one of the hardest things to get right both within and across platforms.
The challenge here is that app developers need to layer their own identity system on top of the platform-level authentication. Stripe web payments just require an email address and credit card, but StoreKit and Google Play Billing link each subscription to an Apple or Google account. None of these systems are aware of the IDs you use internally for users, and your app doesn’t have visibility into the store-level IDs, either. So how do you keep these IDs mapped up consistently?
In normal circumstances, the customer logs into the app and buys a subscription: one app user ID matches neatly with one subscription. A unique token on the receipt can be used as a proxy for the underlying store account. But there are a lot of alternate scenarios to think through. What happens if the customer creates an account and subscribes on their iPhone, and then starts using the app on their iPad without signing in? You’ll need to handle merging the receipt uploaded from their iPad with the account created on their iPhone. And if they share their login with a friend or family member, you now have two unique App Store receipts associated with a single account Which receipt takes precedence and do you block either device from access to paid features?
When you add cross-platform support, the scope increases drastically. For example, you’ll need to solve what should happen if the customer makes a purchase on the web, and then opens the app on their phone (and then do that for all the permutations). It’s also important to build a solution for account sharing — maybe a Netflix model where you figure out ways to enable some sharing, but also set limits. Ideally you can have this in place before your growth takes off and you realize that you’ve lost out on a lot of potential revenue.
Identity validation with RevenueCat
These problems take lots of engineering resources to solve, even within just one platform. We’ve become experts at this at RevenueCat (if you’re in the mood for an even longer discussion, ask us sometime about the race conditions that can happen with StoreKit). To help solve these problems, we provide a foundational layer of identity validation that sits below your account system and serves as the single source of truth for your subscribers across every platform. No matter what device they’re signing into, if they already have a receipt, or if they’re sharing an account, we handle everything so that you always get a clean response back.
Getting the unified data for a subscriber is as easy as passing in an App User ID:
1curl --request GET \
2--url https://api.revenuecat.com/v1/subscribers/app_user_id\
3--header 'Authorization: Bearer REVENUECAT_API_KEY' \
4--header 'Content-Type: application/json' \
5--header 'X-Platform: ios'
You can have RevenueCat create App User IDs for you, or (more commonly) provide them yourself, for example by generating them from an identity management system like Auth0 or Firebase.
Pain point #2: Managing entitlements
Understanding who your subscribers are across your platforms is important — but there’s another layer to this, which is understanding what they are entitled to in your app. Entitlements are the subscriptions, content, bundles, etc. that users have purchased (including the ones that might no longer be valid).
Just like identity management, this is a complex problem that requires a separate approach for each platform, and extra work on top of that for unification. First, you have to learn the entitlement architecture on iOS, Apple, and Stripe. But once you have the data, making sense of it and merging it is another task entirely. Entitlements aren’t booleans. They can be in one of many states: expired, renewing, going through billing issues. And — you guessed it — these states have different names and can mean different things across the three major platforms.
Managing entitlements is an example of the kind of problem that tends to rear its head as apps grow, and add to their offerings. For example, you might start by offering a lifetime subscription, but then you decide you want to replace that option with an annual subscription. Later, you may decide to sell some “pro features” that free-tier users can buy individually. You’ve now created at least three different paths for users to be entitled to a given feature. And over time, you’ll need to handle scenarios like a user buying two pro features, then upgrading to a lifetime subscription, then cancelling their subscription, and still being entitled to the original features they purchased.
Managing entitlements with RevenueCat
At RevenueCat, we are not only your central source of truth for identity validation, we are also managing the source of truth for what users are entitled to on top of that. So we know both who a user is, and what they should have access to.
Here’s how it’s done through our SDK. For an App User ID, we will return the PurchaserInfo object that represents that user. This contains a group of EntitlementsInfo objects, which provide all the additional information you might need about that user’s entitlements (you configure Entitlement IDs in RevenueCat’s dashboard). With just one line, you can tell whether your user has access to a given entitlement:
1if purchaserInfo.entitlements["your_entitlement_id"]?.isActive == true {
2// user has access to "your_entitlement_id"
3}
Pain point #3: Data integrations with third-party tools
When you have in-app purchases and subscriptions, you get a lot of data back from the platforms, such as how long people are subscribing for and when they cancel. But structured data doesn’t make sense on its own. To extract value from your data, you have to be able to put tooling on top of it.
For example, for historical lifecycle analysis, you might want to use a tool like Amplitude to understand when people are canceling and what are the events that lead up to cancellations.
With real-time data, you can run winback campaigns with webhooks or providers that fire off an email or push notification when someone turns off auto-renew. And it’s important to have agility on your tools. A lot of apps start out using one or two integrations, and then they grow, add a marketing team, and decide to expand their tooling stack.
With one platform, it can be fairly straightforward to get the data flowing into your integrations. But moving to multiple platforms means you’ll want your tools to provide a unified view. As we discussed at the beginning of the article, it’s hard to find a shred of similarity between the receipt data you get back from Apple, Google, and Stripe. The responsibility falls on the developer to reconcile trial status, subscription durations, billing states, pricing, and more, which all might be in different formats and different places. If you don’t take the time to merge and structure this data on the way in, each new tool you add will require three different data pipelines—and then you have to maintain all of this over time. You can end up burning a whole sprint just to switch providers or add a new analytics or attributions tool.
Integrations with RevenueCat
RevenueCat pulls the data from each receipt and structures it into a consistent format, then sends it out across the different third-party tools based on what data they can ingest. Here’s an example of the data we send to Mixpanel. Each type of event will look the same, no matter whether it originated with Apple, Google, or Stripe. Everything fits together, and you can immediately start sending all your data to Amplitude, your own data warehouse, or your MMP. Switching from one provider to another is quick and seamless. We integrate with a wide range of third-party tools and add new ones regularly: just enter your credentials in RevenueCat and you’re ready to go.
Furthermore, this is streaming data. We know almost instantly when some user event has happened, like turning off auto-renew. You can use this to kick off a winback campaign with a push notification provider like Braze.
And if you want to integrate with a service we don’t currently support, or build your own custom workflows, you can use our webhooks to do just about anything you want with this data.
Pain point #4: Future-proofing for platform updates
Another thing that makes managing cross-platform subscriptions tricky is the engineering overhead caused by platform updates. Apple announces its new features at WWDC in June and then ships in September—the summer is known to be a very busy time for iOS developers. Google and Stripe are on their own slightly more incremental schedules. For internal planning, you need to work these updates into your development cycles to ensure that you’re always taking advantage of the latest subscription and store features. Of course, you also want to be careful that you don’t break anything, say if a new field is added to a platform’s receipts.
As you support more platforms, your team can end up throttled by all these different release calendars and feature updates. If you’re using an open source library to help with cross-platform subscriptions, they may not always have the latest updates, and you might have to fork the library in order to add additional support.
Future-proofing with RevenueCat
We are always working to get out ahead of the platform updates to make it as simple as possible for you to hit the ground running when an update rolls out. For example, Apple released offer codes on November 17 of last year, and we updated to support it on November 19. With RevenueCat, future-proofing is one less thing you have to worry about.
Summary
Managing in-app subscriptions and purchases across multiple platforms is painful, and can take up a lot of engineering time. Apple’s StoreKit, the Google Play Billing Library, Stripe, and other payment platforms are completely different. Not just in their architecture — they provide different kinds of data and might not agree on how to handle the same payment scenario. This leads to major pain points with identity validation, managing entitlements, and integrating with third party tools. There are even more pain points that we didn’t mention, like testing: Some aspects of in-app purchases are technically impossible to test, and the techniques for testing on each platform vary widely (see our guide to testing on iOS for more on this topic). Furthermore, the software world is always progressing rapidly, and you need to keep up with changes to all three platforms.
We built RevenueCat to solve these problems — to be the bridge between all the platforms you need to support and handle all the mess, inconsistencies, and edge cases, while always staying up to date and online. This leaves you to focus on what you do best: building a great app. We currently support Apple, Google, and Stripe, and we’re adding support for Amazon and even more platforms in the near future.
Want to explore the features that we discussed in this article? You can sign up for a free trial of RevenueCat.
You might also like
- Blog post
Implementing in-app purchases and monetizing your Roku app: A step-by-step guide
A comprehensive guide to implementing and monetizing your Roku channel, from choosing the right strategy to technical tips and integration best practices.
- Blog post
How we built the RevenueCat SDK for Kotlin Multiplatform
Explore the architecture and key decisions behind building the RevenueCat Kotlin Multiplatform SDK, designed to streamline in-app purchases across platforms.
- Blog post
Inside RevenueCat’s engineering strategy: Scaling beyond 32,000+ apps
The strategies and principles that guide our global team to build reliable, developer-loved software