Back to the RevenueCat homepage
RevenueCat SDKGoogle Play Billing

Chapter 8: Error Handling and Retry Strategies

Every call you make to the Play Billing Library can fail. Network connections drop, Google Play Services restarts, users cancel purchases, and payment methods get declined. If your app does not handle these failures properly, users see broken purchase flows, lose access to content they paid for, or give up and uninstall.

This chapter gives you a complete error handling strategy for Play Billing Library 8.x. You will learn what every BillingResponseCode means, which errors are retriable and which are not, and how to implement retry logic that recovers gracefully without hammering Google's servers.

Understanding BillingResult and BillingResponseCode

Every PBL operation returns a BillingResult object. This is the primary way Google Play communicates whether an operation succeeded or why it failed. The BillingResult contains two pieces of information:

  • responseCode: An integer constant from BillingClient.BillingResponseCode that tells you the category of the result.
  • debugMessage: A human readable string with additional context. This is useful for logging, but never show it to users and never parse it programmatically. Google can change these messages at any time.

Here is how you check a billing result:

kotlin
// PBL 8.x
val billingResult = billingClient.acknowledgePurchase(params)

when (billingResult.responseCode) {
    BillingClient.BillingResponseCode.OK -> {
        // Success. Proceed with your logic.
    }
    BillingClient.BillingResponseCode.NETWORK_ERROR -> {
        // Retriable. Schedule a retry.
    }
    else -> {
        Log.e("Billing", billingResult.debugMessage)
    }
}

The OK response code (value 0) means the operation completed successfully. Everything else indicates either a recoverable problem you can retry or a permanent failure you need to handle differently.

Here is the full set of response codes you will encounter in PBL 8.x:

Response Code

Value

Retriable?

OK

0

N/A

USER_CANCELED

1

No

SERVICE_UNAVAILABLE

2

Yes

BILLING_UNAVAILABLE

3

User initiated

ITEM_UNAVAILABLE

4

No

DEVELOPER_ERROR

5

No

ERROR

6

Yes

ITEM_ALREADY_OWNED

7

No (refresh cache)

ITEM_NOT_OWNED

8

No (refresh cache)

NETWORK_ERROR

12

Yes

SERVICE_DISCONNECTED

-1

Yes

SERVICE_TIMEOUT

-3

Yes

FEATURE_NOT_SUPPORTED

-2

No

You should handle every one of these in production code. Ignoring even one can lead to silent failures that are hard to debug.

Sub Response Codes in PBL 8.0+

Starting with PBL 8.0, Google added sub response codes that give you more specific information about why certain operations failed. These appear in the onPurchasesUpdated callback through the BillingResult and provide additional detail beyond the top level response code.

Two sub response codes are particularly useful:

PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS

This tells you the user's payment method was declined specifically because of insufficient funds. The top level response code for this is ERROR, but the sub response code lets you show a more helpful message. Instead of a generic "purchase failed" screen, you can suggest the user update their payment method or try a different one.

USER_INELIGIBLE

This indicates the user does not qualify for a specific offer. For example, if you have a free trial offer restricted to new subscribers and a returning subscriber tries to claim it, you get this sub response code. You can use this to show the user alternative offers they do qualify for.

You access sub response codes through the getSubResponseCode() method on BillingResult. Not every failure includes a sub response code, so always check whether one is present before acting on it. These sub response codes supplement your existing error handling logic rather than replacing it.

Retriable Errors and Their Strategies

Some billing errors are temporary. The operation failed now, but it might succeed if you try again. The key is knowing which errors to retry and how aggressively to retry them.

NETWORK_ERROR and SERVICE_TIMEOUT

These are the most common transient errors. NETWORK_ERROR means the device could not reach Google's servers. SERVICE_TIMEOUT means the request was sent but no response came back in time.

For both of these, a simple retry with a short delay works well. Start with a delay of one to two seconds and retry up to three times. If the user is on a flaky connection, a brief pause is often enough for the network to recover.

Do not retry immediately without any delay. Rapid fire retries on a struggling network connection just add noise and drain the user's battery.

SERVICE_DISCONNECTED

