Back to the RevenueCat homepage
RevenueCat SDKGoogle Play Billing

Chapter 11: Subscription States

Tracking subscription state from scratch means mapping seven distinct states (ACTIVE, IN_GRACE_PERIOD, ON_HOLD, PAUSED, CANCELED, EXPIRED, PENDING), knowing which grant access and which do not, and building a state machine that processes RTDNs and makes correct entitlement decisions.

With RevenueCat, you do not implement a state machine. You read one boolean.

The Entitlement Check

kotlin
val customerInfo = Purchases.sharedInstance.awaitCustomerInfo()
val hasAccess = customerInfo.entitlements["pro_access"]?.isActive == true

isActive is true when:

  • The subscription is ACTIVE
  • The subscription is IN_GRACE_PERIOD (grace period grants access)
  • The subscription is CANCELED but expirationDate is in the future

isActive is false when:

  • The subscription is ON_HOLD
  • The subscription is PAUSED
  • The subscription is EXPIRED
  • The subscription is PENDING (no payment yet)

RevenueCat computes this on the backend using the SubscriptionPurchaseV2 resource from the Google Play Developer API. You do not write the seven-state access decision function.

Reading EntitlementInfo

The EntitlementInfo object gives you additional context if you need it:

kotlin
val entitlement = customerInfo.entitlements["pro_access"] ?: return
val isActive = entitlement.isActive
val willRenew = entitlement.willRenew         // false if canceled
val expirationDate = entitlement.expirationDate  // null for lifetime
val periodType = entitlement.periodType       // NORMAL, TRIAL, INTRO, PREPAID
val billingIssueAt = entitlement.billingIssueDetectedAt // non-null during grace period / on hold
val unsubscribeAt = entitlement.unsubscribeDetectedAt   // non-null when canceled
val store = entitlement.store                 // PLAY_STORE

billingIssueDetectedAt being non-null means the user is in grace period or account hold. Show them a payment update prompt.

unsubscribeDetectedAt being non-null means the user has canceled but may still have active access until expirationDate.

Showing State-Aware UI

Use EntitlementInfo to drive UI decisions beyond the basic access gate. The same isActive boolean that grants access also lets you surface contextual prompts, such as a renewal reminder for users who have canceled but are still within their paid period.

kotlin
fun updateUI(entitlement: EntitlementInfo?) {
    if (entitlement == null || !entitlement.isActive) {
        showSubscribeScreen()
        return
    }

    showPremiumContent()

    when {
        entitlement.billingIssueDetectedAt != null ->
            showBillingIssueWarning()
        entitlement.unsubscribeDetectedAt != null ->
            entitlement.expirationDate?.let { showExpiryNotice(it) }
        !entitlement.willRenew ->
            showNonRenewingNotice()
    }
}

User Identification

For multi-device support and linking purchases to your user accounts, pass your user ID at login:

kotlin
// When user logs in
val loginResult = Purchases.sharedInstance.awaitLogIn("your_user_id")
val customerInfo = loginResult.customerInfo
val created = loginResult.created  // true if this is a new RevenueCat user

// When user logs out
Purchases.sharedInstance.logOut()
// After logOut, an anonymous user session starts

awaitLogIn() merges any anonymous purchases made before login with the identified user. created is true if this is a new RevenueCat user.

CustomerInfo Caching

CustomerInfo is cached on disk. Subsequent calls to awaitCustomerInfo() return the cache immediately, then fetch from the network in the background if the cache is stale. This means your entitlement checks are fast even offline.

For scenarios where you need guaranteed fresh data (for example, after a support interaction that granted a promotional subscription), use:

kotlin
val customerInfo = Purchases.sharedInstance.awaitCustomerInfo(
    fetchPolicy = CacheFetchPolicy.FETCH_CURRENT
)

Listening for Changes

The UpdatedCustomerInfoListener fires whenever the SDK updates its cache:

kotlin
Purchases.sharedInstance.updatedCustomerInfoListener =
    UpdatedCustomerInfoListener { customerInfo ->
        val isActive = customerInfo.entitlements["pro_access"]?.isActive == true
        updateAccessGate(isActive)
    }

This fires after purchases, restores, and background refreshes. It does not fire if the SDK starts with a cache hit and nothing changed. Always call awaitCustomerInfo() on launch as well.

Prefer building from scratch?

The Google Play Billing Handbook covers the same topics with raw BillingClient, Developer API, and RTDNs.

Related chapters

  • Chapter 12: Payment Recovery

    In-app messages are shown automatically by default. Just two lines needed to detect grace period.

    Learn more
  • Chapter 13: Cancellations, Pauses, and Winback

    Read unsubscribeDetectedAt and expirationDate. That's your entire cancellation handler, done.

    Learn more
  • Chapter 10: Webhooks

    One endpoint with normalized JSON events. No Cloud Pub/Sub configuration, no base64 decoding.

    Learn more