◉RORK LABJP
●EXPO — EAS Observe now records native crashes too (Oct 7). Check on SDK 57 at 57.0.21 or later●RORK — GPT-6.1 Sol (Sep 29) and Claude Sonnet 5.5 (Sep 28) were added to the model menu●RN 0.88 — Not out yet. The latest is rc.4 (Oct 7), and 0.87.1 is the current Latest●Q&A — A write-up lists four snags on the way to TestFlight with an Expo + Supabase app●ECOSYSTEM — With Shopify going back to native, one write-up explains why a solo developer stays on Expo●NEW — Before you write 'ship in a week', look at the breakdown of waiting days●EXPO — EAS Observe now records native crashes too (Oct 7). Check on SDK 57 at 57.0.21 or later●RORK — GPT-6.1 Sol (Sep 29) and Claude Sonnet 5.5 (Sep 28) were added to the model menu●RN 0.88 — Not out yet. The latest is rc.4 (Oct 7), and 0.87.1 is the current Latest●Q&A — A write-up lists four snags on the way to TestFlight with an Expo + Supabase app●ECOSYSTEM — With Shopify going back to native, one write-up explains why a solo developer stays on Expo●NEW — Before you write 'ship in a week', look at the breakdown of waiting days
Articles/Business
⟐ Business/2026-05-06Intermediate

Google Play Subscription Offers: Reduce Churn and Maximize Revenue for Your Rork App

A practical guide to using Google Play subscription offers — free trials, introductory pricing, upgrade offers, and win-back offers — to maximize revenue from Android apps built with Rork.

google-play6subscription28offersmonetization47rork59android3

App Store subscription optimization gets most of the attention in the indie developer community, but Google Play has an equally powerful offer system — and most developers aren't using it fully.

Here's a practical breakdown of how to configure Google Play subscription offers and implement them in an Android app built with Rork.

Types of Subscription Offers in Google Play

Google Play Console lets you attach multiple offers to a single subscription product. The main offer types:

Free Trial

The most common form. You can configure 7, 14, or 30-day free trials. One important nuance: Google enforces that the same user can't claim a free trial for the same app multiple times. This is handled by the platform, not your code.

Introductory Price

Offer the first few months at a reduced price before transitioning to the full rate. For example: first month $0.99, then $4.99/month. Conversion rate is typically lower than free trials, but introductory-price subscribers tend to show higher long-term retention — they were willing to pay from the start.

Upgrade Offer

Target users on a free or lower-tier plan with a discounted path to upgrade. Because you're working with existing users, you can be precise about who sees this offer.

Win-back Offer

Discounted pricing specifically for users who previously cancelled. Google Play lets you configure these to automatically target "past subscribers," which removes the need for you to maintain that list yourself. This is consistently the most underutilized offer type among indie developers.

Configuring Offers in Play Console

  1. Play Console → Your app → Monetize → Products → Subscriptions
  2. Select your subscription → Add offer
  3. Set the offer details

Offer ID (choose carefully): This is what you reference in code. It can't be changed after creation, so establish a naming convention upfront:

  • trial-7days — 7-day free trial
  • intro-1month-half — 50% off first month
  • winback-30pct — 30% off for returning subscribers

Eligibility criteria: Who can see this offer:

  • New subscribers only (never purchased)
  • Existing subscribers (for upgrade offers)
  • Lapsed subscribers (for win-back offers)

Android Implementation (Kotlin, Billing Library v7)

// BillingManager.kt
import com.android.billingclient.api.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
 
class BillingManager(private val activity: Activity) {
 
    private lateinit var billingClient: BillingClient
    
    private val _subscriptionOffers = MutableStateFlow<List<ProductDetails.SubscriptionOfferDetails>>(emptyList())
    val subscriptionOffers: StateFlow<List<ProductDetails.SubscriptionOfferDetails>> = _subscriptionOffers
 
    fun initialize() {
        billingClient = BillingClient.newBuilder(activity)
            .setListener { billingResult, purchases ->
                if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                    purchases?.forEach { handlePurchase(it) }
                }
            }
            .enablePendingPurchases()
            .build()
 
