Implementing Google Play Billing (Subscriptions) for Android
With Google Play Billing Library 5, the subscription model was completely restructured. Base plans and offers emerged — this is not just a marketing rename, but a new object hierarchy: one ProductDetails contains multiple SubscriptionOfferDetails, each with its own offerToken. Old code that passed skuDetails.sku directly to BillingFlowParams does not compile with Billing 5+.
How the new model works
Subscription Product
├── Base Plan (monthly)
│ ├── Offer: "free-trial-7days" (offerToken_1)
│ └── Offer: "default" (offerToken_2)
└── Base Plan (annual)
└── Offer: "default" (offerToken_3)
When launching a purchase, select a specific offerToken:
val productDetails = // from queryProductDetailsAsync
val offerToken = productDetails.subscriptionOfferDetails
?.firstOrNull { it.offerTags.contains("default") }
?.offerToken ?: return
val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.setOfferToken(offerToken)
.build()
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
.build()
billingClient.launchBillingFlow(activity, billingFlowParams)
If you pass an offerToken from one base plan, and the user already has a subscription to another — this is an upgrade/downgrade, Google handles it automatically when specifying setSubscriptionUpdateParams.
Grace period and account hold
Unlike iOS, Google Play has two states after payment expires:
- Grace period (1–3 days) — subscription is technically active, Google tries to charge
- Account hold (up to 30 days) — after grace, subscription is paused, Google continues trying
Handle both correctly through purchases.subscriptions.get in Google Play Developer API. The paymentState field: 0 = payment pending, 1 = payment received, 2 = free trial, 3 = pending deferred upgrade.
On the client — through purchase.purchaseState and additionally through Real-Time Developer Notifications (Pub/Sub):
// Example RTDN payload for account hold
{
"subscriptionNotification": {
"notificationType": 5, // SUBSCRIPTION_ON_HOLD
"purchaseToken": "...",
"subscriptionId": "premium_monthly"
}
}
Notification types that must be handled: SUBSCRIPTION_RENEWED (1), SUBSCRIPTION_CANCELED (3), SUBSCRIPTION_ON_HOLD (5), SUBSCRIPTION_IN_GRACE_PERIOD (6), SUBSCRIPTION_RESTARTED (7), SUBSCRIPTION_REVOKED (12), SUBSCRIPTION_EXPIRED (13).
Proration on plan change
When switching between base plans — specify ProrationMode:
val updateParams = BillingFlowParams.SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(currentPurchaseToken)
.setSubscriptionReplacementMode(
BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION
)
.build()
WITH_TIME_PRORATION — most fair for the user: the remainder of the current period is recalculated into days of the new plan. IMMEDIATE_WITHOUT_PRORATION — instant transition without refund.
Workflow
Set up base plans and offers in Play Console → integrate Billing Library 6+ → handle all purchaseState on the client → configure RTDN through Google Cloud Pub/Sub → server-side status synchronization → testing through licensed testers with expiry simulation.
Estimated time — 3–5 days depending on the number of pricing plans and the presence of a server component for RTDN.







