DEV Community

Charles Hartmann
Charles Hartmann

Posted on

Play Billing in 2026: the purchase isn't done when launchBillingFlow returns

Google Play Billing is the only way to sell digital features inside a Play app. Its library has one job: connect your app to Google Play so the user can pay and your app can find out what they own.

Almost every billing bug comes from forgetting that purchases can arrive at any time, from anywhere, and must be confirmed back to Google.

The trap

The trap is treating launchBillingFlow() like a checkout that returns "paid". It does not.

The result arrives later in a listener. Sometimes that is after the app was closed. Sometimes it arrives as PENDING (cash, some wallets). Sometimes it comes from a different device. And if your app does not acknowledge a purchase within three days, Google's integration guide says it is automatically refunded and the entitlement revoked.

Versions and deadlines

At the time of writing (October 2026), the current release is Play Billing Library 9.1.0 (June 18, 2026), with 9.0.0 released on May 19, 2026. Google gives every major version a two-year life. Its "Play Billing Library version deprecation" page lists the dates after which new apps and updates must use a newer version (checked October 13, 2026):

Version New apps and updates must move on by Extension until
7 Aug 31, 2026 Nov 1, 2026
8 Aug 31, 2027 Nov 1, 2027
9 Aug 31, 2028 Nov 1, 2028

So by August 31, 2026, new apps and updates already had to use version 8 or later. Starting a new integration today means version 9.1.0, which buys you until August 31, 2028.

If you are upgrading old code, these release-note changes break builds:

  • 8.0.0 removed queryPurchaseHistory(), querySkuDetailsAsync(), the parameterless enablePendingPurchases() and the queryPurchasesAsync(skuType, ...) overload. In-app items are now called one-time products. New: enableAutoServiceReconnection().
  • 8.1.0 raised minSdkVersion to 23 and added suspended subscriptions.
  • 9.0.0 changed the response for a blocked Play Store (for example a kids mode) from ERROR to BILLING_UNAVAILABLE.

One client, connected

Google recommends one active BillingClient connection, opened when the app starts or returns to the foreground, and strongly recommends automatic service reconnection. With it, the library reconnects by itself when you call an API after a disconnect.

class BillingRepository(context: Context, private val scope: CoroutineScope) :
    PurchasesUpdatedListener {

    private val _purchases = MutableStateFlow<List<Purchase>>(emptyList())
    val purchases: StateFlow<List<Purchase>> = _purchases

    private val client = BillingClient.newBuilder(context)
        .setListener(this)
        .enablePendingPurchases(
            PendingPurchasesParams.newBuilder()
                .enableOneTimeProducts()
                .build()
        )
        .enableAutoServiceReconnection()
        .build()

    override fun onPurchasesUpdated(
        result: BillingResult,
        purchases: List<Purchase>?,
    ) {
        when (result.responseCode) {
            BillingResponseCode.OK -> purchases?.forEach(::process)
            BillingResponseCode.USER_CANCELED -> Unit
            else -> Log.w(TAG, "Update error ${result.responseCode}")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Product prices come from Google Play, localized and formatted for the user. Never hard-code a price string in the app. Query it with queryProductDetailsAsync().

Launching is only the start

The purchase sheet is Google's, not yours. You pass the ProductDetails and an offer token:

fun buy(activity: Activity, details: ProductDetails, accountHash: String?) {
    val offerToken =
        details.oneTimePurchaseOfferDetailsList?.firstOrNull()?.offerToken
            ?: details.subscriptionOfferDetails?.firstOrNull()?.offerToken
    val productParams = BillingFlowParams.ProductDetailsParams.newBuilder()
        .setProductDetails(details)
        .apply { offerToken?.let { setOfferToken(it) } }
        .build()
    val flow = BillingFlowParams.newBuilder()
        .setProductDetailsParamsList(listOf(productParams))
        .apply { accountHash?.let { setObfuscatedAccountId(it) } }
        .build()
    client.launchBillingFlow(activity, flow) // result: onPurchasesUpdated
}
Enter fullscreen mode Exit fullscreen mode

Note the comment on the last line. Nothing about the payment is known when that call returns.

setObfuscatedAccountId() is optional. Google's guide says an obfuscated version of your own user ID helps Google detect fraud and helps you attribute purchases. Never pass an email address or anything readable.

PENDING grants nothing

PENDING means the user has started paying by a delayed method. Grant nothing yet. PURCHASED means paid: verify, grant, then acknowledge (or consume) within three days.

private fun process(purchase: Purchase) {
    when (purchase.purchaseState) {
        Purchase.PurchaseState.PENDING -> {
            // Tell the user payment is pending. Grant nothing.
        }
        Purchase.PurchaseState.PURCHASED -> {
            // 1. Verify (on your server if you have one, Chapter 7).
            // 2. Grant the entitlement (Chapter 6).
            // 3. Acknowledge, unless it is a consumable you consume instead.
            if (!purchase.isAcknowledged) acknowledge(purchase)
        }
        else -> Unit
    }
    _purchases.update { current ->
        val token = purchase.purchaseToken
        current.filterNot { it.purchaseToken == token } + purchase
    }
}

private fun acknowledge(purchase: Purchase) {
    val params = AcknowledgePurchaseParams.newBuilder()
        .setPurchaseToken(purchase.purchaseToken)
        .build()
    client.acknowledgePurchase(params) { result ->
        if (result.responseCode != BillingResponseCode.OK) {
            Log.w(TAG, "Ack failed ${result.responseCode}; retry on refresh")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Consumables (coins, credits) are different. You consume them with consumeAsync() or the KTX consumePurchase(), which both acknowledges the purchase and lets the user buy it again. A remove-ads unlock is never consumed.

Never trust only the listener

Google lists the cases the listener misses:

  • the network drops after payment
  • the user bought on another device
  • a promotion was redeemed outside the app
  • a pending payment completed while the app was closed

The fix is one call whenever the client connects and whenever the app comes back to the foreground:

fun refreshPurchases() {
    val types = listOf(ProductType.INAPP, ProductType.SUBS)
    types.forEach { type ->
        client.queryPurchasesAsync(
            QueryPurchasesParams.newBuilder().setProductType(type).build()
        ) { result, list ->
            if (result.responseCode == BillingResponseCode.OK) {
                list.forEach(::process)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Because process() acknowledges anything not yet acknowledged, this one refresh also retries failed acknowledgements, well inside the three-day window.

Testing without spending money

  • License testers. Add tester Google accounts in Play Console. Google's testing guide says license testers can test with debug builds and sideloaded apps (the package name must match the app in Play Console) and get test payment methods that never charge real money.
  • Test cards for the hard cases. License testers get a slow card that approves after a few minutes, one that declines after a few minutes, and one that approves then charges back. Use them to prove that PENDING grants nothing and that a later approval is picked up by refreshPurchases().
  • Play Billing Lab. Google's Play Billing Lab app lets you change the Play country for a test, repeat trial or introductory offers with the same account, and speed up subscription state changes.
  • Internal testing track. Products can only be created after a build with the library has been published to some track. Purchases in apps on the internal test track or in draft are subject to spend limits.

Checklist

  • Play Billing Library 9.1.0 for new work.
  • One BillingClient with enableAutoServiceReconnection().
  • Grant nothing for PENDING.
  • Verify, grant, then acknowledge or consume within three days.
  • Call queryPurchasesAsync on every connect and every return to the foreground.
  • Test with license testers and the slow test cards before release.

This is adapted from my book Get Paid for Your Android App (Android the Right Way, Book 4). The free sample is here and the full book is here.

Drafted with AI help and edited by the author; based on his book.

Top comments (0)