This means your BillingClient lost its connection to Google Play Services. In PBL 8.x, the library handles reconnection automatically in most cases. If auto reconnection does not resolve the issue, you fall back to calling startConnection() again manually.

kotlin
// PBL 8.x
private suspend fun ensureConnected(): Boolean {
    if (billingClient.isReady) return true
    val result = billingClient.startConnection()
    return result.responseCode ==
        BillingClient.BillingResponseCode.OK
}

After reconnecting, retry the original operation. In practice, SERVICE_DISCONNECTED often happens when the device wakes from sleep or when Google Play Services updates itself in the background.

SERVICE_UNAVAILABLE and ERROR

SERVICE_UNAVAILABLE means Google Play Services is temporarily overloaded or unreachable. ERROR is a general catch all for unexpected failures on Google's side. Both of these call for exponential backoff rather than simple retry. You start with a longer initial delay and increase it with each attempt, giving Google's services time to recover.

Use a base delay of two seconds, double it on each retry, and cap at three attempts. You will see the full implementation later in this chapter.

BILLING_UNAVAILABLE

This means billing is not available on the device at all. Common causes include:

  • Google Play Store is not installed (some sideloaded devices or emulators)
  • Google Play Store version is too old
  • The user's country does not support Google Play purchases
  • The user is not signed into a Google account

You cannot retry this automatically because the underlying problem requires user action. Show a message explaining that Google Play is not available and let the user fix the issue, whether that means signing in, updating the Play Store, or switching accounts. You can offer a "try again" button so the user can retry after resolving the problem.

ITEM_ALREADY_OWNED and ITEM_NOT_OWNED

These are not traditional retry candidates, but they indicate your local purchase state is out of sync with Google's records.

ITEM_ALREADY_OWNED happens when you try to purchase a non consumable product or subscription the user already owns. ITEM_NOT_OWNED happens when you try to consume or acknowledge a purchase that Google does not think the user has.

The fix is to refresh your local cache:

kotlin
// PBL 8.x
suspend fun refreshPurchases() {
    val params = QueryPurchasesParams.newBuilder()
        .setProductType(
            BillingClient.ProductType.SUBS
        )
        .build()
    val result = billingClient.queryPurchasesAsync(params)
    if (result.billingResult.responseCode ==
        BillingClient.BillingResponseCode.OK
    ) {
        // Update your local purchase state
        processPurchases(result.purchasesList)
    }
}

After refreshing, check whether the user actually owns the product. If they do, grant them access instead of trying to purchase again. If they do not, the purchase flow should work on the next attempt.

Non Retriable Errors

Some errors mean "stop trying." Retrying these wastes resources and creates a poor user experience.

FEATURE_NOT_SUPPORTED

This means the device or Play Store version does not support the feature you are trying to use. For example, calling a subscriptions API on a very old version of Google Play Services that does not support subscriptions.

The right approach is to check feature support before calling the method:

kotlin
// PBL 8.x
val result = billingClient.isFeatureSupported(
    BillingClient.FeatureType.SUBSCRIPTIONS
)
if (result.responseCode ==
    BillingClient.BillingResponseCode.OK
) {
    // Safe to use subscription features
} else {
    // Hide subscription UI elements
}

Check feature support once during initialization and adapt your UI accordingly. Do not show subscription options to users whose devices cannot handle them.

USER_CANCELED

The user dismissed the purchase dialog without completing the purchase. This is the most common "error" you will see, and it is not really an error at all. The user simply changed their mind.

Handle this gracefully. Return the user to wherever they were before the purchase flow started. Do not show an error dialog, do not show a "purchase failed" message, and definitely do not prompt them to try again immediately. A quiet return to the previous screen is the right behavior.

ITEM_UNAVAILABLE

The product you tried to query or purchase does not exist or is not active in the Play Console. This should not happen in production if your product configuration is correct.

If you encounter this, refresh your product details from Google Play:

