Back to the RevenueCat homepage
RevenueCat SDKGoogle Play Billing

Chapter 5: Integrating the Play Billing Library

The Play Billing Library (PBL) is your app's interface to Google Play's billing system. Every purchase, product query, and feature check flows through the BillingClient class. This chapter covers how to set up BillingClient correctly, query products, and prepare your app for purchases.

Initializing BillingClient

The BillingClient is the single entry point for all billing operations. You create one using BillingClient.Builder:

kotlin
val billingClient = BillingClient.newBuilder(context)
    .setListener(purchasesUpdatedListener)
    .enablePendingPurchases(
        PendingPurchasesParams.newBuilder().build()
    )
    .enableAutoServiceReconnection()
    .build()

Four things happen in this setup:

  1. newBuilder(context): You pass a Context to the builder. This should be your Application context or an activity context. If you pass an activity context, the BillingClient holds a reference to it, so you need to be careful about leaking activities. The safest choice is applicationContext, which avoids lifecycle issues entirely. If you pass a null context or a context from a destroyed activity, the builder call will succeed but startConnection() will fail later with obscure errors.
  2. setListener: You provide a PurchasesUpdatedListener that receives all purchase results. This is a required call. If you skip it, build() throws an IllegalArgumentException. You can only set one listener per BillingClient instance. If you need multiple components to react to purchases, have your single listener dispatch events through a shared event bus, a Flow, or a callback list.
  3. enablePendingPurchases: This tells PBL that your app can handle purchases that are not immediately completed (for example, when a user pays with cash at a convenience store). In PBL 8, this method requires a PendingPurchasesParams argument. This is mandatory. If you forget to call it, build() throws an IllegalStateException at runtime. The PendingPurchasesParams.newBuilder().build() call uses default settings, which enables pending purchases for both one time and subscription products.
  4. enableAutoServiceReconnection: New in PBL 8, this tells the library to automatically reconnect to Google Play Services if the connection drops. Without this, you would need to manually detect disconnections and call startConnection() again. When enabled, PBL uses an exponential backoff strategy internally to retry the connection, so you do not need to implement your own retry logic for the connection itself.

Common Initialization Mistakes

A few patterns cause problems in production:

  • Creating multiple BillingClient instances: Each instance opens its own connection to Google Play Services. If you create a new one every time an activity starts, you waste resources and may hit connection limits. Stick to one instance per process.
  • Forgetting to call endConnection(): When your billing scope is destroyed (for example, when the user logs out or when an activity scoped client is no longer needed), call billingClient.endConnection(). Failing to do this leaks the service connection.
  • Using the wrong context: Passing a Fragment context or a context from an inner class can lead to memory leaks. Always prefer applicationContext when the BillingClient outlives an activity.
  • Calling billing methods before connection is ready: If you call queryProductDetailsAsync() before onBillingSetupFinished fires with OK, the call returns SERVICE_DISCONNECTED. Queue your operations and execute them only after the connection succeeds.

Where to Create BillingClient

Create your BillingClient in a scope that survives configuration changes. Good options include:

  • A ViewModel scoped to your billing screen or activity
  • A singleton BillingRepository in your dependency injection graph
  • An Application scoped object if billing is central to your app

Avoid creating BillingClient in a Fragment or Activity directly, as configuration changes would destroy and recreate it, wasting connections.

The PurchasesUpdatedListener

The listener receives results for every purchase initiated by launchBillingFlow():

kotlin
val purchasesUpdatedListener =
    PurchasesUpdatedListener { billingResult, purchases ->
        when (billingResult.responseCode) {
            BillingResponseCode.OK -> {
                purchases?.forEach { purchase ->
                    handlePurchase(purchase)
                }
            }
            BillingResponseCode.USER_CANCELED -> {
                // User backed out of the purchase flow
            }
            else -> {
                // Handle other error codes
                logError(billingResult)
            }
        }
    }

This listener fires for purchases made through your app's UI. It does not fire for purchases that happen outside your app, such as subscription renewals or purchases restored from another device. For those, you need queryPurchasesAsync(), covered later in this chapter.

Thread Safety and Lifecycle

The PurchasesUpdatedListener callback fires on the main thread. This means you should not perform long running operations directly inside it. If you need to verify a purchase with your backend server, launch a coroutine or hand the work off to a background thread. Blocking the main thread in this callback freezes the UI and can cause ANRs.

