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
- Play Console → Your app → Monetize → Products → Subscriptions
- Select your subscription → Add offer
- 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 trialintro-1month-half— 50% off first monthwinback-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:
- 7-day free trial targeting new users
- 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.