Skip to content

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.

TypeExampleCheck Pattern
Feature gatebrand_kit, data_exportBoolean — allowed or denied
Usage limitapi_calls, storage_gbNumeric — current vs. limit
Creditsrender_creditsDepleting balance
Seatsteam_membersPer-account count limit
Capability tieranalytics_tierPlan-level access (starter → pro → enterprise)

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.

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:

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

For the complete reactive result (allowed / denied / limited / isLoading / error / gatedPlacement / recheck), use useEntitlement:

React
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>;
}

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

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

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.

Example.tsx
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>
);
}

When autoGate: true, the hook automatically resolves an upgrade placement if the entitlement is denied:

React
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
}

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 typeSecond argumentResult 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
TypeScript
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 matched

Every entitlement check returns an EntitlementResult:

TypeScript
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).
StatusMeaning
allowedFull access — proceed normally
limitedAt 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.
deniedNo access — show upgrade prompt or block

Report usage to keep entitlement checks accurate:

TypeScript
// Update current balances
sdk.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 a union from your Playbook so can() accepts only handles that exist:

Terminal window
npx revturbine generate types ./revturbine.playbook.json --out src/revturbine.generated.ts
TypeScript
import { 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.

React
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>
);
}
React
const { allowed } = useEntitlement({ handle: 'brand_kit' });
// allowed: true/false
React
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 check
React
const { limited, result } = useEntitlement({ handle: 'render_credits' });
// result.status / result.allowed reflect access as credits run low
// result.remaining: the balance the evaluation used
React
const { denied, result } = useEntitlement({ handle: 'team_members' });
if (denied) {
// seat counts: read from useUsageSnapshot() (see "Reading Usage")
// Show "seat limit reached" prompt
}
React
const { denied, result } = useEntitlement({ handle: 'advanced_analytics' });
if (denied) {
// result.current_tier: 'professional'
// Show "upgrade to Professional" prompt
}

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:

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