Guide · Chapter 3 of 5

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.

StatusAccessWhat it means
activeOnVerified and renewing normally, with expiresAt in the future.
cancelledOn until expiry, then offAuto-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.
expiredOffThe 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_periodOnA renewal payment failed and the store is retrying. Access is deliberately kept on so a card problem never looks like a product outage.
account_holdOffThe retry window ended without a successful payment. Access is suspended, but the subscription can still recover if the subscriber fixes their payment method.
unknownOffNo 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.)

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:

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.

Related reading

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.

← Back to the server-side verification guide

Entitlements in one REST call

Free tier — unlimited apps, 1 paywall. No credit card, no revenue share.

Start free