The entitlement state machine
A verified purchase is not a yes/no answer. Here are the six states Tierux stores, what moves a subscriber between them, and when your backend should re-verify instead of trusting what it already has.
Two fields, not one
Reading an entitlement returns both a gate and a reason. GET /api/v1/entitlements/{userId}/{entitlementId} answers with active — the boolean you gate on — plus status, expiresAt and winbackPending:
curl https://tierux.com/api/v1/entitlements/user_abc/pro \ -H "Authorization: Bearer YOUR_API_KEY"
// 200 OK { "userId": "user_abc", "entitlement": "pro", "active": true, "expiresAt": "2026-09-25T00:00:00.000Z", "status": "grace_period", "winbackPending": false }
That pairing is the whole point of the state machine. active tells your code whether to unlock the feature; status tells your product what to say about it. A subscriber in grace_period is still entitled and has a failed payment worth surfacing. A subscriber in cancelled may still have access until the period ends — and is the only one worth showing a save offer to.
Grace is one state where active needs a second look. (A canceled-but-unexpired subscription written by a fresh Play query is a second, known case where active under-grants; see the cancelled row below.) A grace record written by a verify or refresh reads like the sample above: the query stores Play's current expiry, which Play extends while the grace period lasts. A grace record written by Google Play's grace notification alone keeps the expiresAt from before the failed renewal, and because active is recomputed against expiresAt on every read, it usually reads active: false. Treat status: grace_period as entitled in your gate — the grace period chapter of the subscription lifecycle guide has a handler that does this with a time bound.
The six states
The stored status is one of exactly six values. Nothing else is written, and your code never has to parse a store-specific string.
| Status | Access | What it means |
|---|---|---|
active | On | Verified and renewing normally, with expiresAt in the future. |
cancelled | On until expiry on a plain cancellation; off immediately on a refund, revoke, or plan supersession (see the known gap) | Auto-renew is off, or the purchase was revoked. On a plain cancellation access is left in place until expiresAt; on a refund, chargeback, dashboard revoke, or plan supersession it is switched off immediately. Known gap: when the record is written from any fresh Google Play subscription query that reports SUBSCRIPTION_STATE_CANCELED (a verify, a refresh, or the re-query a renewal, recovery, purchase, or restart notification triggers), access is written off even before expiresAt. The response has no reliable field that tells that record apart from a Google Play revoke, an App Store refund or revoke, a dashboard revoke, or a plan supersession, which also read cancelled with active: false and can carry a future expiresAt. winbackPending is not a dependable tell either: a canceled-state query triggered by a Google Play notification arms it when the product has a cancellation win-back offer (a verify or refresh does not touch it), a Google Play or dashboard revoke leaves it as it was, and an App Store refund or revoke clears it. So do not unlock on cancelled plus a future expiresAt. |
expired | Off | The term ended and was not renewed. Access is off, but not permanently: a restart, a reversed refund, or a renewal extension can all move the record back to active — nothing in this table is a dead end. |
grace_period | On | A renewal payment failed and the store is retrying. Access is deliberately kept on so a card problem never looks like a product outage — but on Google Play the read can return active: false (see above), so gate on the status here. |
account_hold | Off | The retry window ended without a successful payment. Access is suspended, but the subscription can still recover if the subscriber fixes their payment method. |
unknown | Off | No entitlement on record for that user and entitlement id, or a store state Tierux does not recognize. Never treat this as "expired" — it is usually "never purchased". |
✓ access on · ~ access on until expiry · ✕ access off
What causes each transition
Three mechanisms drive state in the ordinary course of business — verification, store notifications, and time. (A developer can also revoke an entitlement directly from the dashboard for support cases; that path bypasses verification entirely and is the one exception worth knowing about.)
- A verify call. When a purchase is verified, the store's own view of the subscription is normalized into one of the six states and stored alongside
expiresAt. Google Play's subscription state maps directly — in grace period becomesgrace_period, on hold becomesaccount_hold, canceled becomescancelled, expired becomesexpired, and anything unrecognized becomesunknown(and is never granted). For Apple App Store transactions, a revocation becomescancelled, an expiry date in the past becomesexpired, and everything else that verifies becomesactive. - A store notification. Google Play Real-Time Developer Notifications and Apple App Store Server Notifications move the same state machine without any request from your backend: on Google Play, renewal, recovery, purchase, and restart notifications trigger a fresh subscription query and write the state it reports — usually
active, but it can be any other state the query reports (grace_period,account_hold,cancelled,expired, orunknown); on the App Store, a subscribe, renewal, auto-renew re-enable, reversed refund, or renewal extension writesactive; cancellation writescancelledwhile leaving access alone; grace period writesgrace_periodwith access on (on Google Play, without updatingexpiresAt); on-hold and billing-retry exhaustion writeaccount_holdwith access off; expiry writesexpired; a revoke (refund or chargeback) writescancelledwith access off immediately. Informational events write nothing: on Google Play, a confirmed price change, a deferred renewal, or a pause-schedule change; on the App Store, a renewal-preference change, a price increase, a redeemed offer, or the multi-subscriber renewal-extension summary. Coverage differs per store: Play RTDN is the production-hardened path and the App Store notification path is implemented with limitations — see the platform support matrix. - Time. Expiry is not a job that has to run. When a verification says the subscription is no longer live, the stored status is re-derived: if
expiresAtis already in the past the state becomesexpired; if the store still claimed "active" but the entitlement did not hold up, it becomesunknownrather than silently staying active. And on every read,activeis recomputed againstexpiresAt— an entitlement whose expiry has passed reads as inactive even if no notification has arrived yet.
One edge worth knowing: a paused Google Play subscription turns access off either way, but it is labelled account_hold when Tierux learns about it from a fresh subscription query and unknown when it arrives as a pause notification. Gate on active and treat status as the explanation, not the gate — with the exception of grace_period, described above. (A canceled subscription written by a fresh Play query is a known under-grant with no reliable field in the read response to tell it apart; keep gating on active.)
Plan changes are the other case where a state moves without the subscriber doing anything visible. When an upgrade or downgrade issues a new purchase token that links back to the old one, the superseded entitlement is written to cancelled and switched off — but only once the new token verifies as live, so a deferred downgrade never strands a subscriber between two plans.
Why a state, and not just isEntitled
A boolean collapses four situations that deserve four different product behaviours:
grace_periodvsaccount_hold. Both are payment failures. The first still has access and warrants a gentle "update your card" banner; the second has lost access and warrants a recovery flow. A boolean shows the same locked screen for a working subscription and a broken one.cancelledwhile still active. This is the save window — the subscriber has turned off auto-renew but has not lost anything yet. Tierux also flagswinbackPendinghere when the product has a cancellation win-back offer configured, so the client knows to present the save sheet (how that flag is set and cleared). Win-back offers are a Google Play capability today and are not implemented for App Store — see the matrix.unknownvsexpired. "Never bought" and "used to pay you" are different people. Sending a churned-subscriber win-back email to someone who has never purchased is a support ticket waiting to happen.- Support and debugging. When a subscriber says "I paid and it's locked",
statusplusexpiresAtanswers it in one call instead of a Play Console tab and a guess.
You can still write if (res.active) and ignore the rest; the cost is that a Google Play subscriber in grace can read as locked until a refresh. The state is there when you want it, not a tax when you don't.
Refresh, or trust the cached state?
The read endpoint is cheap and already current — store notifications keep it that way without your backend polling. POST /api/v1/entitlements/{userId}/refresh is the escape hatch: it re-verifies the latest stored purchase per product for that user against the store it came from and rewrites their entitlements.
curl -X POST https://tierux.com/api/v1/entitlements/user_abc/refresh \ -H "Authorization: Bearer YOUR_API_KEY"
// 200 OK { "ok": true, "userId": "user_abc", "entitlements": [ { "ok": true, "userId": "user_abc", "entitlement": "pro", "active": true, "expiresAt": "2026-09-25T00:00:00.000Z", "status": "active" } ] }
Call refresh when: the user taps "restore purchases" or signs in on a new device; support is investigating a specific account; you are reconciling a Google Play or Stripe web subscription after a notification-delivery gap (an App Store purchase is not re-queried from Apple, so refresh cannot repair a missed Apple notification); or you have just changed a product-to-entitlement mapping and want the user's state rebuilt against it.
Trust the cached state when: you are gating a request. Refresh re-verifies the latest stored purchase per product per store the user holds — a fresh Google Play query for each Play purchase and a live Stripe lookup for each Stripe web purchase, while an App Store purchase is re-verified from its stored signed transaction (JWS) rather than re-queried from Apple — so it is a user-triggered or support-triggered action, never a per-request one. Read GET /api/v1/entitlements/{userId}/{entitlementId} on the hot path.
Two behaviours to design around. Refresh is per purchase, isolated: a single purchase that cannot be re-verified is omitted from the response array (decryption and verification failures are logged; a purchase with no stored ciphertext, or an App Store purchase with no transaction id, is skipped silently) rather than failing the whole call — so treat the returned list as "what could be refreshed just now", not as the user's complete entitlement inventory. And the response array only contains entitlements that were re-verified, so read back through the check endpoint if you need the authoritative current state for one specific entitlement id.
Grace period
The state where the payment failed but access stays on — why keeping the lights on beats locking a paying subscriber out.
Account hold
What happens after the retry window closes: access off, subscription recoverable.
Server-side entitlements
The API this state machine lives behind — one REST call, same response shape across stores.
Guide: Grace period, account hold, and billing retry
The failed-renewal transitions in depth, from the subscription lifecycle guide.
Entitlements in one REST call
Free tier — unlimited apps, 1 paywall. No credit card, no revenue share.
Start free