kotlin
// PBL 8.x
suspend fun refreshProductDetails(
    productId: String
): ProductDetails? {
    val params = QueryProductDetailsParams.newBuilder()
        .setProductList(
            listOf(
                QueryProductDetailsParams.Product
                    .newBuilder()
                    .setProductId(productId)
                    .setProductType(
                        BillingClient.ProductType.INAPP
                    )
                    .build()
            )
        )
        .build()
    val result =
        billingClient.queryProductDetails(params)
    return result.productDetailsList?.firstOrNull()
}

If the product still comes back empty, it likely means the product was deactivated in the Play Console. Remove it from your UI.

DEVELOPER_ERROR

This is Google telling you that your API call is malformed. Common causes include:

  • Passing an invalid product ID
  • Using the wrong product type (e.g., querying a subscription as INAPP)
  • Providing invalid parameters to launchBillingFlow()
  • Calling methods before the BillingClient is connected

This error should never reach production. If it does, fix your code. Log the debugMessage because it usually contains specific information about what you did wrong.

Implementing Simple Retry

For transient errors like NETWORK_ERROR and SERVICE_TIMEOUT, a simple retry with a fixed delay handles most cases. Here is a reusable retry wrapper:

kotlin
// PBL 8.x
suspend fun <T> retryBillingCall(
    maxAttempts: Int = 3,
    delayMs: Long = 1_000L,
    block: suspend () -> T
): T {
    var lastException: Exception? = null
    repeat(maxAttempts) { attempt ->
        try {
            return block()
        } catch (e: Exception) {
            lastException = e
            if (attempt < maxAttempts - 1) {
                delay(delayMs)
            }
        }
    }
    throw lastException
        ?: IllegalStateException("Retry failed")
}

You use it like this:

kotlin
// PBL 8.x
val result = retryBillingCall(maxAttempts = 3) {
    billingClient.queryProductDetails(params)
}

This works well for operations that throw exceptions on transient failures. However, most PBL methods return a BillingResult instead of throwing. You need a version that understands billing response codes:

kotlin
// PBL 8.x
suspend fun <T> retryOnBillingError(
    maxAttempts: Int = 3,
    delayMs: Long = 1_000L,
    retriableCodes: Set<Int> = setOf(
        BillingClient.BillingResponseCode.NETWORK_ERROR,
        BillingClient.BillingResponseCode.SERVICE_TIMEOUT,
        BillingClient.BillingResponseCode.ERROR,
        BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE
    ),
    block: suspend () -> T
): T where T : BillingResult {
    var lastResult: T? = null
    repeat(maxAttempts) { attempt ->
        val result = block()
        if (result.responseCode !in retriableCodes) {
            return result
        }
        lastResult = result
        if (attempt < maxAttempts - 1) {
            delay(delayMs)
        }
    }
    return lastResult!!
}

This version checks the response code after each attempt. If the code is not in the retriable set, it returns immediately, whether it is OK or a non retriable error. Only retriable codes trigger another attempt.

Implementing Exponential Backoff

For errors like SERVICE_UNAVAILABLE and ERROR, exponential backoff is the better strategy. Instead of waiting the same amount of time between each retry, you double the delay each time. This gives overloaded services room to recover.

kotlin
// PBL 8.x
suspend fun <T> retryWithBackoff(
    maxAttempts: Int = 3,
    baseDelayMs: Long = 2_000L,
    factor: Double = 2.0,
    retriableCodes: Set<Int> = setOf(
        BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
        BillingClient.BillingResponseCode.ERROR,
        BillingClient.BillingResponseCode.NETWORK_ERROR,
        BillingClient.BillingResponseCode.SERVICE_TIMEOUT
    ),
    block: suspend () -> BillingResult
): BillingResult {
    var lastResult: BillingResult? = null
    repeat(maxAttempts) { attempt ->
        val result = block()
        if (result.responseCode !in retriableCodes) {
            return result
        }
        lastResult = result
        if (attempt < maxAttempts - 1) {
            val delayMs = baseDelayMs *
                factor.pow(attempt.toDouble())
            delay(delayMs.toLong())
        }
    }
    return lastResult!!
}

With a base delay of 2 seconds and a factor of 2, the delays look like this:

Attempt

Delay Before Retry

1st retry

2 seconds

2nd retry

4 seconds

3rd retry

8 seconds (if you extend to 4 attempts)