The listener is tied to the BillingClient instance. If you call endConnection(), the listener will no longer receive callbacks. If the BillingClient reconnects (through auto reconnection), the same listener is reused. You do not need to register it again after a reconnection.

One important consideration: the listener can fire even when your app is in the background. If the user completes a purchase flow and then quickly switches away from your app, the callback may arrive while your activity is stopped. Make sure your listener does not directly update UI elements. Instead, update your data layer (a repository or a state holder) and let your UI observe the changes when it returns to the foreground.

The purchases parameter in the callback can be null when the billingResult response code is not OK. Always check for null before iterating. A common mistake is to force unwrap this list and crash when the user cancels the purchase flow.

Connecting to Google Play

After building BillingClient, you must connect to Google Play Services before calling any other method:

kotlin
billingClient.startConnection(
    object : BillingClientStateListener {
        override fun onBillingSetupFinished(
            billingResult: BillingResult
        ) {
            if (billingResult.responseCode ==
                BillingResponseCode.OK
            ) {
                // Ready to query products and make purchases
                onBillingReady()
            }
        }

        override fun onBillingServiceDisconnected() {
            // Connection lost. With auto reconnection
            // enabled, PBL will retry automatically.
        }
    }
)

With enableAutoServiceReconnection() enabled, you do not need to manually call startConnection() again in onBillingServiceDisconnected(). The library handles reconnection for you. However, any operations that were in progress at the time of disconnection will fail and need to be retried by your code.

Connection States and What Happens When It Drops

The BillingClient connection can be in one of three states at any given time: disconnected, connecting, or connected. You can check the current state using billingClient.isReady, which returns true only when the connection is fully established and ready to handle requests.

When the connection drops, the onBillingServiceDisconnected() callback fires. This can happen for several reasons: Google Play Services being updated in the background, the system killing the Play Store process to reclaim memory, or a network interruption. With auto reconnection enabled, PBL retries the connection automatically using exponential backoff. The onBillingSetupFinished callback fires again once the connection is restored.

Any API call you make while the connection is down returns SERVICE_DISCONNECTED as the response code. Your code should handle this by queuing the failed operation and retrying it once onBillingSetupFinished fires again with OK. A practical pattern is to maintain a list of pending operations and flush them each time the connection comes back up.

If you are not using auto reconnection (for example, because you need fine grained control), you need to implement your own retry logic in onBillingServiceDisconnected(). Use exponential backoff with a cap to avoid overwhelming the system. A starting delay of 1 second, doubling up to a maximum of 15 seconds, works well for most apps.

If you are using the KTX extensions, the coroutine version is cleaner:

kotlin
// KTX coroutine extension (suspending)
val billingResult = billingClient.startConnection()
if (billingResult.responseCode ==
    BillingResponseCode.OK
) {
    onBillingReady()
}

Querying Available Products

Once connected, you can query product details. This fetches current pricing, descriptions, and offer information from Google Play:

kotlin
val productList = listOf(
    QueryProductDetailsParams.Product.newBuilder()
        .setProductId("premium_monthly")
        .setProductType(ProductType.SUBS)
        .build(),
    QueryProductDetailsParams.Product.newBuilder()
        .setProductId("remove_ads")
        .setProductType(ProductType.INAPP)
        .build()
)

val params = QueryProductDetailsParams.newBuilder()
    .setProductList(productList)
    .build()

billingClient.queryProductDetailsAsync(params) {
    billingResult, productDetailsList ->
    if (billingResult.responseCode ==
        BillingResponseCode.OK
    ) {
        displayProducts(productDetailsList)
    }
}

You must specify both the product ID and the product type (INAPP for one time products, SUBS for subscriptions). Querying the wrong type for a product ID returns no results for that product.

Batching and Performance

You can include multiple products in a single query, and you should. Each queryProductDetailsAsync call is a round trip to Google Play's servers. If you have ten products, querying them all in one call is significantly faster than making ten individual calls. Google Play handles batches of up to 20 products per query efficiently. If you have more than 20 products, split them into batches of 20 and run the queries in parallel.

You can mix INAPP and SUBS products in the same product list. PBL handles them in one round trip regardless of type.

Handling Empty Results

