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 still has access and 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.
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, then off | Auto-renew is off, or the purchase was revoked. On a plain cancellation access is left in place until expiresAt; on a refund, chargeback, or plan supersession it is switched off immediately. |
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. |
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: renewal, recovery, and restart write
active; cancellation writescancelledwhile leaving access alone; grace period writesgrace_periodwith access on; 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; treat status as the explanation, not the gate.
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. 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 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 after a notification-delivery gap; 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 fans out one store round trip per stored purchase, 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 logged and omitted from the response array 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.
Entitlements in one REST call
Free tier — unlimited apps, 1 paywall. No credit card, no revenue share.
Start free