---
title: "Handling edge cases in Google Play Billing"
description: "Explains how to correctly handle common edge cases in Google Play Billing, including pending purchases, ITEM_ALREADY_OWNED errors, multi-quantity consumables, subscription downgrades, and network failures."
language: "en"
publishedAt: "2026-02-02T02:06:07Z"
updatedAt: "2026-02-02T02:06:07Z"
authors:
  - name: "Jaewoong Eum"
    url: "https://www.revenuecat.com/blog/author/jaewoong-eum"
category: "Engineering"
categoryUrl: "https://www.revenuecat.com/blog/engineering"
readingTime: 10
canonical: "https://www.revenuecat.com/blog/engineering/google-play-edge-cases"
---

# Handling edge cases in Google Play Billing

Explains how to correctly handle common edge cases in Google Play Billing, including pending purchases, ITEM_ALREADY_OWNED errors, multi-quantity consumables, subscription downgrades, and network failures.

## Table of contents

- [The fundamental problem: the happy path is not enough](#the-fundamental-problem-the-happy-path-is-not-enough)
- [Pending purchases: when payment is not immediate](#pending-purchases-when-payment-is-not-immediate)
  - [Why pending purchases happen](#why-pending-purchases-happen)
  - [Detecting and handling the pending state](#detecting-and-handling-the-pending-state)
  - [Completing a pending purchase](#completing-a-pending-purchase)
  - [Enabling pending purchases in your BillingClient](#enabling-pending-purchases-in-your-billingclient)
- [The ITEM_ALREADY_OWNED response: a common source of confusion](#the-itemalreadyowned-response-a-common-source-of-confusion)
  - [Why ITEM_ALREADY_OWNED happens](#why-itemalreadyowned-happens)
  - [Handling ITEM_ALREADY_OWNED correctly](#handling-itemalreadyowned-correctly)
  - [Preventing ITEM_ALREADY_OWNED proactively](#preventing-itemalreadyowned-proactively)
- [Consumable purchases: acknowledge vs. consume](#consumable-purchases-acknowledge-vs-consume)
  - [The acknowledgment and consumption flow](#the-acknowledgment-and-consumption-flow)
  - [The multi-quantity edge case](#the-multi-quantity-edge-case)
  - [The consumption retry problem](#the-consumption-retry-problem)
- [Subscription downgrades and proration modes](#subscription-downgrades-and-proration-modes)
  - [The default downgrade behavior](#the-default-downgrade-behavior)
  - [Understanding replacement modes](#understanding-replacement-modes)
  - [The deferred downgrade pitfall](#the-deferred-downgrade-pitfall)
  - [The linked purchase token on plan changes](#the-linked-purchase-token-on-plan-changes)
- [Network failures and retry strategies](#network-failures-and-retry-strategies)
  - [The acknowledgment window](#the-acknowledgment-window)
  - [BillingClient disconnection](#billingclient-disconnection)
- [How RevenueCat handles these edge cases](#how-revenuecat-handles-these-edge-cases)
  - [Pending purchases](#pending-purchases)
  - [Acknowledgment and consumption](#acknowledgment-and-consumption)
  - [Subscription plan changes](#subscription-plan-changes)
  - [Network resilience](#network-resilience)
- [Wrapping up](#wrapping-up)

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:

```kotlin
\/\/ The happy path
billingClient.launchBillingFlow(activity, params)

\/\/ In PurchasesUpdatedListener
override fun onPurchasesUpdated(
    billingResult: BillingResult,
    purchases: List<Purchase>?
) {
    if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
        purchases?.forEach { purchase ->
            if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
                acknowledgePurchase(purchase)
                grantEntitlement(purchase)
            }
        }
    }
}
```

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:

```kotlin
override fun onPurchasesUpdated(
    billingResult: BillingResult,
    purchases: List<Purchase>?
) {
    if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
        purchases?.forEach { purchase ->
            when (purchase.purchaseState) {
                Purchase.PurchaseState.PURCHASED -> {
                    \/\/ Payment complete, grant access
                    processPurchase(purchase)
                }
                Purchase.PurchaseState.PENDING -> {
                    \/\/ Payment not yet complete
                    handlePendingPurchase(purchase)
                }
                Purchase.PurchaseState.UNSPECIFIED_STATE -> {
                    \/\/ Unknown state, query backend for clarification
                    queryBackendForState(purchase)
                }
            }
        }
    }
}
```

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:

```kotlin
fun handlePendingPurchase(purchase: Purchase) {
    \/\/ Store the pending purchase token for later verification
    purchaseRepository.savePendingPurchase(
        purchaseToken = purchase.purchaseToken,
        productId = purchase.products.first(),
        orderId = purchase.orderId,
        purchaseTime = purchase.purchaseTime,
    )

    \/\/ Show clear UI to the user
    showPendingUI(
        message = "Your purchase is being processed. " +
            "You'll get access once payment is confirmed.",
    )
}
```

### **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:

```kotlin
\/\/ Backend notification handler
fun handlePurchaseNotification(notification: DeveloperNotification) {
    val purchaseToken = notification.oneTimeProductNotification?.purchaseToken
        ?: notification.subscriptionNotification?.purchaseToken
        ?: return

    val pendingPurchase = purchaseRepository.findPendingPurchase(purchaseToken)
    if (pendingPurchase != null) {
        \/\/ Previously pending purchase is now complete
        val purchaseDetails = playDeveloperApi
            .purchases()
            .products()
            .get(packageName, pendingPurchase.productId, purchaseToken)
            .execute()

        if (purchaseDetails.purchaseState == 0) { \/\/ 0 = Purchased
            entitlementRepository.grantEntitlement(
                userId = pendingPurchase.userId,
                productId = pendingPurchase.productId,
            )
            purchaseRepository.markCompleted(purchaseToken)

            \/\/ Notify user that their purchase is ready
            notificationService.sendPushNotification(
                userId = pendingPurchase.userId,
                title = "Purchase Complete",
                body = "Your purchase has been confirmed. Enjoy your content!",
            )
        }
    }
}
```

### **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:

```kotlin
val billingClient = BillingClient.newBuilder(context)
    .setListener(purchasesUpdatedListener)
    .enablePendingPurchases(
        PendingPurchasesParams.newBuilder()
            .enableOneTimeProducts()
            .enablePrepaidPlans()
            .build()
    )
    .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:

```kotlin
override fun onPurchasesUpdated(
    billingResult: BillingResult,
    purchases: List<Purchase>?
) {
    when (billingResult.responseCode) {
        BillingClient.BillingResponseCode.OK -> {
            purchases?.forEach { processPurchase(it) }
        }
        BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> {
            \/\/ Query existing purchases and process any unacknowledged ones
            recoverUnacknowledgedPurchases()
        }
        BillingClient.BillingResponseCode.USER_CANCELED -> {
            \/\/ User backed out, no action needed
        }
        else -> {
            handleBillingError(billingResult)
        }
    }
}

private fun recoverUnacknowledgedPurchases() {
    val params = QueryPurchasesParams.newBuilder()
        .setProductType(BillingClient.ProductType.INAPP)
        .build()

    billingClient.queryPurchasesAsync(params) { billingResult, purchases ->
        if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
            purchases.forEach { purchase ->
                if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED &&
                    !purchase.isAcknowledged
                ) {
                    \/\/ Found the unacknowledged purchase, process it
                    processPurchase(purchase)
                }
            }
        }
    }
}
```

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:

```kotlin
fun processUnacknowledgedPurchasesOnStartup() {
    val inAppParams = QueryPurchasesParams.newBuilder()
        .setProductType(BillingClient.ProductType.INAPP)
        .build()

    val subsParams = QueryPurchasesParams.newBuilder()
        .setProductType(BillingClient.ProductType.SUBS)
        .build()

    billingClient.queryPurchasesAsync(inAppParams) { result, purchases ->
        if (result.responseCode == BillingClient.BillingResponseCode.OK) {
            purchases.filter {
                it.purchaseState == Purchase.PurchaseState.PURCHASED &&
                    !it.isAcknowledged
            }.forEach { processPurchase(it) }
        }
    }

    billingClient.queryPurchasesAsync(subsParams) { result, purchases ->
        if (result.responseCode == BillingClient.BillingResponseCode.OK) {
            purchases.filter {
                it.purchaseState == Purchase.PurchaseState.PURCHASED &&
                    !it.isAcknowledged
            }.forEach { processPurchase(it) }
        }
    }
```

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:

```kotlin
fun processConsumablePurchase(purchase: Purchase) {
    \/\/ Verify with backend first
    verifyPurchaseWithBackend(purchase) { isValid ->
        if (isValid) {
            \/\/ Grant the consumable content
            grantConsumableContent(purchase)

            \/\/ Consume the purchase (this also acknowledges it)
            val consumeParams = ConsumeParams.newBuilder()
                .setPurchaseToken(purchase.purchaseToken)
                .build()

            billingClient.consumeAsync(consumeParams) { billingResult, _ ->
                if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                    \/\/ Retry consumption, the user cannot rebuy until consumed
                    scheduleConsumptionRetry(purchase.purchaseToken)
                }
            }
        }
    }
}
```

### **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:

```kotlin
fun processConsumablePurchase(purchase: Purchase) {
    val quantity = purchase.quantity  \/\/ Could be > 1

    verifyPurchaseWithBackend(purchase) { isValid ->
        if (isValid) {
            \/\/ Grant the correct quantity
            grantConsumableContent(purchase.products.first(), quantity)

            val consumeParams = ConsumeParams.newBuilder()
                .setPurchaseToken(purchase.purchaseToken)
                .build()

            billingClient.consumeAsync(consumeParams) { billingResult, _ ->
                if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
                    scheduleConsumptionRetry(purchase.purchaseToken)
                }
            }
        }
    }
}
```

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:

```kotlin
val billingFlowParams = BillingFlowParams.newBuilder()
    .setProductDetailsParamsList(
        listOf(
            BillingFlowParams.ProductDetailsParams.newBuilder()
                .setProductDetails(productDetails)
                .build()
        )
    )
    .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:

1. The user cannot buy the same consumable again
1. The purchase may be refunded after three days if not acknowledged (though consumption implicitly acknowledges)
You should implement a retry mechanism for failed consumptions:

```kotlin
class ConsumptionRetryManager(
    private val billingClient: BillingClient,
    private val purchaseRepository: PurchaseRepository,
) {
    fun scheduleConsumptionRetry(purchaseToken: String) {
        purchaseRepository.markPendingConsumption(purchaseToken)
    }

    fun retryPendingConsumptions() {
        val pendingTokens = purchaseRepository.getPendingConsumptionTokens()

        pendingTokens.forEach { token ->
            val consumeParams = ConsumeParams.newBuilder()
                .setPurchaseToken(token)
                .build()

            billingClient.consumeAsync(consumeParams) { billingResult, _ ->
                if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                    purchaseRepository.clearPendingConsumption(token)
                }
                \/\/ If still failing, it will be retried next time
            }
        }
    }
}
```

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.

```kotlin
fun launchDowngrade(
    activity: Activity,
    newProductDetails: ProductDetails,
    newOfferToken: String,
    currentPurchaseToken: String,
) {
    val billingFlowParams = BillingFlowParams.newBuilder()
        .setProductDetailsParamsList(
            listOf(
                BillingFlowParams.ProductDetailsParams.newBuilder()
                    .setProductDetails(newProductDetails)
                    .setOfferToken(newOfferToken)
                    .build()
            )
        )
        .setSubscriptionUpdateParams(
            BillingFlowParams.SubscriptionUpdateParams.newBuilder()
                .setOldPurchaseToken(currentPurchaseToken)
                .setSubscriptionReplacementMode(
                    BillingFlowParams.SubscriptionUpdateParams
                        .ReplacementMode.DEFERRED
                )
                .build()
        )
        .build()

    billingClient.launchBillingFlow(activity, billingFlowParams)
}
```

### **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:

```kotlin
fun handleDowngradeResult(purchase: Purchase) {
    \/\/ After a deferred downgrade, the purchase still reflects
    \/\/ the OLD subscription until the next renewal
    val currentProductId = purchase.products.first()

    \/\/ Query the subscription status from your backend to check
    \/\/ if a deferred downgrade is pending
    checkBackendForPendingDowngrade(purchase.purchaseToken) { pendingDowngrade ->
        if (pendingDowngrade != null) {
            \/\/ Show UI indicating the downgrade is scheduled
            showDowngradeScheduledUI(
                currentPlan = currentProductId,
                futurePlan = pendingDowngrade.newProductId,
                effectiveDate = pendingDowngrade.effectiveDate,
            )
        } else {
            \/\/ Normal subscription state
            showSubscriptionUI(currentProductId)
        }
    }
}
```

### **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:

```kotlin
\/\/ Backend handler for subscription replacement
fun handleSubscriptionReplacement(newPurchaseToken: String) {
    val subscription = playDeveloperApi
        .purchases()
        .subscriptionsv2()
        .get(packageName, newPurchaseToken)
        .execute()

    val linkedToken = subscription.linkedPurchaseToken

    if (linkedToken != null) {
        \/\/ This is a plan change, not a new purchase
        val userId = userRepository.findByPurchaseToken(linkedToken)

        \/\/ Update to new token
        userRepository.updatePurchaseToken(userId, newPurchaseToken)

        \/\/ Invalidate old token to prevent double-counting
        subscriptionRepository.invalidate(linkedToken)
    }

    \/\/ Acknowledge the new purchase
    acknowledgePurchase(newPurchaseToken)
}
```

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:

```kotlin
class AcknowledgmentManager(
    private val billingClient: BillingClient,
    private val purchaseRepository: PurchaseRepository,
) {
    fun acknowledgePurchaseWithRetry(purchase: Purchase) {
        if (purchase.isAcknowledged) return

        val params = AcknowledgePurchaseParams.newBuilder()
            .setPurchaseToken(purchase.purchaseToken)
            .build()

        billingClient.acknowledgePurchase(params) { billingResult ->
            when (billingResult.responseCode) {
                BillingClient.BillingResponseCode.OK -> {
                    purchaseRepository.markAcknowledged(purchase.purchaseToken)
                }
                BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
                BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
                BillingClient.BillingResponseCode.ERROR -> {
                    \/\/ Transient failure, schedule retry
                    purchaseRepository.markPendingAcknowledgment(
                        purchase.purchaseToken
                    )
                }
                else -> {
                    \/\/ Non-retryable error, log for investigation
                    logAcknowledgmentFailure(purchase, billingResult)
                }
            }
        }
    }

    fun retryPendingAcknowledgments() {
        val pendingTokens = purchaseRepository.getPendingAcknowledgmentTokens()

        val inAppParams = QueryPurchasesParams.newBuilder()
            .setProductType(BillingClient.ProductType.INAPP)
            .build()

        billingClient.queryPurchasesAsync(inAppParams) { result, purchases ->
            if (result.responseCode == BillingClient.BillingResponseCode.OK) {
                purchases
                    .filter { it.purchaseToken in pendingTokens }
                    .filter { !it.isAcknowledged }
                    .forEach { acknowledgePurchaseWithRetry(it) }
            }
        }
    }
}
```

### **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:

```kotlin
class BillingClientManager(
    private val context: Context,
    private val listener: PurchasesUpdatedListener,
) {
    private var billingClient: BillingClient? = null
    private var retryCount = 0

    fun connect(onConnected: () -> Unit) {
        billingClient = BillingClient.newBuilder(context)
            .setListener(listener)
            .enablePendingPurchases(
                PendingPurchasesParams.newBuilder()
                    .enableOneTimeProducts()
                    .build()
            )
            .build()

        billingClient?.startConnection(object : BillingClientStateListener {
            override fun onBillingSetupFinished(billingResult: BillingResult) {
                if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                    retryCount = 0
                    onConnected()
                } else {
                    retryConnection(onConnected)
                }
            }

            override fun onBillingServiceDisconnected() {
                retryConnection(onConnected)
            }
        })
    }

    private fun retryConnection(onConnected: () -> Unit) {
        if (retryCount < MAX_RETRY_COUNT) {
            retryCount++
            val delayMs = (1000L * (1 shl retryCount)).coerceAtMost(MAX_RETRY_DELAY_MS)
            handler.postDelayed({ connect(onConnected) }, delayMs)
        }
    }

    companion object {
        private const val MAX_RETRY_COUNT = 5
        private const val MAX_RETRY_DELAY_MS = 30_000L
    }
}
```

## **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:

```kotlin
Purchases.sharedInstance.getCustomerInfoWith { customerInfo ->
    val isPremium = customerInfo.entitlements["premium"]?.isActive == true

    if (isPremium) {
        showPremiumContent()
    } else {
        showSubscriptionOptions()
    }
}
```

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`:

```kotlin
Purchases.sharedInstance.purchaseWith(
    PurchaseParams.Builder(activity, newPackage)
        .oldProductId(currentProductId)
        .googleReplacementMode(GoogleReplacementMode.DEFERRED)
        .build(),
    onSuccess = { transaction, customerInfo ->
        \/\/ CustomerInfo reflects the new subscription state
        updateUI(customerInfo)
    },
    onError = { error, userCancelled ->
        if (!userCancelled) {
            showError(error)
        }
    }
)
```

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:

```kotlin
\/\/ This works offline using cached CustomerInfo
Purchases.sharedInstance.getCustomerInfoWith { customerInfo ->
    val isPremium = customerInfo.entitlements["premium"]?.isActive == true
    updateUI(isPremium)
}
```

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

---

## Related posts

- [Understanding Google Play subscription proration: a developer’s guide](https://www.revenuecat.com/blog/engineering/google-proration)
- [Android SDK lifecycle management with Hilt dependency injection](https://www.revenuecat.com/blog/engineering/hilt-sdk-lifecycle)
- [Understanding Google Play’s subscription lifecycle: a complete guide](https://www.revenuecat.com/blog/engineering/google-play-lifecycle)