When the response code is OK but the productDetailsList is empty, it means none of your product IDs matched anything in Google Play. This usually happens for one of these reasons: the product IDs in your code do not match what you configured in the Google Play Console, the products are in draft status and have not been activated yet, or you are testing on a device that is not signed into a Google account that has access to your app's test track.

If the list is partially empty (you queried five products but only got three back), the missing products may have mismatched IDs or types. Check that each product ID and type pair matches your Play Console configuration exactly. Product IDs are case sensitive.

Understanding QueryProductDetailsResult

In PBL 8, the result of queryProductDetailsAsync() can include UnfetchedProduct entries. An UnfetchedProduct indicates that a product ID was recognized but its details could not be fetched. Each UnfetchedProduct carries its own BillingResult with a response code that tells you why the fetch failed.

The response codes you may see on an UnfetchedProduct include:

  • SERVICE_UNAVAILABLE: Google Play's servers could not be reached. This is a transient error, often caused by network issues. Retry the query after a short delay.
  • SERVICE_TIMEOUT: The request took too long to complete. This typically happens on slow connections. Retry with backoff.
  • ERROR: A general server side error occurred. This is also typically transient. Retry, but if it persists across multiple attempts, check the Play Console for product configuration issues.
  • DEVELOPER_ERROR: The product ID or type is invalid. This is not transient. Check your product configuration in the Google Play Console.

A practical approach is to collect the unfetched product IDs, filter out any with DEVELOPER_ERROR (which will never succeed), and retry the remaining ones. Limit your retries to two or three attempts with exponential backoff. If products remain unfetched after that, show the user the products you did successfully fetch and log the failures for later investigation.

Why You Should Never Cache ProductDetails

ProductDetails objects contain pricing information that can change at any time. If you cache them, you risk showing outdated prices to users, which leads to a poor experience and potential policy violations. Always query fresh ProductDetails before displaying products to the user. The only caching you should do is within a single session or screen lifecycle, and only if you query again when the user navigates back.

Checking Feature Support

Not all devices and Google Play versions support every billing feature. Before using advanced features, check support:

kotlin
val result = billingClient.isFeatureSupported(
    BillingClient.FeatureType.SUBSCRIPTIONS
)
if (result.responseCode == BillingResponseCode.OK) {
    // Subscriptions are supported
}

Available feature types include:

  • SUBSCRIPTIONS: Basic subscription support. Almost universally available on modern devices, but older devices running very old Play Store versions may not support it.
  • SUBSCRIPTIONS_UPDATE: Subscription upgrades and downgrades. Required before calling launchBillingFlow() with a subscription replacement. Without this, users on older Play Store versions cannot switch between subscription tiers.
  • PRICE_CHANGE_CONFIRMATION: In app price change confirmation flow. Lets you show a dialog when a subscription price changes so the user can accept or decline. If unsupported, you need to handle price changes through other channels like email.
  • IN_APP_MESSAGING: In app messaging for payment issues such as declined credit cards. When supported, Google Play can show messages directly in your app prompting the user to fix payment problems. This reduces involuntary churn from payment failures.
  • PRODUCT_DETAILS: The ProductDetails API introduced in PBL 5. On very old Play Store versions, only the deprecated SkuDetails API is available. In PBL 8, this feature should be available on virtually all active devices.
  • ALTERNATIVE_BILLING: Support for alternative billing. This is relevant in markets where regulations require apps to offer alternative payment methods.
  • ALTERNATIVE_BILLING_ONLY: The app uses only alternative billing with no Google Play billing. This is a distinct feature from ALTERNATIVE_BILLING, which allows both.
  • EXTERNAL_OFFER: Support for linking to external offers. Relevant for apps that qualify under specific regional regulations to direct users to web based purchasing.

If a feature is not supported, do not call its related APIs. The call may return FEATURE_NOT_SUPPORTED or behave unpredictably. Instead, fall back to alternative behavior or hide the related UI element. A good practice is to check feature support once after the connection is established and store the results in memory for the duration of the session.

Querying the User's Billing Country

Starting with PBL 7, you can query the user's billing country. This is useful for showing region specific offers or adjusting your UI:

kotlin
val params = GetBillingConfigParams.newBuilder().build()
billingClient.getBillingConfigAsync(params) {
    billingResult, billingConfig ->
    if (billingResult.responseCode ==
        BillingResponseCode.OK
    ) {
        val countryCode = billingConfig?.countryCode
        // Use countryCode for region specific logic
    }
}