Three attempts with this pattern mean the entire sequence takes about 6 seconds in the worst case. That is a reasonable amount of time for the user to wait, and it gives Google's services meaningful breathing room between attempts.

You can add jitter (random variation) to the delay to prevent multiple devices from retrying at exactly the same time. This matters at scale when thousands of devices might hit the same transient error simultaneously:

kotlin
// PBL 8.x
val jitter = Random.nextLong(0, baseDelayMs / 2)
val delayMs = baseDelayMs *
    factor.pow(attempt.toDouble()) + jitter
delay(delayMs.toLong())

Adding jitter spreads retry attempts across a wider time window, reducing the chance of another wave of failures hitting the service all at once.

Proper Error Handling in onPurchasesUpdated

The onPurchasesUpdated callback is where you receive purchase results after a user interacts with the Google Play purchase dialog. This is the single most important place to get error handling right, because it directly affects whether users get the content they paid for.

Here is a complete implementation that handles every response code:

kotlin
// PBL 8.x
override fun onPurchasesUpdated(
    billingResult: BillingResult,
    purchases: List<Purchase>?
) {
    when (billingResult.responseCode) {
        BillingClient.BillingResponseCode.OK -> {
            purchases?.forEach { purchase ->
                handlePurchase(purchase)
            }
        }
        BillingClient.BillingResponseCode.USER_CANCELED -> {
            // User backed out. No action needed.
        }
        BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> {
            // Refresh local cache and grant access
            scope.launch { refreshPurchases() }
        }
        else -> {
            Log.e("Billing",
                "Purchase failed: " +
                "${billingResult.responseCode} " +
                billingResult.debugMessage
            )
            showPurchaseError(billingResult)
        }
    }
}

A few things to note about this implementation:

  1. OK with null purchases: Even when the response code is OK, the purchases list can theoretically be null. Always null check it.
  2. USER_CANCELED does nothing: No error messages, no prompts, no analytics events marking it as a failure. The user made a deliberate choice.
  3. ITEM_ALREADY_OWNED refreshes state: Instead of showing an error, you query for the user's current purchases and grant access. This handles the case where a purchase succeeded but your app crashed before processing it.
  4. Everything else gets logged: The debugMessage goes into your logs for investigation. The user sees a generic, friendly error message.

For the handlePurchase function called on success, make sure you verify the purchase on your backend and acknowledge it within 3 days. An unacknowledged purchase gets refunded automatically by Google.

The Complete Decision Tree

Bringing everything together, here is the full decision tree for handling any BillingResponseCode. This function takes a billing result and the operation that produced it, then decides what to do:

kotlin
// PBL 8.x
sealed class BillingAction {
    data object Success : BillingAction()
    data object RetrySimple : BillingAction()
    data object RetryBackoff : BillingAction()
    data object Reconnect : BillingAction()
    data object RefreshPurchases : BillingAction()
    data object RefreshProducts : BillingAction()
    data object UserRetry : BillingAction()
    data object Ignore : BillingAction()
    data class Fail(val msg: String) : BillingAction()
}
kotlin
// PBL 8.x
fun decideBillingAction(code: Int): BillingAction =
    when (code) {
        BillingResponseCode.OK -> BillingAction.Success
        BillingResponseCode.NETWORK_ERROR,
        BillingResponseCode.SERVICE_TIMEOUT ->
            BillingAction.RetrySimple
        BillingResponseCode.SERVICE_UNAVAILABLE,
        BillingResponseCode.ERROR ->
            BillingAction.RetryBackoff
        BillingResponseCode.SERVICE_DISCONNECTED ->
            BillingAction.Reconnect
        BillingResponseCode.ITEM_ALREADY_OWNED,
        BillingResponseCode.ITEM_NOT_OWNED ->
            BillingAction.RefreshPurchases
        BillingResponseCode.BILLING_UNAVAILABLE ->
            BillingAction.UserRetry
        BillingResponseCode.USER_CANCELED ->
            BillingAction.Ignore
        BillingResponseCode.ITEM_UNAVAILABLE ->
            BillingAction.RefreshProducts
        BillingResponseCode.FEATURE_NOT_SUPPORTED ->
            BillingAction.Fail("Feature not supported")
        BillingResponseCode.DEVELOPER_ERROR ->
            BillingAction.Fail("Developer error")
        else -> BillingAction.Fail("Unknown error")
    }

