| name | subscription-states |
| description | Use this skill when mapping a Google Play subscription to the seven states (PENDING, ACTIVE, IN_GRACE_PERIOD, ON_HOLD, PAUSED, CANCELED, EXPIRED) and deciding what access your app should grant in each state. |
| license | Apache-2.0; see LICENSE |
| metadata | {"author":"RevenueCat","source":"google-play-handbook chapter 11","keywords":["android","play-billing","subscriptions","subscription-state","state-machine","entitlement"]} |
Subscription States
A Google Play subscription moves through seven states between the first purchase and final termination. Each state has a concrete access decision: grant, retain until expiry, or revoke. Get the mapping wrong and you either give away paid content or revoke access a user already paid for.
This skill walks you through confirming your app reads the correct state source, deciding the access rule for each state, and writing the mapping code.
Phase 1: Discovery
Before changing anything, confirm where your entitlement decision reads subscription state from. The authoritative source is the subscriptionState field on the SubscriptionPurchaseV2 resource returned by purchases.subscriptionsv2.get on the Google Play Developer API. The client Purchase object from queryPurchasesAsync() does not carry this field.
Answer these questions:
- Does your backend call
purchases.subscriptionsv2.get (not the legacy purchases.subscriptions.get)?
- Does your entitlement function read
subscriptionState from that response rather than inferring state from the RTDN notification type?
- Does the function check
expiryTime when the state is SUBSCRIPTION_STATE_CANCELED?
- Does your client code treat a
Purchase returned by queryPurchasesAsync() as "has a purchase," not "has access"?
If any answer is no, fix the source of truth first. Inferring state from the notification type is the single most common cause of wrong entitlement decisions, because two different transitions can produce the same RTDN type. For example, SUBSCRIPTION_RECOVERED fires both when a grace period retry succeeds and when a user fixes payment during account hold.
Confirm the app is on subscriptionsv2 before moving on.
Phase 2: Plan the Decision Per State
Decide the access rule for each of the seven states. Use this table as the contract your code must match:
| State | Access Decision | Reason |
|---|
| PENDING | Do not grant | Payment has not settled yet. |
| ACTIVE | Grant | Paid and within the current billing period. |
| IN_GRACE_PERIOD | Grant | Payment failed, Google is retrying, user keeps access during the configured window. |
| CANCELED | Retain until expiryTime | User canceled but already paid for the current period. |
| ON_HOLD | Revoke | Grace period expired without recovery. |
| PAUSED | Revoke | Voluntary pause, no billing during the window. |
| EXPIRED | Revoke | Subscription has ended. |
Three rules flow from this table:
- Treat
CANCELED as a delayed revocation, not an immediate one. Schedule the revocation for expiryTime.
- Treat
ON_HOLD as an immediate revocation even though queryPurchasesAsync() still returns the purchase with PurchaseState.PURCHASED. The client-visible purchase does not tell you access is gone.
- Treat
PENDING as "no access yet." Wait for the transition to ACTIVE before entitling the user.
Plan the UI message for each revoking or warning state:
IN_GRACE_PERIOD: show a "fix your payment" prompt with the Play Store deep link.
ON_HOLD: show the same prompt, since recovery is still possible until the hold expires.
PAUSED: show the resume date, not a payment failure message.
CANCELED: show a resubscribe prompt that references expiryTime.
EXPIRED: show a fresh purchase offer.
Phase 3: Execute
Map the state string to an access decision
Encode the decision table directly. Keep the function pure so it is easy to test.
fun shouldGrantAccess(
state: String,
expiryTimeMillis: Long
): Boolean = when (state) {
"SUBSCRIPTION_STATE_ACTIVE",
"SUBSCRIPTION_STATE_IN_GRACE_PERIOD" -> true
"SUBSCRIPTION_STATE_CANCELED" ->
System.currentTimeMillis() < expiryTimeMillis
else -> false
}
Everything not listed (ON_HOLD, PAUSED, EXPIRED, PENDING, and anything unknown) returns false. This is the safe default.
Gate the client path with the backend state
The client must not decide entitlement alone. queryPurchasesAsync() returns ON_HOLD purchases with PurchaseState.PURCHASED, which looks like access but is not.
suspend fun checkEntitlement(
client: BillingClient,
api: YourBackendApi
): Boolean {
val params = QueryPurchasesParams.newBuilder()
.setProductType(ProductType.SUBS).build()
val result = client.queryPurchasesAsync(params)
val purchase = result.purchasesList.firstOrNull()
?: return false
if (purchase.purchaseState == PurchaseState.PENDING) {
return false
}
return api.verifyEntitlement(purchase.purchaseToken)
}
The client check filters out "no purchase" and pending payments. The backend call resolves the real subscription state.
Route the backend handler by state, not by notification type
When an RTDN arrives, fetch the subscription and branch on subscriptionState.
fun handle(notification: SubscriptionNotification) {
val token = notification.purchaseToken
val sub = playApi.getSubscriptionV2(pkg, token)
when (sub.subscriptionState) {
"SUBSCRIPTION_STATE_ACTIVE" -> grant(token)
"SUBSCRIPTION_STATE_IN_GRACE_PERIOD" -> grantWithWarning(token)
"SUBSCRIPTION_STATE_CANCELED" -> scheduleRevoke(token, sub)
"SUBSCRIPTION_STATE_ON_HOLD",
"SUBSCRIPTION_STATE_PAUSED",
"SUBSCRIPTION_STATE_EXPIRED" -> revoke(token)
"SUBSCRIPTION_STATE_PENDING" -> hold(token)
}
}
Schedule the CANCELED revocation at expiryTime so the user keeps the time they paid for.
fun scheduleRevoke(token: String, sub: SubscriptionPurchaseV2) {
val expiry = Instant.parse(
sub.lineItems.first().expiryTime
).toEpochMilli()
if (System.currentTimeMillis() >= expiry) {
revoke(token)
} else {
db.setExpiry(token, expiry)
scheduler.scheduleAt(expiry) { revoke(token) }
}
}
Verify
Before shipping, confirm these behaviors with fixtures or a staging account:
- A
CANCELED subscription with expiryTime in the future still grants access.
- An
ON_HOLD subscription revokes access even though queryPurchasesAsync() still returns the purchase.
- A
PENDING subscription does not grant access until it transitions to ACTIVE.
- An unknown or new state string returns
false rather than defaulting to true.
References