Google Play Billing provides a comprehensive API surface for handling in-app purchases and subscriptions on Android. Most developers are comfortable with the standard purchase flow: launch the billing flow, receive a result, acknowledge the purchase, and grant entitlements. But production billing systems must handle a wider range of scenarios that are often underrepresented in tutorials and sample code. Pending purchases, multi-quantity consumables, subscription downgrades with proration, and the ITEM_ALREADY_OWNED response are all situations your app will encounter in the real world, and mishandling any of them can result in lost revenue, confused users, or failed purchases.
In this article, we’ll explore the most common edge cases in Google Play Billing, understand why they occur, examine how to handle each one correctly with the Play Billing Library, and see how RevenueCat simplifies these scenarios so you can focus on your product instead of billing infrastructure.
The fundamental problem: the happy path is not enough
Most billing implementations start from the sample code in the Android documentation:
1// The happy path
2billingClient.launchBillingFlow(activity, params)
3
4// In PurchasesUpdatedListener
5override fun onPurchasesUpdated(
6 billingResult: BillingResult,
7 purchases: List<Purchase>?
8) {
9 if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
10 purchases?.forEach { purchase ->
11 if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
12 acknowledgePurchase(purchase)
13 grantEntitlement(purchase)
14 }
15 }
16 }
17}
This handles a successful, immediate purchase. But what happens when the payment is delayed by 48 hours because the user is paying at a convenience store? What happens when the user already owns the item because a previous acknowledgment failed silently? What happens when a subscription downgrade takes effect at the next renewal instead of immediately? Each of these scenarios requires specific handling, and ignoring them leads to support tickets, refund requests, and lost subscribers.
Pending purchases: when payment is not immediate
Not all purchases complete instantly. Certain payment methods, including cash payments at convenience stores, bank transfers, and some carrier billing options, require asynchronous processing. When a user initiates a purchase with one of these methods, Google Play returns a purchase in the PENDING state rather than the PURCHASED state.
Why pending purchases happen
Pending purchases are common in markets where credit card penetration is low:
| Payment method | Common regions | Typical processing time |
|---|---|---|
| Cash payments (convenience stores) | Japan, Mexico, Indonesia | 24-48 hours |
| Bank transfers | Germany, Netherlands, Brazil | 1-3 business days |
| Carrier billing (some carriers) | Various | Minutes to hours |
If your app is available globally, you’re bound to encounter pending purchases. Ignoring this state means users in these regions cannot purchase your products at all — or worse, they see confusing behavior where their purchase ‘disappears’.
Detecting and handling the pending state
The PurchasesUpdatedListener receives pending purchases alongside completed ones. The critical distinction is in the purchaseState field:
1override fun onPurchasesUpdated(
2 billingResult: BillingResult,
3 purchases: List<Purchase>?
4) {
5 if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
6 purchases?.forEach { purchase ->
7 when (purchase.purchaseState) {
8 Purchase.PurchaseState.PURCHASED -> {
9 // Payment complete, grant access
10 processPurchase(purchase)
11 }
12 Purchase.PurchaseState.PENDING -> {
13 // Payment not yet complete
14 handlePendingPurchase(purchase)
15 }
16 Purchase.PurchaseState.UNSPECIFIED_STATE -> {
17 // Unknown state, query backend for clarification
18 queryBackendForState(purchase)
19 }
20 }
21 }
22 }
23}
The key rule is: do not grant entitlements for pending purchases. The user has not paid yet. Instead, record the pending purchase and communicate the status clearly:
1fun handlePendingPurchase(purchase: Purchase) {
2 // Store the pending purchase token for later verification
3 purchaseRepository.savePendingPurchase(
4 purchaseToken = purchase.purchaseToken,
5 productId = purchase.products.first(),
6 orderId = purchase.orderId,
7 purchaseTime = purchase.purchaseTime,
8 )
9
10 // Show clear UI to the user
11 showPendingUI(
12 message = "Your purchase is being processed. " +
13 "You'll get access once payment is confirmed.",
14 )
15}
Completing a pending purchase
When the payment is eventually confirmed, your app receives an updated purchase via onPurchasesUpdated or through queryPurchasesAsync. The purchaseState will now be PURCHASED, and you can proceed with acknowledgment and entitlement granting.
However, there is a subtlety: the user might not have your app open when the payment completes. Your backend should handle this through Real-Time Developer Notifications (RTDN). When you receive a ONE_TIME_PRODUCT_PURCHASED or SUBSCRIPTION_PURCHASED notification for a previously pending token, your backend should update the entitlement and notify the user:
1// Backend notification handler
2fun handlePurchaseNotification(notification: DeveloperNotification) {
3 val purchaseToken = notification.oneTimeProductNotification?.purchaseToken
4 ?: notification.subscriptionNotification?.purchaseToken
5 ?: return
6
7 val pendingPurchase = purchaseRepository.findPendingPurchase(purchaseToken)
8 if (pendingPurchase != null) {
9 // Previously pending purchase is now complete
10 val purchaseDetails = playDeveloperApi
11 .purchases()
12 .products()
13 .get(packageName, pendingPurchase.productId, purchaseToken)
14 .execute()
15
16 if (purchaseDetails.purchaseState == 0) { // 0 = Purchased
17 entitlementRepository.grantEntitlement(
18 userId = pendingPurchase.userId,
19 productId = pendingPurchase.productId,
20 )
21 purchaseRepository.markCompleted(purchaseToken)
22
23 // Notify user that their purchase is ready
24 notificationService.sendPushNotification(
25 userId = pendingPurchase.userId,
26 title = "Purchase Complete",
27 body = "Your purchase has been confirmed. Enjoy your content!",
28 )
29 }
30 }
31}
Enabling pending purchases in your BillingClient
Pending purchase support must be explicitly enabled when building the BillingClient. Without this, purchases from delayed payment methods will fail entirely:
1val billingClient = BillingClient.newBuilder(context)
2 .setListener(purchasesUpdatedListener)
3 .enablePendingPurchases(
4 PendingPurchasesParams.newBuilder()
5 .enableOneTimeProducts()
6 .enablePrepaidPlans()
7 .build()
8 )
9 .build()
Starting with Play Billing Library 7, calling enablePendingPurchases() is required. Without it, BillingClient initialization will fail.
The ITEM_ALREADY_OWNED response: a common source of confusion
One of the most frequently encountered edge cases is BillingResponseCode.ITEM_ALREADY_OWNED. This response occurs when a user attempts to purchase a non-consumable product or subscription they already own. While it sounds straightforward, the scenarios that trigger it are often surprising.
Why ITEM_ALREADY_OWNED happens
The most common cause is not the user deliberately trying to buy something twice. It’s a previous purchase that was not properly acknowledged. Google Play’s acknowledgment requirement means that unacknowledged purchases exist in a limbo state: the user has been charged, but the purchase has not been confirmed by your app. If the user tries to buy the same item again, Google Play returns ITEM_ALREADY_OWNED because the unacknowledged purchase still exists.
This happens more often than you might expect:
- The app crashed after receiving the purchase but before acknowledging it
- A network error prevented the acknowledgment call from completing
- The user force-closed the app during the purchase flow
- The acknowledgment API call returned an error that was not retried
Handling ITEM_ALREADY_OWNED correctly
The correct response to ITEM_ALREADY_OWNED is not to show an error message. Instead, you should query for existing purchases and process any unacknowledged ones:
1override fun onPurchasesUpdated(
2 billingResult: BillingResult,
3 purchases: List<Purchase>?
4) {
5 when (billingResult.responseCode) {
6 BillingClient.BillingResponseCode.OK -> {
7 purchases?.forEach { processPurchase(it) }
8 }
9 BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> {
10 // Query existing purchases and process any unacknowledged ones
11 recoverUnacknowledgedPurchases()
12 }
13 BillingClient.BillingResponseCode.USER_CANCELED -> {
14 // User backed out, no action needed
15 }
16 else -> {
17 handleBillingError(billingResult)
18 }
19 }
20}
21
22private fun recoverUnacknowledgedPurchases() {
23 val params = QueryPurchasesParams.newBuilder()
24 .setProductType(BillingClient.ProductType.INAPP)
25 .build()
26
27 billingClient.queryPurchasesAsync(params) { billingResult, purchases ->
28 if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
29 purchases.forEach { purchase ->
30 if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED &&
31 !purchase.isAcknowledged
32 ) {
33 // Found the unacknowledged purchase, process it
34 processPurchase(purchase)
35 }
36 }
37 }
38 }
39}
This pattern turns a frustrating error into a seamless recovery. The user does not need to know that a previous purchase failed to acknowledge. From their perspective, they tap ‘Buy’ and get the item.
Preventing ITEM_ALREADY_OWNED proactively
The best approach is to prevent this scenario by processing unacknowledged purchases on app startup:
1fun processUnacknowledgedPurchasesOnStartup() {
2 val inAppParams = QueryPurchasesParams.newBuilder()
3 .setProductType(BillingClient.ProductType.INAPP)
4 .build()
5
6 val subsParams = QueryPurchasesParams.newBuilder()
7 .setProductType(BillingClient.ProductType.SUBS)
8 .build()
9
10 billingClient.queryPurchasesAsync(inAppParams) { result, purchases ->
11 if (result.responseCode == BillingClient.BillingResponseCode.OK) {
12 purchases.filter {
13 it.purchaseState == Purchase.PurchaseState.PURCHASED &&
14 !it.isAcknowledged
15 }.forEach { processPurchase(it) }
16 }
17 }
18
19 billingClient.queryPurchasesAsync(subsParams) { result, purchases ->
20 if (result.responseCode == BillingClient.BillingResponseCode.OK) {
21 purchases.filter {
22 it.purchaseState == Purchase.PurchaseState.PURCHASED &&
23 !it.isAcknowledged
24 }.forEach { processPurchase(it) }
25 }
26 }
Call this method when the BillingClient connects successfully. This ensures that any purchases that slipped through the cracks are recovered before the user encounters problems.
Consumable purchases: acknowledge vs. consume
For consumable products like in-game virtual currency, extra lives, or token packs, the distinction between acknowledgment and consumption is a common source of bugs. Both are required for consumable products, but they serve different purposes and have different timing requirements.
The acknowledgment and consumption flow
Acknowledgment tells Google Play you’ve delivered the purchased content. It must happen within three days of the purchase, or the purchase is automatically refunded.
Consumption resets the purchase so the user can buy the same item again. Without consuming a product, the user cannot repurchase it, and attempting to do so returns ITEM_ALREADY_OWNED.
For consumable products, you should consume the purchase, which implicitly acknowledges it:
1fun processConsumablePurchase(purchase: Purchase) {
2 // Verify with backend first
3 verifyPurchaseWithBackend(purchase) { isValid ->
4 if (isValid) {
5 // Grant the consumable content
6 grantConsumableContent(purchase)
7
8 // Consume the purchase (this also acknowledges it)
9 val consumeParams = ConsumeParams.newBuilder()
10 .setPurchaseToken(purchase.purchaseToken)
11 .build()
12
13 billingClient.consumeAsync(consumeParams) { billingResult, _ ->
14 if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
15 // Retry consumption, the user cannot rebuy until consumed
16 scheduleConsumptionRetry(purchase.purchaseToken)
17 }
18 }
19 }
20 }
21}
The multi-quantity edge case
Google Play supports multi-quantity purchases for consumable products. A user can buy multiple units of a consumable in a single transaction. The quantity is available in the Purchase object:
1fun processConsumablePurchase(purchase: Purchase) {
2 val quantity = purchase.quantity // Could be > 1
3
4 verifyPurchaseWithBackend(purchase) { isValid ->
5 if (isValid) {
6 // Grant the correct quantity
7 grantConsumableContent(purchase.products.first(), quantity)
8
9 val consumeParams = ConsumeParams.newBuilder()
10 .setPurchaseToken(purchase.purchaseToken)
11 .build()
12
13 billingClient.consumeAsync(consumeParams) { billingResult, _ ->
14 if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
15 scheduleConsumptionRetry(purchase.purchaseToken)
16 }
17 }
18 }
19 }
20}
If you ignore the quantity field and always grant one unit, users who purchase multiple units will receive fewer items than they paid for. This leads to support tickets and refund requests.
To enable multi-quantity purchases, you must configure the product in the Google Play Console with ‘Allow multi-quantity purchases’ enabled. Additionally, your BillingFlowParams can specify a maximum quantity the user is allowed to select:
1val billingFlowParams = BillingFlowParams.newBuilder()
2 .setProductDetailsParamsList(
3 listOf(
4 BillingFlowParams.ProductDetailsParams.newBuilder()
5 .setProductDetails(productDetails)
6 .build()
7 )
8 )
9 .build()
The consumption retry problem
If the consumeAsync call fails (due to a network error, for example), the user has received their content but the purchase has not been consumed. This means:
- The user cannot buy the same consumable again
- The purchase may be refunded after three days if not acknowledged (though consumption implicitly acknowledges)
You should implement a retry mechanism for failed consumptions:
1class ConsumptionRetryManager(
2 private val billingClient: BillingClient,
3 private val purchaseRepository: PurchaseRepository,
4) {
5 fun scheduleConsumptionRetry(purchaseToken: String) {
6 purchaseRepository.markPendingConsumption(purchaseToken)
7 }
8
9 fun retryPendingConsumptions() {
10 val pendingTokens = purchaseRepository.getPendingConsumptionTokens()
11
12 pendingTokens.forEach { token ->
13 val consumeParams = ConsumeParams.newBuilder()
14 .setPurchaseToken(token)
15 .build()
16
17 billingClient.consumeAsync(consumeParams) { billingResult, _ ->
18 if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
19 purchaseRepository.clearPendingConsumption(token)
20 }
21 // If still failing, it will be retried next time
22 }
23 }
24 }
25}
Call retryPendingConsumptions() each time the BillingClient connects, alongside your unacknowledged purchase recovery logic.
Subscription downgrades and proration modes
When a user changes their subscription plan, the billing behavior depends on whether they are upgrading or downgrading and which proration mode you specify. Downgrades in particular have behavior that surprises many developers.
The default downgrade behavior
When a user downgrades their subscription (moves to a cheaper plan), the default behavior is DEFERRED: the downgrade takes effect at the next renewal date, not immediately. The user continues to have access to the higher-tier features until their current billing period ends.
1fun launchDowngrade(
2 activity: Activity,
3 newProductDetails: ProductDetails,
4 newOfferToken: String,
5 currentPurchaseToken: String,
6) {
7 val billingFlowParams = BillingFlowParams.newBuilder()
8 .setProductDetailsParamsList(
9 listOf(
10 BillingFlowParams.ProductDetailsParams.newBuilder()
11 .setProductDetails(newProductDetails)
12 .setOfferToken(newOfferToken)
13 .build()
14 )
15 )
16 .setSubscriptionUpdateParams(
17 BillingFlowParams.SubscriptionUpdateParams.newBuilder()
18 .setOldPurchaseToken(currentPurchaseToken)
19 .setSubscriptionReplacementMode(
20 BillingFlowParams.SubscriptionUpdateParams
21 .ReplacementMode.DEFERRED
22 )
23 .build()
24 )
25 .build()
26
27 billingClient.launchBillingFlow(activity, billingFlowParams)
28}
Understanding replacement modes
Each replacement mode has different implications for billing, access, and user experience:
| Mode | When Change Takes Effect | Billing Impact | Best For |
|---|---|---|---|
IMMEDIATE_WITH_TIME_PRORATION | Immediately | Remaining time credited toward new plan | Upgrades where user gets immediate access |
IMMEDIATE_AND_CHARGE_PRORATED_PRICE | Immediately | Prorated charge for remainder of period | Upgrades with fair billing |
IMMEDIATE_AND_CHARGE_FULL_PRICE | Immediately | Full new price charged, new billing period starts | Premium upgrades |
DEFERRED | Next renewal | No immediate charge | Downgrades |
IMMEDIATE_WITHOUT_PRORATION | Immediately | No charge until next renewal | Lateral moves or trials of higher tiers |
The deferred downgrade pitfall
The most common mistake with deferred downgrades is checking the subscription state immediately after the purchase flow completes and expecting to see the new plan. With DEFERRED mode, the original subscription remains active with the original product ID until the next renewal. The new subscription only appears after renewal.
This means your entitlement check must account for the transition period:
1fun handleDowngradeResult(purchase: Purchase) {
2 // After a deferred downgrade, the purchase still reflects
3 // the OLD subscription until the next renewal
4 val currentProductId = purchase.products.first()
5
6 // Query the subscription status from your backend to check
7 // if a deferred downgrade is pending
8 checkBackendForPendingDowngrade(purchase.purchaseToken) { pendingDowngrade ->
9 if (pendingDowngrade != null) {
10 // Show UI indicating the downgrade is scheduled
11 showDowngradeScheduledUI(
12 currentPlan = currentProductId,
13 futurePlan = pendingDowngrade.newProductId,
14 effectiveDate = pendingDowngrade.effectiveDate,
15 )
16 } else {
17 // Normal subscription state
18 showSubscriptionUI(currentProductId)
19 }
20 }
21}
The linked purchase token on plan changes
When a subscription replacement is processed (whether upgrade or downgrade), a new purchase token is generated. The new purchase includes a linkedPurchaseToken field pointing to the old subscription. Your backend must handle this correctly to avoid creating duplicate entitlements:
1// Backend handler for subscription replacement
2fun handleSubscriptionReplacement(newPurchaseToken: String) {
3 val subscription = playDeveloperApi
4 .purchases()
5 .subscriptionsv2()
6 .get(packageName, newPurchaseToken)
7 .execute()
8
9 val linkedToken = subscription.linkedPurchaseToken
10
11 if (linkedToken != null) {
12 // This is a plan change, not a new purchase
13 val userId = userRepository.findByPurchaseToken(linkedToken)
14
15 // Update to new token
16 userRepository.updatePurchaseToken(userId, newPurchaseToken)
17
18 // Invalidate old token to prevent double-counting
19 subscriptionRepository.invalidate(linkedToken)
20 }
21
22 // Acknowledge the new purchase
23 acknowledgePurchase(newPurchaseToken)
24}
Failing to invalidate the old purchase token when processing a replacement is a common bug that leads to inflated subscriber counts and incorrect revenue reporting.
Network failures and retry strategies
Billing operations are network dependent, and network failures or low latency are inevitable. The critical operations that can fail are the purchase flow itself, acknowledgment, consumption, and purchase verification.
The acknowledgment window
Google Play gives you three days to acknowledge a purchase. If you fail to acknowledge within this window, the purchase is automatically refunded. This is a safeguard for users, but it means your acknowledgment logic must be resilient to transient failures:
1class AcknowledgmentManager(
2 private val billingClient: BillingClient,
3 private val purchaseRepository: PurchaseRepository,
4) {
5 fun acknowledgePurchaseWithRetry(purchase: Purchase) {
6 if (purchase.isAcknowledged) return
7
8 val params = AcknowledgePurchaseParams.newBuilder()
9 .setPurchaseToken(purchase.purchaseToken)
10 .build()
11
12 billingClient.acknowledgePurchase(params) { billingResult ->
13 when (billingResult.responseCode) {
14 BillingClient.BillingResponseCode.OK -> {
15 purchaseRepository.markAcknowledged(purchase.purchaseToken)
16 }
17 BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
18 BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
19 BillingClient.BillingResponseCode.ERROR -> {
20 // Transient failure, schedule retry
21 purchaseRepository.markPendingAcknowledgment(
22 purchase.purchaseToken
23 )
24 }
25 else -> {
26 // Non-retryable error, log for investigation
27 logAcknowledgmentFailure(purchase, billingResult)
28 }
29 }
30 }
31 }
32
33 fun retryPendingAcknowledgments() {
34 val pendingTokens = purchaseRepository.getPendingAcknowledgmentTokens()
35
36 val inAppParams = QueryPurchasesParams.newBuilder()
37 .setProductType(BillingClient.ProductType.INAPP)
38 .build()
39
40 billingClient.queryPurchasesAsync(inAppParams) { result, purchases ->
41 if (result.responseCode == BillingClient.BillingResponseCode.OK) {
42 purchases
43 .filter { it.purchaseToken in pendingTokens }
44 .filter { !it.isAcknowledged }
45 .forEach { acknowledgePurchaseWithRetry(it) }
46 }
47 }
48 }
49}
BillingClient disconnection
The BillingClient can disconnect at any time, and operations performed on a disconnected client will fail. You should implement reconnection logic with exponential backoff:
1class BillingClientManager(
2 private val context: Context,
3 private val listener: PurchasesUpdatedListener,
4) {
5 private var billingClient: BillingClient? = null
6 private var retryCount = 0
7
8 fun connect(onConnected: () -> Unit) {
9 billingClient = BillingClient.newBuilder(context)
10 .setListener(listener)
11 .enablePendingPurchases(
12 PendingPurchasesParams.newBuilder()
13 .enableOneTimeProducts()
14 .build()
15 )
16 .build()
17
18 billingClient?.startConnection(object : BillingClientStateListener {
19 override fun onBillingSetupFinished(billingResult: BillingResult) {
20 if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
21 retryCount = 0
22 onConnected()
23 } else {
24 retryConnection(onConnected)
25 }
26 }
27
28 override fun onBillingServiceDisconnected() {
29 retryConnection(onConnected)
30 }
31 })
32 }
33
34 private fun retryConnection(onConnected: () -> Unit) {
35 if (retryCount < MAX_RETRY_COUNT) {
36 retryCount++
37 val delayMs = (1000L * (1 shl retryCount)).coerceAtMost(MAX_RETRY_DELAY_MS)
38 handler.postDelayed({ connect(onConnected) }, delayMs)
39 }
40 }
41
42 companion object {
43 private const val MAX_RETRY_COUNT = 5
44 private const val MAX_RETRY_DELAY_MS = 30_000L
45 }
46}
How RevenueCat handles these edge cases
Each of the edge cases described above requires careful implementation, retry logic, and backend infrastructure. This is where RevenueCat provides significant value by abstracting away the complexity and handling these scenarios automatically.
Pending purchases
RevenueCat tracks pending purchase states internally and updates CustomerInfo when payments are confirmed. Your app only needs to check entitlements:
1Purchases.sharedInstance.getCustomerInfoWith { customerInfo ->
2 val isPremium = customerInfo.entitlements["premium"]?.isActive == true
3
4 if (isPremium) {
5 showPremiumContent()
6 } else {
7 showSubscriptionOptions()
8 }
9}
RevenueCat’s backend processes RTDN notifications from Google Play, so when a pending purchase completes, the entitlement is updated RevenueCat’s server-side. The next time your app queries CustomerInfo, the entitlement is active. No custom notification handling or purchase token tracking is needed on your side. If you’re a sole developer, building the overall backend infrastructure is a ton of resources.
Acknowledgment and consumption
RevenueCat handles acknowledgment and consumption automatically. When a purchase is received by the SDK, it is verified with RevenueCat’s backend, and RevenueCat acknowledges the purchase with Google Play on your behalf. For consumable products, RevenueCat handles consumption after verification. You never need to call acknowledgePurchase or consumeAsync yourself.
This eliminates the entire class of bugs related to failed acknowledgments, missed consumption calls, and the ITEM_ALREADY_OWNED problem.
Subscription plan changes
RevenueCat provides a clean API for subscription upgrades and downgrades through purchaseWith:
1Purchases.sharedInstance.purchaseWith(
2 PurchaseParams.Builder(activity, newPackage)
3 .oldProductId(currentProductId)
4 .googleReplacementMode(GoogleReplacementMode.DEFERRED)
5 .build(),
6 onSuccess = { transaction, customerInfo ->
7 // CustomerInfo reflects the new subscription state
8 updateUI(customerInfo)
9 },
10 onError = { error, userCancelled ->
11 if (!userCancelled) {
12 showError(error)
13 }
14 }
15)
RevenueCat handles the linked purchase token logic, entitlement transitions, and deferred downgrade tracking on the backend. Your app simply checks CustomerInfo for the current entitlement state.
Network resilience
RevenueCat’s SDK includes built-in retry logic for all network operations, caches CustomerInfo locally for offline access, and synchronizes with the backend when connectivity is restored. This means your app can check entitlements even when the device is offline:
1// This works offline using cached CustomerInfo
2Purchases.sharedInstance.getCustomerInfoWith { customerInfo ->
3 val isPremium = customerInfo.entitlements["premium"]?.isActive == true
4 updateUI(isPremium)
5}
The SDK distinguishes between stale and fresh data, retries failed operations with exponential backoff, and ensures that entitlements are eventually consistent with the server-side state.
Wrapping up
In this article, you’ve explored the edge cases that separate a sample billing integration from a production one.
Each of these scenarios has a well defined solution using the Play Billing Library directly, but the cumulative implementation effort is significant. You need client-side handling, backend RTDN processing, retry mechanisms, and careful state management across all of them. For teams that want to ship subscription features without building and maintaining this infrastructure, RevenueCat handles these edge cases automatically, letting you check a single CustomerInfo object instead of managing the complexity yourself.
Whether you build the billing infrastructure directly or use RevenueCat, understanding these edge cases is essential. They represent the difference between a billing system that works in testing and one that works reliably for millions of users across diverse markets and payment methods.
As always, happy coding!
— Jaewoong

