Skip to content
All posts
May 9, 20263 min read

Android In-App Purchases With Play Billing: A Practical Guide

Monetizing with in-app purchases requires more than integrating the Play Billing Library. Here's how to handle purchase flows, verify purchases securely, restore purchases, and avoid the common mistakes that cause rejected apps and failed payments.

AndroidMonetizationKotlin
Share:

IAP implementation has real consequences: failed payments mean lost revenue, unverified purchases mean fraud, and unclaimed entitlements mean angry users. Here's how to do it correctly.


Setup

kotlin
// build.gradle.kts
implementation("com.android.billingclient:billing-ktx:7.0.0")

The Billing Library is Google's official client. Don't use third-party billing wrappers — they add complexity and lag behind API updates.


Initializing the Billing Client

kotlin
class BillingManager(private val context: Context) {
    
    private val billingClient = BillingClient.newBuilder(context)
        .setListener { billingResult, purchases ->
            if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                purchases?.forEach { processPurchase(it) }
            }
        }
        .enablePendingPurchases()
        .build()
    
    suspend fun connect(): Boolean = suspendCancellableCoroutine { continuation ->
        billingClient.startConnection(object : BillingClientStateListener {
            override fun onBillingSetupFinished(result: BillingResult) {
                continuation.resume(result.responseCode == BillingClient.BillingResponseCode.OK)
            }
            
            override fun onBillingServiceDisconnected() {
                continuation.resume(false)
            }
        })
    }
}

The billing client must be connected before any purchases. Handle disconnection — reconnect before any billing operation.


Querying Products

kotlin
suspend fun getProducts(productIds: List<String>): List<ProductDetails> {
    if (!billingClient.isReady) connect()
    
    val params = QueryProductDetailsParams.newBuilder()
        .setProductList(
            productIds.map { productId ->
                QueryProductDetailsParams.Product.newBuilder()
                    .setProductId(productId)
                    .setProductType(BillingClient.ProductType.INAPP) // or SUBS for subscriptions
                    .build()
            }
        )
        .build()
    
    val result = billingClient.queryProductDetails(params)
    return if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
        result.productDetailsList ?: emptyList()
    } else {
        emptyList()
    }
}

Always show the price from

code
ProductDetails
, not a hardcoded value. Prices vary by region and currency.

kotlin
val price = productDetails.oneTimePurchaseOfferDetails?.formattedPrice
// Displays "₹500.00" or "$4.99" based on user's locale

Launching the Purchase Flow

kotlin
fun launchPurchaseFlow(activity: Activity, productDetails: ProductDetails) {
    val offerToken = productDetails.subscriptionOfferDetails?.get(0)?.offerToken
    
    val productDetailsParamsList = listOf(
        BillingFlowParams.ProductDetailsParams.newBuilder()
            .setProductDetails(productDetails)
            .apply {
                offerToken?.let { setOfferToken(it) } // Required for subscriptions
            }
            .build()
    )
    
    val billingFlowParams = BillingFlowParams.newBuilder()
        .setProductDetailsParamsList(productDetailsParamsList)
        .build()
    
    val result = billingClient.launchBillingFlow(activity, billingFlowParams)
    if (result.responseCode != BillingClient.BillingResponseCode.OK) {
        // Handle launch failure
    }
}

Processing Purchases

This is the most critical part. You must acknowledge purchases or Google will refund them within 3 days:

kotlin
private fun processPurchase(purchase: Purchase) {
    if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) return
    
    // 1. Verify the purchase server-side BEFORE granting entitlement
    verifyPurchaseWithServer(purchase) { isValid ->
        if (isValid) {
            // 2. Grant entitlement
            grantPremiumAccess(purchase.products)
            
            // 3. Acknowledge the purchase (required within 3 days)
            if (!purchase.isAcknowledged) {
                acknowledgePurchase(purchase.purchaseToken)
            }
        }
    }
}

private fun acknowledgePurchase(purchaseToken: String) {
    val params = AcknowledgePurchaseParams.newBuilder()
        .setPurchaseToken(purchaseToken)
        .build()
    
    billingClient.acknowledgePurchase(params) { result ->
        if (result.responseCode != BillingClient.BillingResponseCode.OK) {
            // Retry — this must succeed or Google will refund
            scheduleAcknowledgeRetry(purchaseToken)
        }
    }
}

[!IMPORTANT] Never grant entitlement without server-side verification of the purchase token. The Play Billing Library doesn't prevent fake purchase receipts. Always verify the

code
purchaseToken
against Google's servers before granting access.


Server-Side Verification

Your backend verifies with the Google Play Developer API:

code
GET https://www.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/products/{productId}/tokens/{token}

A valid response includes

code
purchaseState: 0
(purchased). Grant entitlement only on valid responses.

For subscriptions:

code
GET https://www.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptions/{subscriptionId}/tokens/{token}

Restoring Purchases

Users reinstalling your app should regain their purchases. Query existing purchases on app launch:

kotlin
suspend fun restorePurchases() {
    if (!billingClient.isReady) connect()
    
    val params = QueryPurchasesParams.newBuilder()
        .setProductType(BillingClient.ProductType.INAPP)
        .build()
    
    val result = billingClient.queryPurchasesAsync(params)
    
    result.purchasesList.forEach { purchase ->
        if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
            processPurchase(purchase) // Grant entitlement if not already active
        }
    }
}

Call

code
restorePurchases()
on every app launch (when billing client connects), not just when the user taps a restore button.


Common Mistakes

Granting entitlement before verification. Fast-path to fraud.

Not handling

code
PENDING
purchase state. Some payment methods (like bank transfers in certain regions) produce
code
PENDING
purchases. Don't grant entitlement for pending purchases.

Not acknowledging purchases promptly. Google refunds unacknowledged purchases after 3 days. Implement retry logic.

Not restoring purchases on launch. Users assume their paid features persist. Silently restoring them is correct UX.

Hardcoding prices. Always use

code
formattedPrice
from
code
ProductDetails
.


Takeaways

  • Always verify purchase tokens server-side before granting entitlement
  • Acknowledge purchases within 3 days or Google refunds them automatically
  • Restore purchases on every app launch by querying existing purchases
  • code
    PENDING
    purchase state means payment isn't confirmed — don't grant access yet
  • Display prices from
    code
    ProductDetails.formattedPrice
    , never hardcode them
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this