# Subscription States

> 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.

- Skill: `revenuecat/subscription-states-2` (Agent Skill)
- Install (CLI): `npx skillmds@latest add revenuecat/subscription-states-2`
- Raw SKILL.md: https://api.skillmd.com/api/skills/revenuecat/subscription-states-2/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: Apache-2.0; see LICENSE
- Author: revenuecat (https://skillmd.com/u/revenuecat)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/revenuecat/subscription-states-2

---


# 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:

1. Does your backend call `purchases.subscriptionsv2.get` (not the legacy `purchases.subscriptions.get`)?
2. Does your entitlement function read `subscriptionState` from that response rather than inferring state from the RTDN notification type?
3. Does the function check `expiryTime` when the state is `SUBSCRIPTION_STATE_CANCELED`?
4. 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.

```kotlin
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.

```kotlin
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`.

```kotlin
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.

```kotlin
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

- [Full chapter](https://www.revenuecat.com/guides/google-play-billing/the-subscription-state-machine)

