Subscription States
Decide whether a user has access, and drive state aware UI, using CustomerInfo and EntitlementInfo on Android.
Phase 1: Discover
Confirm what you are actually checking before you write code.
- Which entitlement identifier gates the feature? (for example
pro_access) - Do you need a plain access boolean, or do you also need to explain why the user has or lacks access (billing issue, canceled but still paid, paused)?
- Do you need the cached value (fast, possibly stale) or a freshly fetched value (server authoritative)?
- Are you refreshing UI once on launch, or reacting to live changes (purchase, restore, background refresh)?
If you only need access on/off, you only need isActive. Everything else is optional context.
Phase 2: Plan
Google's eight states versus RevenueCat's boolean
Rolling your own tracker with the Google Play Developer API means mapping eight subscription states and deciding which grant access.
| Google state | Grants access? |
|---|---|
| ACTIVE | yes |
| IN_GRACE_PERIOD | yes |
CANCELED (before expirationDate) | yes |
| ON_HOLD | no |
| PAUSED | no |
| EXPIRED | no |
| PENDING | no |
| PENDING_PURCHASE_CANCELED | no |
RevenueCat computes this on the backend from the Google SubscriptionPurchaseV2 resource and exposes the result as one field: EntitlementInfo.isActive. You read a boolean instead of implementing the state machine.
What to read, and when
| Need | Field |
|---|---|
| Does the user have access right now? | entitlement.isActive |
| Will the subscription renew at period end? | entitlement.willRenew |
| When does paid access end? | entitlement.expirationDate |
| Is this a trial, intro, prepaid, or normal period? | entitlement.periodType |
| Is there a payment problem? | entitlement.billingIssueDetectedAt |
| Has the user canceled but still has time left? | entitlement.unsubscribeDetectedAt |
| Which store issued the entitlement? | entitlement.store |
Decision rules
- Gate features on
isActive == true. Nothing else. - Use
billingIssueDetectedAt != nullto show a fix payment prompt. - Use
unsubscribeDetectedAt != nullwithexpirationDateto show a renewal reminder while access is still valid. - Use
!willRenew(when no billing issue and no explicit cancel timestamp) to show a non renewing notice.
Phase 3: Execute
Read CustomerInfo and check access
kotlinval customerInfo = Purchases.sharedInstance.awaitCustomerInfo() val hasAccess = customerInfo.entitlements["pro_access"]?.isActive == true
awaitCustomerInfo() returns the disk cache immediately, then refreshes from the network in the background. Entitlement checks stay fast, even offline.
Force a fresh fetch when you must
Use this after a server side grant (for example, a support agent issued a promo).
kotlinval fresh = Purchases.sharedInstance.awaitCustomerInfo( fetchPolicy = CacheFetchPolicy.FETCH_CURRENT )
Drive state aware UI
isActive gates access; the other fields explain context.
kotlinfun updateUI(entitlement: EntitlementInfo?) { if (entitlement != null && !entitlement.isActive && entitlement.billingIssueDetectedAt != null) { // Account hold, or an expiry after a billing issue that was never resolved. See revenuecat/payment-recovery. showAccountHoldScreen(); return } if (entitlement == null || !entitlement.isActive) { showSubscribeScreen(); return } showPremiumContent() when { entitlement.billingIssueDetectedAt != null -> showBillingIssueWarning() entitlement.unsubscribeDetectedAt != null -> entitlement.expirationDate?.let { showExpiryNotice(it) } !entitlement.willRenew -> showNonRenewingNotice() } }
Messaging guide by signal
| Signal on an active entitlement | Message to show |
|---|---|
billingIssueDetectedAt != null | Payment problem, update method |
unsubscribeDetectedAt != null | Access ends on expirationDate |
willRenew == false (no other signal) | Will not renew this period |
periodType == TRIAL or INTRO | Trial or intro pricing in effect |
Identify the user for multi device
kotlinval result = Purchases.sharedInstance.awaitLogIn("your_user_id") val customerInfo = result.customerInfo val createdNewUser = result.created
awaitLogIn() merges anonymous purchases with the identified user. logOut() starts a fresh anonymous session.
Phase 4: Verify
Listen for CustomerInfo updates
Register a listener so UI reacts to purchases, restores, and background refreshes without manual polling.
kotlinPurchases.sharedInstance.updatedCustomerInfoListener = UpdatedCustomerInfoListener { info -> val active = info.entitlements["pro_access"]?.isActive == true updateAccessGate(active) }
Setting the listener immediately delivers the cached CustomerInfo if one exists. On a first launch with no cache it fires only after the first fetch, so still call awaitCustomerInfo() on launch to drive the initial UI.
Test matrix
Walk through each case and confirm the UI responds correctly.
| Case | Expected isActive | Expected UI |
|---|---|---|
| Fresh purchase | true | Premium content |
| Grace period (billing issue, still granted) | true | Premium + billing warning |
Canceled, still before expirationDate | true | Premium + expiry notice |
| On hold | false | Account hold screen (fix payment method, paywall as a fallback) |
| Expired after a billing issue that was never resolved | false | Account hold screen, whose paywall path lets the user buy again |
| Paused | false | Subscribe screen |
| Expired | false | Subscribe screen |
| Pending (no payment yet) | false | Subscribe screen |
Sanity checks
- Access gate flips correctly when the listener fires after a purchase.
FETCH_CURRENTupdatesCustomerInfoafter a backend grant (promo, refund, support action).- After
logOut(),CustomerInforeflects an anonymous user andisActiveresets accordingly. - Offline launch still returns cached
CustomerInfoand gates access without a network call.


