rc-subscription-states — Claude Code skill: RevenueCat access gating
A skill for Android apps on RevenueCat that tells the agent how to gate paid features through CustomerInfo and EntitlementInfo.
npx skills add RevenueCat/ai-toolkit --skill rc-subscription-states -a claude-codeWhat rc-subscription-states does
The skill walks the agent through four phases: discover what is actually being checked, plan, execute, verify. The idea it repeats in every code sample is that access is gated on entitlement.isActive == true and nothing else. The other fields — willRenew, periodType, expirationDate, billingIssueDetectedAt, unsubscribeDetectedAt, store — exist to explain context, not to decide access.
Inside there is a mapping table that collapses Google Play's seven subscription states (ACTIVE, IN_GRACE_PERIOD, CANCELED before the expiration date, ON_HOLD, PAUSED, EXPIRED, PENDING) into that single boolean. If you have been maintaining that state machine yourself against the Google Play Developer API, this is the part that saves you the work. There are Kotlin samples too: reading CustomerInfo with awaitCustomerInfo(), forcing a refresh via CacheFetchPolicy.FETCH_CURRENT after a backend grant, wiring updatedCustomerInfoListener, and calling awaitLogIn() to merge anonymous purchases with an identified user.
One trap worth knowing: the listener does not fire when the SDK starts from a cache hit and nothing changed. So awaitCustomerInfo() on launch is still required alongside the listener. The examples are Android and Kotlin only — on another platform or language they will not carry over.
What it helps with
- gate a paid feature on an active subscription
- show a payment-problem warning without revoking access
- remind a canceled user that access ends on
expirationDate - refresh
CustomerInfoafter a promo granted by support - merge anonymous purchases with a logged-in user
Example prompts for the agent
- Gate the pro features on
entitlement.isActiveand show the subscribe screen when there is no access - Handle
billingIssueDetectedAtandunsubscribeDetectedAtin the premium screen UI - Set up
updatedCustomerInfoListenerplus anawaitCustomerInfo()call on launch
How to install rc-subscription-states
1Skills CLI (Vercel)
npx skills add RevenueCat/ai-toolkit --skill rc-subscription-states -a claude-code2Manually: put the files in place
~/.claude/skills/rc-subscription-states/SKILL.mdCopy the whole skill folder; for one project use .claude/skills/rc-subscription-states/
1Skills CLI (Vercel)
npx skills add RevenueCat/ai-toolkit --skill rc-subscription-states -a codex2Manually: put the files in place
.agents/skills/rc-subscription-states/SKILL.mdFor all projects use ~/.agents/skills/
1Skills CLI (Vercel)
npx skills add RevenueCat/ai-toolkit --skill rc-subscription-states -a cursor2Manually: put the files in place
.cursor/skills/rc-subscription-states/SKILL.mdCursor also reads .agents/skills/ and .claude/skills/
1GitHub CLI
gh skill install RevenueCat/ai-toolkit rc-subscription-states2Manually: put the files in place
.github/skills/rc-subscription-states/SKILL.md1Skills CLI (Vercel)
npx skills add RevenueCat/ai-toolkit --skill rc-subscription-states -a gemini-cli2Platform CLI
gemini skills install https://github.com/RevenueCat/ai-toolkit --path revenuecat-play-billing/skills/rc-subscription-statesManually: put the files in place
.devin/skills/rc-subscription-states/SKILL.mdThe legacy .windsurf/skills/ path still works
Skills CLI (Vercel)
npx skills add RevenueCat/ai-toolkit --skill rc-subscription-states -a opencodeClaude.ai
Zip the folder and upload it in Settings → Capabilities → Skills
Step by step, with commands for each platform: How to install skills in Claude Code: five ways
Questions about rc-subscription-states
How do I install rc-subscription-states in Claude?
Run npx skills add RevenueCat/ai-toolkit --skill rc-subscription-states -a claude-code. The skill lands in ~/.claude/skills/rc-subscription-states/SKILL.md.
What does rc-subscription-states do?
It guides the agent through gating features on RevenueCat subscription state on Android: reading CustomerInfo, checking entitlement.isActive, and drilling into willRenew, periodType and expirationDate when the logic needs more than a boolean.
Do I need it if I already map Google Play states myself?
That is exactly the case it targets: instead of mapping seven Google Play states to an access decision, you read one boolean, isActive, that RevenueCat computes on the backend.
Source text
SKILL.md · MIT
---
name: rc-subscription-states
description: Use this skill when gating access based on RevenueCat subscription state on Android. Covers reading CustomerInfo, checking entitlement.isActive as the single source of truth for access, and drilling into willRenew, periodType, and expirationDate when deeper logic is needed.
license: Apache-2.0; see LICENSE
metadata:
author: RevenueCat
source: revenuecat-handbook chapter 11
keywords:
- android
- revenuecat
- customerinfo
- entitlement
- is-active
- will-renew
---
# 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 seven states versus RevenueCat's boolean
Rolling your own tracker with the Google Play Developer API means mapping seven 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 |
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 != null` to show a fix payment prompt.
- Use `unsubscribeDetectedAt != null` with `expirationDate` to 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
```kotlin
val 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).
```kotlin
val fresh = Purchases.sharedInstance.awaitCustomerInfo(
fetchPolicy = CacheFetchPolicy.FETCH_CURRENT
)
```
### Drive state aware UI
`isActive` gates access; the other fields explain context.
```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()
}
}
```
### 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
```kotlin
val 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.
```kotlin
Purchases.sharedInstance.updatedCustomerInfoListener =
UpdatedCustomerInfoListener { info ->
val active = info.entitlements["pro_access"]?.isActive == true
updateAccessGate(active)
}
```
The listener does not fire when the SDK starts with a cache hit and nothing changed. Always call `awaitCustomerInfo()` on launch in addition to setting the listener.
### 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 | Subscribe screen |
| 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_CURRENT` updates `CustomerInfo` after a backend grant (promo, refund, support action).
- After `logOut()`, `CustomerInfo` reflects an anonymous user and `isActive` resets accordingly.
- Offline launch still returns cached `CustomerInfo` and gates access without a network call.
## References
- [Full chapter](https://www.revenuecat.com/guides/revenuecat-android-sdk/subscription-states)
© RevenueCat · MIT · RevenueCat/ai-toolkit