The country code is an ISO 3166-1 alpha-2 code (for example, "US", "KR", "DE"). This reflects the user's Google Play billing country, which may differ from their device locale or physical location. A user living in Germany with a device set to English still returns "DE" if their Google Play account is registered there.

Use Cases for Country Specific Logic

The billing country is useful in several scenarios:

  • Regional offer targeting: You can show country specific promotions or highlight plans that are popular in a given market. For example, you might feature annual plans in markets where users tend to prefer longer commitments.
  • Compliance filtering: Some features or pricing models are only available in specific regions. Use the billing country to determine which products or offers to display.
  • Analytics and segmentation: Knowing the billing country lets you segment conversion rates, revenue, and churn by market without relying on less reliable signals like device locale.
  • Currency display: While ProductDetails already includes localized pricing, you may want to adjust surrounding UI text or layout based on the country.

Caching the Billing Country

The billing country rarely changes for a given user. You can safely cache it for the duration of a session. If you need it across sessions, storing it in local preferences is reasonable, but you should refresh it each time the BillingClient connects in case the user changed their Play account. Do not use the cached value as a source of truth for server side decisions. Your backend should rely on the country information from Google Play's server side APIs instead.

Personalized Pricing

The EU Consumer Rights Directive requires that you disclose when prices are personalized based on automated decision making. If you use personalized pricing, you must set setIsOfferPersonalized(true) on your BillingFlowParams. This adds a disclosure message to the purchase dialog:

kotlin
val flowParams = BillingFlowParams.newBuilder()
    .setProductDetailsParamsList(listOf(productParams))
    .setIsOfferPersonalized(true)
    .build()

Only set this to true if you are actually personalizing the price. If all users see the same price, leave it at the default (false).

When Personalized Pricing Applies

Personalized pricing means that the price a specific user sees was determined by automated profiling or decision making about that individual. This includes scenarios where you adjust prices based on a user's purchase history, engagement level, geographic data beyond the standard Play Store localization, or any machine learning model that outputs different prices for different users.

Standard regional pricing set in the Google Play Console does not count as personalized pricing, because all users in the same country see the same price. Similarly, promotional offers that are available to all users in a defined segment (such as "all new users") are generally not considered personalized.

EU Law Context

The requirement comes from Article 6(1)(ea) of the EU Consumer Rights Directive (2011/83/EU, as amended by Directive 2019/2161). It mandates that online marketplaces inform consumers when prices are personalized based on automated decision making. Google Play enforces this by requiring the setIsOfferPersonalized(true) flag. When you set it, the purchase dialog includes a disclosure that the price was personalized for the user.

If you are selling to users in the EU and using any form of price personalization, you must set this flag. Failing to do so can expose you to regulatory risk. The safest approach is to determine at runtime whether the user's billing country is in the EU (using getBillingConfigAsync()) and set the flag accordingly when your pricing logic involves personalization.

Attaching User Identifiers

To help Google detect fraud and to link purchases to your own user accounts, you can attach obfuscated identifiers to purchases:

kotlin
val flowParams = BillingFlowParams.newBuilder()
    .setProductDetailsParamsList(listOf(productParams))
    .setObfuscatedAccountId(hashOf(userId))
    .setObfuscatedProfileId(hashOf(profileId))
    .build()

These identifiers are obfuscated, meaning you should hash your actual user IDs before passing them. Google uses these to detect when multiple purchases are associated with the same account, which helps identify fraudulent behavior. The identifiers are also returned in the Purchase object and in server side API responses, making it easier to reconcile purchases with your user database.

KTX Coroutine Extensions

The billing-ktx artifact provides suspending versions of async methods. Instead of callbacks, you get clean coroutine code:

kotlin
// Callback style
billingClient.queryProductDetailsAsync(params) {
    result, details -> /* handle */
}

// KTX coroutine style
val result = billingClient.queryProductDetails(params)
val details = result.productDetailsList

The KTX extensions are available for:

  • queryProductDetails() (suspending version of queryProductDetailsAsync)
  • queryPurchasesAsync() has a coroutine counterpart
  • consumePurchase() (suspending version of consumeAsync)
  • acknowledgePurchase() (suspending version of acknowledgePurchase)

