Entitlements
Entitlements define what users can do based on their plan. The SDK evaluates entitlements locally from your Playbook in every mode, and provides React hooks and imperative APIs for access control.
Entitlement Types
Section titled “Entitlement Types”| Type | Example | Check Pattern |
|---|---|---|
| Feature gate | brand_kit, data_export | Boolean — allowed or denied |
| Usage limit | api_calls, storage_gb | Numeric — current vs. limit |
| Credits | render_credits | Depleting balance |
| Seats | team_members | Per-account count limit |
| Capability tier | analytics_tier | Plan-level access (starter → pro → enterprise) |
Checking Entitlements
Section titled “Checking Entitlements”One rule, every surface. The verdict is allowed on an EntitlementResult — rt.can(), useEntitlement().result, the server re-check — and can on the useCan hook, which is resolved && !denied and so also covers the moment before the first answer arrives. status is the shape of the answer, never the verdict: limited appears on both sides of allowed.
React Hook — useCan
Section titled “React Hook — useCan”The simplest reactive check. useCan(handle) returns { can, limited, result }. can is the verdict: resolved && !denied, where denied means status === 'denied' || allowed === false. A limited result that still grants (allowed: true) keeps can true; a limited result that denies is a deny. Gate on !can — it folds in “not yet resolved”, which result.allowed alone cannot:
import { useCan } from "@revturbine/sdk";import { DemoApp } from "./DemoApp";
function ExportButton() { const { can, limited } = useCan("data_export"); if (!can) return <button disabled>Upgrade to Export</button>; return <button>Export Video{limited ? " — running low" : ""}</button>;}
// Dan is not entitled to data_export, so `can` is false and the button is// disabled. Switch to user="user_alice" (entitled) and it enables.export default function App() { return ( <DemoApp user="user_dan"> <ExportButton /> </DemoApp> );}Opens a live, editable sandbox with the published SDK.
Full surface — useEntitlement
Section titled “Full surface — useEntitlement”For the complete reactive result (allowed / denied / limited / isLoading / error / gatedPlacement / recheck), use useEntitlement:
import { useEntitlement } from '@revturbine/sdk';
function ExportButton() { const { allowed, denied, isLoading } = useEntitlement({ handle: 'data_export', });
if (isLoading) return <button disabled>Loading…</button>; if (denied) return <button disabled>Upgrade to Export</button>;
return <button>Export Video</button>;}Declarative gate — <Gate>
Section titled “Declarative gate — <Gate>”<Gate> gates its children declaratively. can="handle" checks an entitlement (shorthand for check={{ entitlement: 'handle' }}); children render while entitled, and a configured upgrade placement — or deniedFallback — shows on denial. limitedFallback renders a soft “running low” state while access is still granted:
import { Gate } from "@revturbine/sdk";import { DemoApp } from "./DemoApp";
// Dan lacks data_export, so the gate renders the configured upgrade placement// in place of the children. Switch to user="user_alice" to see the children.export default function App() { return ( <DemoApp user="user_dan"> <Gate id="editor_export_action" surfaceTemplateIds={["modal_overlay"]} can="data_export" > <button>Export Video</button> </Gate> </DemoApp> );}Opens a live, editable sandbox with the published SDK.
A <Gate> is also a placement. On denial the gate doesn’t render hardcoded UI — it resolves a placement from your monetization config and renders it in place of the children. So what a denied user sees — the upgrade modal or banner, its headline, offer, and CTA — is authored in the dashboard (or the CLI config), not baked into your app. Your PM, growth, or marketing team can rewrite the denial copy, swap the offer, or A/B-test it with no code deploy. surfaceTemplateIds scopes which surface templates can fill the gate; deniedFallback is the static backstop shown only when no placement is configured for it.
Live gate example
Section titled “Live gate example”Switch demo users to see the gate respond — it runs in local_only mode with no backend. Two more gate scenarios are on the Interactive Playground: Branding gate and Brand Kit gate.
Data Export gate — a blocking modal gate on a paid feature.
import { Gate } from "@revturbine/sdk";
export function Example() { return ( <Gate id="editor_export_action" surfaceTemplateIds={["modal_overlay"]} check={{ entitlement: "data_export" }} > <div style={{ padding: 16, background: "#e8f5e9", borderRadius: 8, border: "1px solid #a5d6a7" }}> ✅ Access granted — premium content visible </div> </Gate> );}With Auto-Gate
Section titled “With Auto-Gate”When autoGate: true, the hook automatically resolves an upgrade placement if the entitlement is denied:
const { allowed, denied, gatedPlacement } = useEntitlement({ handle: 'data_export', autoGate: true, gatePlacementRequest: { slotId: 'export_gate', surfaceTemplateIds: ['modal_overlay'], },});
if (denied && gatedPlacement) { // Render gatedPlacement.content as an upgrade prompt}Imperative API
Section titled “Imperative API”Pass only what your app knows better than the Playbook — current consumption, or the tier a feature needs. Everything else comes from plan configuration.
| Entitlement type | Second argument | Result includes |
|---|---|---|
| Feature | — | status, allowed |
| Capability tier | { requiredTier } | status, allowed, current_tier |
| Usage limit | { used } | status, allowed, limit, used, remaining |
| Credits | { balance } | status, allowed, remaining |
| Seats | — (counts come from the usage snapshot) | status, allowed |
const result = await sdk.can('data_export');
// result.status: 'allowed' | 'limited' | 'denied'// result.allowed: boolean// result.reason: string (optional)// result.current_tier: string (optional, for tier-based)// result.limit / used / remaining: present when a usage_limit or credits rule matchedEntitlement Result
Section titled “Entitlement Result”Every entitlement check returns an EntitlementResult:
interface EntitlementResult { status: 'allowed' | 'limited' | 'denied'; allowed: boolean; reason?: string; current_tier?: string; limit?: number; // effective limit from the matched rule used?: number; // amount the evaluation applied against limit remaining?: number; // max(0, limit - used)}// limit / used / remaining appear only on limit-bearing outcomes — a matched// usage_limit or credits rule. Their absence means the check was// limit-agnostic (a feature or tier rule), not unlimited. For balances across// handles, use useUsageSnapshot() (see "Reading Usage" below).| Status | Meaning |
|---|---|
allowed | Full access — proceed normally |
limited | At or past a limit. Read allowed, not this — under the default (unset) enforcement limited carries allowed: false; only degrade and allow_overage pair it with allowed: true. There is no “approaching” state. |
denied | No access — show upgrade prompt or block |
Usage Tracking
Section titled “Usage Tracking”Report usage to keep entitlement checks accurate:
// Update current balancessdk.update({ usage: { api_calls: 950, storage_gb: 45, team_members: 5, },});update() triggers:
- Re-evaluation of usage-based entitlements
- Segment re-evaluation (targeting rules may change)
- Decision cache invalidation
- Local runtime state persistence
update() accepts a widened session-context patch, so the same call can also
refresh plan, traits, or page context. Usage balances always belong under the
usage key; a bare entitlement handle is not interpreted as usage.
Generate Entitlement Handle Types
Section titled “Generate Entitlement Handle Types”Generate a union from your Playbook so can() accepts only handles that exist:
npx revturbine generate types ./revturbine.playbook.json --out src/revturbine.generated.tsimport { Entitlements } from './revturbine.generated';
// The generated value retains its EntitlementHandle literal type.const handle = Entitlements.feature.data_export;const result = await sdk.can(handle);See the CLI reference for selectors and all supported generated type families.
Reading Usage
Section titled “Reading Usage”import { useUsageSnapshot } from '@revturbine/sdk';
function UsagePanel() { const { usage, refresh } = useUsageSnapshot();
return ( <ul> {Object.entries(usage).map(([handle, data]) => ( <li key={handle}> {handle}: {data.current} / {data.limit ?? '∞'} </li> ))} </ul> );}Patterns by Entitlement Type
Section titled “Patterns by Entitlement Type”Feature Gate
Section titled “Feature Gate”const { allowed } = useEntitlement({ handle: 'brand_kit' });// allowed: true/falseUsage Limit
Section titled “Usage Limit”const { allowed, limited, result } = useEntitlement({ handle: 'api_calls' });// limited: at or past the limit — read `allowed` for the verdict// result.used / result.limit / result.remaining: on the result for this checkCredits
Section titled “Credits”const { limited, result } = useEntitlement({ handle: 'render_credits' });// result.status / result.allowed reflect access as credits run low// result.remaining: the balance the evaluation usedconst { denied, result } = useEntitlement({ handle: 'team_members' });if (denied) { // seat counts: read from useUsageSnapshot() (see "Reading Usage") // Show "seat limit reached" prompt}Capability Tier
Section titled “Capability Tier”const { denied, result } = useEntitlement({ handle: 'advanced_analytics' });if (denied) { // result.current_tier: 'professional' // Show "upgrade to Professional" prompt}Fail-Closed Semantics
Section titled “Fail-Closed Semantics”A check grants access only when it can affirmatively justify the grant; every other outcome denies, with allowed: false and a reason naming why. That is what fail-closed means wherever these docs use it — and it is about the answer, not the network: the Playbook is cached locally, so a configured runtime answers without a round trip, and the no-rule cases below are the only ones where there was nothing to evaluate. The reason lets you tell those apart from a real denial:
// Hosted mode, the launched Playbook could not be fetched:// { status: 'denied', allowed: false, reason: 'config_unavailable' }
// Local mode, no Playbook and no cached result:// { status: 'denied', allowed: false, reason: 'entitlement_not_in_playbook' }
// A real denial — a rule decided this:// { status: 'denied', allowed: false, reason: 'no_matching_entitlement_rule' }See Entitlement reason codes for the complete emitted set.
Next Steps
Section titled “Next Steps”- Placements Guide — render upgrade prompts for denied entitlements
- Events & Analytics — track entitlement check outcomes
- Error Handling — fail-closed checks and recovery patterns
- Try it live → Data Export Gate