        billingClient.startConnection(object : BillingClientStateListener {
            override fun onBillingSetupFinished(result: BillingResult) {
                if (result.responseCode == BillingClient.BillingResponseCode.OK) {
                    loadSubscriptionOffers()
                }
            }
            override fun onBillingServiceDisconnected() {
                // Implement reconnection logic
            }
        })
    }
 
    private fun loadSubscriptionOffers() {
        val queryParams = QueryProductDetailsParams.newBuilder()
            .setProductList(
                listOf(
                    QueryProductDetailsParams.Product.newBuilder()
                        .setProductId("premium_monthly")
                        .setProductType(BillingClient.ProductType.SUBS)
                        .build()
                )
            )
            .build()
 
        billingClient.queryProductDetailsAsync(queryParams) { result, productDetailsList ->
            if (result.responseCode == BillingClient.BillingResponseCode.OK) {
                val product = productDetailsList.firstOrNull() ?: return@queryProductDetailsAsync
                val offers = product.subscriptionOfferDetails ?: emptyList()
 
                // Debug: log all available offers
                offers.forEach { offer ->
                    println("Offer ID: ${offer.offerId}")
                    println("Tags: ${offer.offerTags}")
                    offer.pricingPhases.pricingPhaseList.forEach { phase ->
                        println("  Phase: ${phase.formattedPrice} x ${phase.billingCycleCount} cycles")
                    }
                }
 
                _subscriptionOffers.value = offers
            }
        }
    }
 
    fun selectBestOfferForUser(
        offers: List<ProductDetails.SubscriptionOfferDetails>,
        isReturningSubscriber: Boolean,
        hasUsedTrial: Boolean,
    ): ProductDetails.SubscriptionOfferDetails? {
        return when {
            // Returning subscribers get the win-back offer
            isReturningSubscriber -> offers.firstOrNull { it.offerTags.contains("winback") }
            // New users who haven't used a trial get the free trial
            !hasUsedTrial -> offers.firstOrNull { it.offerTags.contains("trial") }
            // Trial already used — offer introductory pricing
            else -> offers.firstOrNull { it.offerTags.contains("introductory") }
        } ?: offers.firstOrNull() // Fallback: base price
    }
 
    fun launchPurchaseFlow(
        productDetails: ProductDetails,
        selectedOffer: ProductDetails.SubscriptionOfferDetails,
    ) {
        val productDetailsParamsList = listOf(
            BillingFlowParams.ProductDetailsParams.newBuilder()
                .setProductDetails(productDetails)
                .setOfferToken(selectedOffer.offerToken) // Critical: pass the offer token
                .build()
        )
 
        val billingFlowParams = BillingFlowParams.newBuilder()
            .setProductDetailsParamsList(productDetailsParamsList)
            .build()
 
        billingClient.launchBillingFlow(activity, billingFlowParams)
    }
 
    private fun handlePurchase(purchase: Purchase) {
        if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
            if (!purchase.isAcknowledged) {
                // Acknowledge within 3 days or Google will refund automatically
                val params = AcknowledgePurchaseParams.newBuilder()
                    .setPurchaseToken(purchase.purchaseToken)
                    .build()
                billingClient.acknowledgePurchase(params) { result ->
                    if (result.responseCode == BillingClient.BillingResponseCode.OK) {
                        println("Acknowledged: ${purchase.orderId}")
                    }
                }
            }
            updateSubscriptionStatus(purchase.purchaseToken)
        }
    }
 
    private fun updateSubscriptionStatus(purchaseToken: String) {
        // Notify RevenueCat or your backend to update subscription status
    }
}

Paywall UI Design for Offers

How you present offers matters as much as the offer itself.

For free trials, show the timeline. "7 days free" is okay. "Start today — cancel anytime before [date] and you won't be charged. After that, $4.99/month" is better. Showing the exact future charge date and making the cancel path clear actually increases conversion because it reduces anxiety about being caught off guard.

For win-back offers, add context. A plain discount ("30% off") feels transactional. "Welcome back — we've held this offer for past subscribers" feels like you thought about them specifically. The latter tends to convert better, particularly in categories where user trust matters.

Measure LTV, not just conversion. When A/B testing offers with RevenueCat Experiments, the headline metric shouldn't be conversion rate — it should be revenue per install at 90 or 180 days. Introductory-price subscribers often show higher LTV than free-trial subscribers, even though the trial converts at a higher initial rate.

Where to Start

If you're not using any offers today, the minimum viable setup is two offers:

  1. 7-day free trial targeting new users
  2. Win-back offer at 30% off targeting lapsed subscribers

Set these up in Play Console, tag them with trial and winback in your offer configuration, and use the selectBestOfferForUser() function above to serve the right offer to the right user. That's the 80/20 version — simple enough to ship in a day, and it covers the two highest-impact scenarios.

For subscription analytics and deeper revenue optimization, the premium article Building a Complete Stripe SaaS Payment System with Rork covers the infrastructure side for web-based monetization alongside your mobile IAP.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⟐ Business2026-04-28
Choosing a Revenue Model for Your Rork App — Ads, Subscriptions, or One-Time Purchase, Backed by Real Operating Numbers
Drawing on the years I spent running ads in my own wallpaper apps, I rebuilt the comparison of ads, subscriptions, and one-time purchases under identical conditions — with genre-by-genre guidance and implementation notes for Rork.
⟐ Business2026-09-03
My Second App From the Same Codebase Ships With a Distinct-Value Manifest
Google Play has drawn a clearer line around low-value apps. Here is how I decide where the distinct value of a sibling app lives, and the small script I run before every submission to check that the two apps have actually diverged.
⟐ Business2026-05-02
A Solo Developer's Strategy for Pushing a Mobile App Past ¥100k/Month — Real Revenue Tuning Tested on Rork-Built Apps
The hands-on strategy for breaking the ¥100k/month wall in solo mobile development. Ad eCPM optimization, IAP tuning, subscription retention, and ASO — every lever I've actually tested on Rork-built apps over 12 years of solo monetization, with code and numbers.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links