Then you wire up the decision tree to your actual retry and recovery logic:

kotlin
// PBL 8.x
suspend fun executeBillingAction(
    action: BillingAction,
    operation: suspend () -> BillingResult
): BillingResult = when (action) {
    is BillingAction.Success -> BillingResult
        .newBuilder()
        .setResponseCode(BillingResponseCode.OK)
        .build()
    is BillingAction.RetrySimple ->
        retryOnBillingError { operation() }
    is BillingAction.RetryBackoff ->
        retryWithBackoff { operation() }
    is BillingAction.Reconnect -> {
        ensureConnected(); operation()
    }
    is BillingAction.RefreshPurchases -> {
        refreshPurchases(); operation()
    }
    is BillingAction.RefreshProducts -> operation()
    is BillingAction.UserRetry,
    is BillingAction.Ignore,
    is BillingAction.Fail -> BillingResult
        .newBuilder()
        .setResponseCode(BillingResponseCode.ERROR)
        .build()
}

This pattern separates the decision about what to do from the execution of that decision. You can test the decision logic with simple unit tests against response codes, without needing a real BillingClient. The execution layer handles the actual retries, reconnections, and cache refreshes.

Putting It All Together

In production, your billing wrapper ties all of these pieces together. Every operation goes through the decision tree, and the wrapper handles retries transparently:

kotlin
// PBL 8.x
suspend fun queryProducts(
    params: QueryProductDetailsParams
): QueryProductDetailsResult {
    var lastResult: QueryProductDetailsResult? = null
    for (attempt in 1..3) {
        val result =
            billingClient.queryProductDetails(params)
        lastResult = result
        if (result.billingResult.responseCode ==
            BillingResponseCode.OK
        ) return result
        if (result.billingResult.responseCode !in
            retriableCodes
        ) return result
        delay(2_000L * attempt)
    }
    return lastResult!!
}
kotlin
// PBL 8.x
suspend fun acknowledgePurchase(
    token: String
): BillingResult {
    val params = AcknowledgePurchaseParams
        .newBuilder()
        .setPurchaseToken(token)
        .build()
    return retryWithBackoff {
        billingClient.acknowledgePurchase(params)
    }
}

The calling code does not need to worry about transient errors. The retry logic handles them automatically. When a non retriable error does surface, it propagates up to the UI layer where you can show the appropriate message.

What to Show Users

Error handling is not just about code. It is about the user experience when things go wrong. Here are practical guidelines:

  • Network errors during retry: Show a subtle loading indicator. Do not flash error messages between retry attempts.
  • Final failure after retries exhausted: Show a clear message like "Something went wrong. Please try again." with a retry button.
  • BILLING_UNAVAILABLE: Show "Google Play is not available. Please check that you're signed in to the Play Store."
  • ITEM_ALREADY_OWNED: Skip the error entirely. Refresh your purchase state and grant access silently.
  • USER_CANCELED: Show nothing. Return to the previous screen.
  • Payment declined (sub response code): Show "Your payment method was declined. Please update your payment method in Google Play and try again."

Never show raw error codes or debug messages to users. Never show technical details about what went wrong. Keep messages actionable. Tell the user what they can do, not what the system failed to do.

Want a simpler approach?

The RevenueCat SDK Handbook covers the same topics — with less code and a managed backend.

Related chapters

  • Chapter 6: The Purchase Flow

    End-to-end from launchBillingFlow() to acknowledgement: payment dialog, callbacks, pending purchases, and multi-quantity.

    Learn more
  • Chapter 5: Integrating the Play Billing Library

    BillingClient setup, connection lifecycle, product queries, and the four builder methods you must call correctly.

    Learn more
  • BillingResult Response Code Reference

    All BillingResponseCode values, sub response codes, retry strategies, and the error handling decision tree.

    Learn more
Error Handling and Retry Strategies | RevenueCat