Skip to content

Connect a Compose paywall to Google Play Billing

Short answer: load your subscription with queryProductDetails, turn each SubscriptionOfferDetails into a plan (price from the last pricing phase, trial from a free phase, offer token as the id), and call launchBillingFlow with that offer token when the user taps the paywall’s button.

implementation("com.android.billingclient:billing-ktx:9.1.0")
  1. Load the product. Call it after the client is connected:

    /** Loads one subscription product. Call after the BillingClient is connected. */
    suspend fun BillingClient.loadSubscription(productId: String): ProductDetails? {
    val product = QueryProductDetailsParams.Product.newBuilder()
    .setProductId(productId)
    .setProductType(BillingClient.ProductType.SUBS)
    .build()
    val params = QueryProductDetailsParams.newBuilder().setProductList(listOf(product)).build()
    return queryProductDetails(params).productDetailsList?.firstOrNull()
    }
  2. Map offers to plans. A subscription has one or more offers, each with pricing phases. The last phase is the recurring price, and a free phase (priceAmountMicros == 0) is the trial. formattedPrice is already localized by Google Play.

    /** One PaywallPlan per base plan or offer. The offer token becomes the plan id. */
    fun ProductDetails.toPaywallPlans(): List<PaywallPlan> =
    subscriptionOfferDetails.orEmpty().map { offer ->
    val phases = offer.pricingPhases.pricingPhaseList
    val recurring = phases.last() // the price after any trial or intro phase
    val freeTrial = phases.firstOrNull { it.priceAmountMicros == 0L }
    val yearly = recurring.billingPeriod == "P1Y"
    PaywallPlan(
    id = offer.offerToken,
    title = if (yearly) "Yearly" else "Monthly",
    price = recurring.formattedPrice, // already localized by Google Play
    period = if (yearly) "per year" else "per month",
    trialText = freeTrial?.let { "${isoPeriodToText(it.billingPeriod)} free trial" },
    badge = if (yearly) "Best value" else null,
    )
    }
    /** "P7D" -> "7-day", "P1M" -> "1-month". Google Play uses ISO 8601 periods. */
    fun isoPeriodToText(iso: String): String {
    val match = Regex("P(\\d+)([DWMY])").find(iso) ?: return iso
    val (amount, unit) = match.destructured
    val word = when (unit) { "D" -> "day"; "W" -> "week"; "M" -> "month"; else -> "year" }
    return "$amount-$word"
    }
  3. Launch the purchase from onPurchase. The offer token you stored in plan.id picks the offer:

    fun launchPurchase(
    activity: Activity,
    billingClient: BillingClient,
    product: ProductDetails,
    plan: PaywallPlan,
    ): BillingResult {
    val productParams = BillingFlowParams.ProductDetailsParams.newBuilder()
    .setProductDetails(product)
    .setOfferToken(plan.id)
    .build()
    val flowParams = BillingFlowParams.newBuilder()
    .setProductDetailsParamsList(listOf(productParams))
    .build()
    return billingClient.launchBillingFlow(activity, flowParams)
    }
  4. Restore. On Google Play, “restore” means asking which subscriptions the account already owns, then unlocking the app if one is active:

    /** "Restore" on Google Play means: ask Play which subscriptions this account already owns. */
    suspend fun BillingClient.activeSubscriptions(): List<Purchase> {
    val params = QueryPurchasesParams.newBuilder()
    .setProductType(BillingClient.ProductType.SUBS)
    .build()
    return queryPurchasesAsync(params).purchasesList
    .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
    }
  5. Show the paywall with the mapped plans, for example the classic design, and call the functions above from onPurchase and onRestore.

  • Multiple base plans. If monthly and yearly are separate base plans of one product, subscriptionOfferDetails lists both; the mapper above handles that. If they are separate products, query both and concatenate the plans.
  • Don’t show offers the user isn’t eligible for. Play returns only the offers this user can buy, so a free trial disappears for users who already used it. Don’t cache old plans.
  • Wait for products before showing the paywall. Show a loading state instead of an empty plan list. The paywall needs at least one plan.

FAQ

Which Play Billing version does this guide use?

Google Play Billing Library 9.1.0 with the billing-ktx extensions. The snippets are compiled against that version.

What should the plan id be?

The offer token of the subscription offer. launchBillingFlow needs it, so storing it in PaywallPlan.id lets onPurchase start the right offer directly.

Where do I acknowledge the purchase?

In your PurchasesUpdatedListener, after verifying the purchase (ideally on your server). The paywall UI is not involved in that step.

Try it in your app

Add the library (JitPack, minSdk 26), then follow Getting started.

implementation("com.github.sanikantv:m3-expressive-paywall:v1.0.0")
Get started

⭐ If this guide helped, star M3 Expressive Paywall on GitHub. It helps other Compose developers find it.

Built by Sanikant. Set in Google Sans Flex (SIL Open Font License). Not affiliated with Google.