The real benefit of coroutines becomes clear when you chain multiple billing operations together. With callbacks, querying products and then launching a purchase flow requires nested listeners that are hard to follow. With coroutines, the same logic reads top to bottom:

kotlin
// Callback style: nested and hard to follow
billingClient.queryProductDetailsAsync(params) {
    result, details ->
    if (result.responseCode == BillingResponseCode.OK) {
        val product = details.firstOrNull()
        if (product != null) {
            val flowParams = buildFlowParams(product)
            billingClient.launchBillingFlow(
                activity, flowParams
            )
        }
    }
}
kotlin
// KTX coroutine style: sequential and readable
val result = billingClient.queryProductDetails(params)
if (result.billingResult.responseCode ==
    BillingResponseCode.OK
) {
    val product = result.productDetailsList
        ?.firstOrNull()
    if (product != null) {
        val flowParams = buildFlowParams(product)
        billingClient.launchBillingFlow(
            activity, flowParams
        )
    }
}

Error handling is also simpler. With callbacks, you handle errors inside each callback. With coroutines, you can use standard try/catch blocks or Result wrappers. This makes it easier to implement retry logic or to propagate errors up to your UI layer.

Using coroutines makes your billing code more readable and easier to compose with other async operations in your app. If you are already using coroutines elsewhere in your project, adopting the KTX extensions is straightforward and reduces the amount of callback management code you need to maintain.

Connection Lifecycle Management

Deciding when to create and destroy your BillingClient depends on how central billing is to your app. There are two common patterns.

Pattern 1: Application Scoped Client

If your app uses billing throughout many screens (for example, checking subscription status to unlock features), keep a single BillingClient alive for the entire application lifecycle. Create it in your Application.onCreate() or in a singleton provided by your dependency injection framework. Call startConnection() once at startup. Call endConnection() only when the application is being destroyed or when the user logs out and you want a clean slate.

This pattern is simple and avoids repeated connection setup. The downside is that the connection to Google Play Services stays open even when the user is not interacting with billing features. In practice, this overhead is minimal because idle connections consume very little resources.

Pattern 2: Scoped Client

If billing is only relevant on a few screens (for example, a single purchase screen), you can scope the BillingClient to a ViewModel or a navigation graph scope. Create the client when the scope starts, connect, and call endConnection() when the scope is destroyed. This keeps things tidy but means you pay the connection setup cost each time the user enters the billing flow.

Which Pattern to Choose

For most apps, the application scoped pattern is the better choice. Connection setup takes a few hundred milliseconds on a typical device, and if you need to check subscription status frequently (to gate features, show badges, or customize the UI), having an always connected client avoids repeated delays. Use the scoped pattern only if billing is a rarely accessed corner of your app and you want to minimize the surface area of your Google Play Services interaction.

Regardless of which pattern you use, always call endConnection() when you are done with the client. Failing to do so leaks the service connection and can cause issues if your app creates a new BillingClient later without ending the previous one.

Querying Existing Purchases

You need to check for existing purchases in several situations:

  • When BillingClient first connects (to catch purchases made on other devices)
  • When your app returns to the foreground (to catch purchases made outside your app)
  • When restoring purchases after an app reinstall
kotlin
val params = QueryPurchasesParams.newBuilder()
    .setProductType(ProductType.SUBS)
    .build()

val result = billingClient.queryPurchasesAsync(params)
if (result.billingResult.responseCode ==
    BillingResponseCode.OK
) {
    result.purchasesList.forEach { purchase ->
        handlePurchase(purchase)
    }
}

You must query separately for ProductType.INAPP and ProductType.SUBS to get all purchases. For subscriptions, queryPurchasesAsync() returns purchases in states that may still grant access: PURCHASED and PENDING. It does not return expired or fully canceled subscriptions.

Call this method on every app launch and every return to foreground. This is your safety net for catching purchases that your PurchasesUpdatedListener might have missed.

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 8: Error Handling and Retry Strategies

    Every BillingResponseCode explained. Simple retry, exponential backoff, and the complete error decision tree.

    Learn more
  • Chapter 2: Setting Up Your Environment

    Developer account, Play Console, service account keys, Pub/Sub for RTDNs — everything before a single line of code.

    Learn more
Integrating the Play Billing Library | RevenueCat