This is the abridged developer documentation for RevTurbine SDK
# Start
> One command, one , a working feature gate in about ten minutes with no account — then connect Stripe and change monetization without a deploy.
RevTurbine is monetization for product-led SaaS in one SDK: plans, entitlements, usage limits, trials, and the upgrade prompts that convert — decided per user, inside your app, from a Playbook you keep. We add no latency and our availability never affects your product: the SDK evaluates your Playbook locally, so decisions never depend on our servers.
This page is the whole happy path. It runs in local mode first — no account, no key — and ends with hosted mode, where the same Playbook changes without a deploy.
**Note — Not using React?:**
The walkthrough is React because that is the shortest path to something on screen. The engine is framework-agnostic: `import { initRevTurbine } from '@revturbine/sdk/headless'` pulls in no React and runs on Svelte, Vue, plain TypeScript, and Node. Same checks, same decisions; you render the resolved content yourself. See [Headless API](/revturbine-sdk/guides/headless-api/) and [Server-Side Integration](/revturbine-sdk/getting-started/server-side/). Python and Rust SDKs evaluate the same Playbook with byte-identical results.
## Two ideas to hold
Every decision RevTurbine makes combines two things, and the SDK combines them locally:
- The **Playbook** is what you authored, the same for every user: [plans](/revturbine-sdk/concepts/runtime-and-playbook/#plan) and their [variations](/revturbine-sdk/concepts/runtime-and-playbook/#plan-variation), [entitlements](/revturbine-sdk/concepts/runtime-and-playbook/#entitlement) and the [rules](/revturbine-sdk/concepts/runtime-and-playbook/#entitlement-rule) that grant them, [segments](/revturbine-sdk/concepts/runtime-and-playbook/#segment), and [placements](/revturbine-sdk/concepts/runtime-and-playbook/#placement) with their [payloads](/revturbine-sdk/concepts/runtime-and-playbook/#placement-payload). It is one versioned file.
- The **Runtime** is who is asking right now: the [UserContext](/revturbine-sdk/concepts/runtime-and-playbook/#usercontext), their [usage](/revturbine-sdk/concepts/runtime-and-playbook/#usage), and the [entitlement grants](/revturbine-sdk/concepts/runtime-and-playbook/#entitlement-grant) that result. [AccountContext](/revturbine-sdk/concepts/runtime-and-playbook/#accountcontext) is planned, not shipped.
Plain-English definitions, how initialization and context changes drive a decision, and a worked example are on [Runtime and Playbook](/revturbine-sdk/concepts/runtime-and-playbook/). The code for every runtime is on [Initialize the SDK](/revturbine-sdk/getting-started/initialization/).
## Integration questions
Use these focused guides when moving beyond the first gate:
- [SDK upgrade behavior](/revturbine-sdk/guides/sdk-upgrades/): mirrors, denial reasons, gates, and presentation changes.
- [Plans without subscriptions](/revturbine-sdk/guides/plans-without-subscriptions/): explicit free-plan policy and hosted context precedence.
- [Visitor, user, and account identity](/revturbine-sdk/guides/identity-lifecycle/): sign-in, sign-out, and account switches.
- [Usage periods and resets](/revturbine-sdk/guides/usage-periods/): application accounting and atomic enforcement.
- [Billing facts versus app events](/revturbine-sdk/guides/events/#billing-facts-and-application-events): Stripe ownership and event identity.
- [Mounted React gates](/revturbine-sdk/getting-started/react/#keeping-mounted-gates-current): reactive updates without remounting.
- [Warnings and diagnostics](/revturbine-sdk/guides/troubleshooting/#warnings-that-need-an-integration-fix): what to fix when initialization or decisions degrade.
- [Custom CTA actions and prices](/revturbine-sdk/guides/custom-cta-actions/): authored versus resolved shape and currency units.
- [First CLI upload](/revturbine-sdk/reference/cli/#first-upload-of-a-starter-playbook): tenant selection and local evaluation limits.
- [Parameter entitlements](/revturbine-sdk/guides/entitlements/#parameter-entitlements): application-read metadata rather than access gates.
These pages are also included in the generated `llms-full.txt` for coding agents.
## 1. Bootstrap
**With your coding agent**
Paste this into Claude Code, Cursor, or whichever agent you use. The scaffold installs the RevTurbine agent skills, so the agent knows the rest.
```text
Run `npm create revturbine@latest` in this project. Then follow the
revturbine-start-here skill: wrap the app in in local
mode with the generated revturbine.playbook.json, and replace ONE
non-critical hard-coded plan check with . Do not touch server-side
enforcement. Show me the diff before you finish.
```
Starting a new app instead? Scaffold it with its framework first (for example `create-next-app`), then paste this:
```text
Run `npm create revturbine@latest` in this new app. Then follow the
revturbine-start-here skill and build monetization in from the start: put
every plan, limit and upgrade prompt in revturbine.playbook.json, never in
app code, and run `npx revturbine validate ./revturbine.playbook.json` after
each change. Gate features with or can(), and re-check anything paid
on the server. Show me the Playbook and the diff before you finish.
```
The fuller prompts for both cases — and the CLI loop an agent uses to keep changing monetization afterwards — are on [Add RevTurbine with a Coding Agent](/revturbine-sdk/guides/coding-agent/).
**By hand**
```bash
npm create revturbine@latest
```
That installs `@revturbine/sdk`, pins `@revturbine/cli` to the project, drops a local-mode `revturbine.playbook.json` with a free and a pro plan, one gated feature and one upgrade prompt, and installs the agent skills. `npx revturbine create` and `npx @revturbine/cli init` run the same command. Already have an account? `revturbine download --live --save ./revturbine.playbook.json` pulls your real Playbook instead.
## 2. Wrap your app
Local mode, no account: the Playbook you just generated is the whole configuration.
```tsx title="src/App.tsx"
import { RevTurbineProvider } from '@revturbine/sdk';
import playbook from './revturbine.playbook.json';
import { useMemo } from 'react';
export default function App() {
const options = useMemo(() => ({
localRuntime: { playbook },
user: { id: 'user_123', plan_handle: 'starter' },
}), []);
return (
);
}
```
`plan_handle` is the plan's handle from your Playbook — the value plan-scoped rules match on. In hosted mode it arrives from Stripe on its own (step 7); here you supply it.
## 3. Gate a feature
`` renders its children when the user is entitled. When they aren't, it renders the upgrade prompt your Playbook configures for that gate — copy, offer and CTA authored in config, not in your app.
```tsx title="src/components/BatchExport.tsx"
import { Gate } from '@revturbine/sdk';
export function BatchExport() {
return (
);
}
```
Start the dev server. With the starter Playbook's free plan, the gate shows the upgrade prompt; change `plan_handle` to `'pro'` and the button appears.
## 4. Check and protect in code
For anything that isn't a React subtree — an event handler, a keyboard shortcut, a server route — ask the SDK directly.
```tsx
import { useRevTurbine } from '@revturbine/sdk';
function ExportMenu() {
const { sdk: rt } = useRevTurbine();
async function onExport() {
// Ask: the answer, and nothing else.
const access = await rt.can('batch_export');
// Ask and act: runs exportPdf only when entitled; hands back the
// entitlement so you can open the upgrade prompt otherwise.
const gated = await rt.gate('export_pdf', () => exportPdf());
if (!gated.ran) openUpgrade(gated.entitlement);
}
return ;
}
```
`can()` returns `{ status, allowed, reason }` — plus `limit` / `used` / `remaining` when a usage or credit rule matched. See [Entitlements](/revturbine-sdk/guides/entitlements/).
## 5. Add a slot
A `` is a spot where RevTurbine may show something — a banner, a modal, a toast, a button. Which placement fills it, for which user, with what copy and how often, is decided from the Playbook: targeting, caps, cooldowns and priority between competing prompts are the engine's job, not yours.
```tsx title="src/components/Dashboard.tsx"
import { Slot } from '@revturbine/sdk';
export function Dashboard() {
return (
Dashboard
{/* An upgrade banner for the users the Playbook targets — or nothing */}
);
}
```
When a user clicks a CTA inside a placement, the SDK calls a resolver you provide — `navigate_to_plans`, `open_checkout_modal`, and so on — so navigation and checkout stay in your code. The starter Playbook uses `navigate_to_plans`; add one line to `options`:
```tsx docs-check=false reason="one option line; the full initRevTurbine options object is shown above"
uiPathResolvers: { navigate_to_plans: () => { window.location.href = '/pricing'; } },
```
Every action type and what it carries: [Placements → UI paths](/revturbine-sdk/guides/placements/).
## 6. Report usage and track events
Usage limits and credits need to know the balance; the Playbook decides what to do about it.
```ts
await rt.update({ usage: { ai_credits_used: 42 } }); // absolute balance, not a delta
await rt.track('ai_generation_completed', { credits: 3 });
```
Balances drive quota meters, low-credit warnings and limit prompts. Events feed targeting and analytics.
## 7. Verify on your server
The browser check drives the UI; your server makes the money-safe decision before paid work runs — with the same SDK, evaluating the same Playbook in-process.
```ts
import { initRevTurbine, RuntimeMode } from '@revturbine/sdk/headless';
import playbook from './revturbine.playbook.json';
export async function POST(request: Request): Promise {
const user = await getSignedInUser(request); // your own auth
const rt = await initRevTurbine({
runtimeMode: RuntimeMode.LocalOnly,
localRuntime: { playbook }, // loaded once per process; `revturbine download --live` on deploy
user: { id: user.id, plan_handle: user.planHandle },
});
const result = await rt.can('batch_export');
if (!result.allowed) return Response.json({ error: 'upgrade_required' }, { status: 402 });
return runExport(request);
}
```
One instance per request, built from a Playbook you loaded once, so the check
makes no network call. Why the re-check, and which actions need it:
[Client vs Server Enforcement](/revturbine-sdk/concepts/enforcement/). Why a server does not use
hosted mode, and what it can report: [Server-Side Integration](/revturbine-sdk/getting-started/server-side/).
## 8. Go hosted: connect Stripe, change it without a deploy
Everything above works forever in local mode, free. Hosted mode is where you stop deploying to change monetization.
1. **Create an account and connect the CLI.** [revturbine.com/app](https://revturbine.com/app), then `revturbine login` (a browser device flow — no keys to paste).
2. **Connect Stripe** in **Settings → Stripe**. From then on a purchase updates what the user can do with no app code: plan and payment state arrive through a client session. Put `revturbine_account_id` and `revturbine_user_id` in the metadata of every Stripe customer you create so each payer links to an account from day one: [Link Stripe customers to accounts](/revturbine-sdk/guides/billing/#link-stripe-customers-to-accounts). See [Billing & Stripe](/revturbine-sdk/guides/billing/).
3. **Switch the provider to hosted mode.** Mint an ingest key under **Settings → API Token Management → Ingest Keys** and add your app's origins to its allowed origins. Then add the **client-session route** to your server (it mints a short-lived token with your server key, so the plan Stripe holds reaches the SDK) and pass both to the provider:
```tsx
import { RevTurbineProvider } from '@revturbine/sdk';
import { useMemo, type ReactNode } from 'react';
import { fetchClientSession } from './revturbine-client-session';
export function RevTurbine({ userId, children }: { userId: string; children: ReactNode }) {
const options = useMemo(() => ({
publicKey: process.env.NEXT_PUBLIC_REVTURBINE_PUBLIC_KEY!, // public key, safe in the browser
user: { id: userId }, // plan_handle now comes from Stripe
clientSession: fetchClientSession, // calls YOUR route; see Client sessions
}), [userId]);
return {children};
}
```
The route and the `fetchClientSession` function are on [Client sessions](/revturbine-sdk/guides/client-sessions/); every initialization variant is on [Initialize the SDK](/revturbine-sdk/getting-started/initialization/).
4. **Change something and reload.** Open the dashboard, edit the banner's copy or the free plan's export limit, launch the release. The SDK picks it up on its next refresh — no deploy. Or do it from the terminal: `revturbine download --live`, edit, `revturbine validate`, `revturbine diff --live`, `revturbine launch`.
That loop — see what converted, change the Playbook, launch — is the product. Your agent can drive it too: [Add RevTurbine with a Coding Agent](/revturbine-sdk/guides/coding-agent/).
## Why RevTurbine
**Decisions, not just rendering**
Caps, cooldowns, eligibility, and priority across competing placements are decided for you. One `` returns the winner. No client-side orchestration, no homegrown segment logic.
**Entitlements and prompts in one engine, tied to billing**
A `` surfaces usage warnings, trial nudges and lifecycle prompts tied to billing state; `rt.can()` confirms or denies access. Gate a feature and show the upgrade prompt that converts it, from one Playbook.
**Changed by product, not by a deploy**
Drop a slot once. Copy, targeting, limits, offers and prices change in the dashboard or through the CLI; resolvers keep navigation in your code. Every launch is a versioned release you can roll back.
**The same answer everywhere**
`@revturbine/sdk/headless` imports no React; Python and Rust evaluate the same Playbook. A cross-language gate diffs their decisions byte-for-byte, so a check answers identically wherever you make it.
## Integration reference
| Task | Guide |
|---|---|
| Initialize React and inspect readiness | [React Integration](/revturbine-sdk/getting-started/react/#userevturbine) |
| Run checks without React | [TypeScript / Node](/revturbine-sdk/guides/headless-api/), [Python](/revturbine-sdk/getting-started/python/), [Rust](/revturbine-sdk/getting-started/rust/) |
| Choose Playbook delivery and optional wiring | [Runtime Modes](/revturbine-sdk/guides/runtime-modes/), [Configuration](/revturbine-sdk/reference/configuration/) |
| Match your app's colors | [Theming](/revturbine-sdk/guides/theming/) |
| Route blocked actions to a paywall | [Paywall Host Pattern](/revturbine-sdk/guides/paywall-host/) |
| Explain a missing placement or denied check | [Troubleshooting](/revturbine-sdk/guides/troubleshooting/), [Debugging Decisions](/revturbine-sdk/operate/debugging/) |
| Check release changes before upgrading | [SDK changelog](https://github.com/revt-eng/revturbine-sdk/blob/main/CHANGELOG.md) |
Coding agents can fetch individual pages from this index. [llms-small.txt](/revturbine-sdk/llms-small.txt) is an abridged documentation bundle, including the language guides; [llms-full.txt](/revturbine-sdk/llms-full.txt) contains the complete documentation.
## Next steps
- [Recommended API Path](/revturbine-sdk/getting-started/api-overview/) — `can()` vs `gate()` vs ``, and the vocabulary
- [Entitlements](/revturbine-sdk/guides/entitlements/) — feature gates, usage limits, credits, seats, tiers
- [Placements](/revturbine-sdk/guides/placements/) — targeting, caps, cooldowns, priority, and UI paths
- [Runtime Modes](/revturbine-sdk/guides/runtime-modes/) — local mode, hosted mode, and what is cached
- [Interactive Playground](/revturbine-sdk/playground/) — every scenario, live and editable
- [API reference](/revturbine-sdk/api/) — full TypeDoc reference for all exports
## Backend and edge examples
Use the [cloud integration tutorial](/revturbine-sdk/tutorials/vercel-edge-entitlements/) for runnable
Vercel, Firebase, AWS, Google Cloud and Azure adapters. It covers trusted user
context, Playbook freshness, local entitlement checks, and server enforcement.
# Component Gallery
> Interactive demos of all built-in placement slot components with live theme switching.
The Component Gallery renders each built-in placement slot directly in the browser — no Sandpack or iframe needed. Switch between story variants and theme presets to see how each component responds.
## Available Components
| Component | Type | Description |
|-----------|------|-------------|
| [ButtonSlot](/revturbine-sdk/components/button-slot/) | `button` | Standalone CTA button for nav bars and inline prompts |
| [BannerSlot](/revturbine-sdk/components/banner-slot/) | `banner` | Full-width banner for announcements and usage warnings |
| [ModalSlot](/revturbine-sdk/components/modal-slot/) | `modal` | Overlay modal for feature gates and trial expirations |
| [ToastSlot](/revturbine-sdk/components/toast-slot/) | `toast` | Ephemeral toast notification for usage alerts |
| [QuotaMeterSlot](/revturbine-sdk/components/quota-meter-slot/) | `in_page` | Usage meter with progress bar, counter, and gauge styles |
| [InlineEmbedSlot](/revturbine-sdk/components/inline-embed-slot/) | `in_page` | Inline card for feature gates and upgrade prompts |
Each page includes:
- **Variant selector** — switch between story variants (e.g. Primary, Secondary, Accent)
- **Theme selector** — Default, Dark, and Warm Brand presets
- **Live rendering** — components render with the SDK's real theme system
# BannerSlot
> Full-width banner placement — announcements, usage warnings, and promotional messages.
A full-width banner for announcements, usage warnings, and time-sensitive promotions. Supports top/bottom positioning and optional dismiss.
**Content fields:** `header`, `body`, `cta_label`, `position` (`top` | `bottom`), `dismissible`
## Usage
A live, editable example — edit the code (or switch the demo user) and the preview re-runs:
```tsx
import { Slot } from "@revturbine/sdk";
import { DemoApp } from "./DemoApp";
// Bob is on Professional — the engine serves him an annual-upgrade banner.
export default function App() {
return (
);
}
```
The `BannerSlot` renders automatically when the decision engine selects a `banner_placement` template. Banners render at the top or bottom of the page. When `dismissible` is `true`, users can close the banner. The SDK tracks dismissals to prevent re-showing.
**Related:** [Playground → Annual Banner](/revturbine-sdk/playground/#fixed-banner) · [API Reference](/revturbine-sdk/api/)
# ButtonSlot
> Standalone CTA button placement — renders a styled upgrade/action button.
A standalone CTA button suitable for persistent nav CTAs or inline upgrade prompts.
**Content fields:** `cta_label`, `style` (`primary` | `secondary` | `accent`)
## Usage
A live, editable example. The `` renders whatever `button` placement the decision engine picks for the current user — or nothing. Edit the code (or switch the demo user) and the preview re-runs:
```tsx
import { Slot } from "@revturbine/sdk";
import { DemoApp } from "./DemoApp";
// Carol is on Starter with an active trial, so the engine serves her an upgrade
// button. Switch to user="user_eve" (Enterprise) and the slot renders nothing —
// there's nothing to upsell.
export default function App() {
return (
);
}
```
The `ButtonSlot` renders automatically when the decision engine selects a `button` template for the slot. Style variants are controlled via the `style` content field in your placement configuration.
**Related:** [Playground → Upgrade Button](/revturbine-sdk/guides/placements/#fixed-button) · [API Reference](/revturbine-sdk/api/)
# InlineEmbedSlot
> Inline card placement — feature gates and upgrade prompts embedded in page flow.
An inline card for feature gates and contextual upgrade prompts. Embeds naturally in the page flow alongside content.
**Content fields:** `header`, `body`, `cta_label`, `image_url`, `message`
## Usage
A live, editable example. The user below is **not** entitled, so the gate renders the configured inline upgrade card in place of the children — edit the code (or switch to an entitled demo user) and the preview re-runs:
```tsx
import { Gate } from "@revturbine/sdk";
import { DemoApp } from "./DemoApp";
// Dan lacks the branding entitlement, so the gate renders the configured inline
// upgrade card. Switch to user="user_alice" (entitled) to see the children.
export default function App() {
return (
Branding controls — visible only when entitled
);
}
```
The `InlineEmbedSlot` renders automatically when the decision engine selects an `inline_gate_message` template. Inline embeds render in-flow where you place them. When the entitlement is denied, the gate content replaces the protected component.
**Related:** [Playground → Branding Gate](/revturbine-sdk/playground/#gate-inline) · [API Reference](/revturbine-sdk/api/)
# ModalSlot
> Overlay modal placement — feature gates, trial endings, and upgrade prompts.
An overlay modal for feature gates, trial expirations, and high-impact upgrade prompts. Supports optional (dismissible) and blocking modes.
**Content fields:** `header`, `body`, `cta_label`, `secondary_cta_label`, `image_url`, `style` (`blocking`)
## Usage
A live, editable example. The user below is **not** entitled, so the gate renders the configured upgrade modal in place of the children — edit the code (or switch to an entitled demo user) and the preview re-runs:
```tsx
import { Gate } from "@revturbine/sdk";
import { DemoApp } from "./DemoApp";
// Dan lacks the data_export entitlement, so the gate renders the configured
// upgrade modal. Switch to user="user_alice" (entitled) to see the children.
export default function App() {
return (
Export panel — visible only when entitled
);
}
```
The `ModalSlot` renders automatically when the decision engine selects a `modal_overlay` template. Blocking modals have no dismiss option — the user must take action. Optional modals include a secondary CTA or close button.
**Related:** [Playground → Data Export Gate](/revturbine-sdk/guides/entitlements/#gate-modal) · [API Reference](/revturbine-sdk/api/)
# QuotaMeterSlot
> Usage meter placement — progress bars, numeric counters, and circular gauges.
A usage meter that visualizes consumption against limits. Supports progress bar, numeric counter, and circular gauge display styles with automatic color thresholds.
**Content fields:** `header`, `usage_current`, `usage_limit`, `cta_label`, `display_style` (`progress_bar` | `numeric_counter` | `circular_gauge`), `show_at`
## Usage
A live, editable example — edit the code (or switch the demo user) and the preview re-runs:
```tsx
import { Slot } from "@revturbine/sdk";
import { DemoApp } from "./DemoApp";
// Alice has consumed 20 of her 30 core credits — the meter renders her usage.
export default function App() {
return (
);
}
```
The `QuotaMeterSlot` renders automatically when the decision engine selects a `usage_counter` template. The meter changes color automatically based on usage percentage — green below 70%, amber at 70-90%, red above 90%. The `show_at` threshold hides the meter until usage reaches that percentage.
**Related:** [Playground → Quota Meter](/revturbine-sdk/playground/#fixed-usage-counter) · [API Reference](/revturbine-sdk/api/)
# ToastSlot
> Ephemeral toast notification — non-blocking alerts for usage events and feature announcements.
A non-blocking toast notification for usage alerts, feature announcements, and success confirmations. Supports multiple positions and optional CTA.
**Content fields:** `message`, `cta_label`, `position` (`bottom-right` | `top-right` | `bottom-center`), `duration`
## Usage
A live, editable example — edit the code (or switch the demo user) and the preview re-runs:
```tsx
import { Slot } from "@revturbine/sdk";
import { DemoApp } from "./DemoApp";
// Bob is on Professional — the engine serves him a trial-welcome toast.
export default function App() {
return (
);
}
```
The `ToastSlot` renders automatically when the decision engine selects a `toast_message` template. Toasts auto-dismiss after `duration` milliseconds (set to `0` to disable auto-dismiss). Position is controlled by the `position` content field.
**Related:** [Playground → Trial Toast](/revturbine-sdk/playground/#msg-toast) · [API Reference](/revturbine-sdk/api/)
# Architecture
> SDK architecture, directory structure, and key concepts.
## Directory Structure
```text
sdk/
├── index.ts # Barrel export (entry point for @revturbine/sdk)
├── customer-side.ts # Core SDK class — identity, placements, entitlements, events
├── generated.ts # Re-exports the bundled schema types (generated)
├── react/ # React integration layer
│ ├── RevTurbineProvider # Context provider — wraps app for SDK access
│ ├── usePlacement # Hook — load decision + manage interactions
│ └── Placement # Render-prop alternative to usePlacement
├── placements/ # Placement rendering system
│ ├── types.ts # Slot type interfaces
│ ├── registry.ts # PlacementTypeRegistry — resolves outputs → slot components
│ ├── builtin.ts # Registers all 9 built-in slot types
│ ├── PlacementRenderer # Core renderer — resolves slot type, expands tokens, renders
│ ├── Slot # Drop-in component (decision + render in one step); exported as SurfaceSlotComponent too
│ ├── useSurfaceSlot # Hook variant of
│ └── slots/ # Built-in slot components
│ ├── BannerSlot # Full-width sticky banner (top/bottom)
│ ├── ModalSlot # Centered overlay dialog
│ ├── InlineEmbedSlot # Inline card in page flow
│ ├── ToastSlot # Ephemeral auto-dismiss notification
│ ├── ButtonSlot # Single CTA button
│ ├── FullPageSlot # Dedicated full-page (plans/upgrade)
│ ├── QuotaMeterSlot # Usage meter (bar/gauge/numeric)
│ ├── CliSlot # CLI-style monospace message
│ └── CreditBalanceSlot # Depleting credit balance display
```
## Where decisions run
In every runtime mode a decision — which plan a user is on, whether they can use a feature, which placement a slot shows — is a pure function of the user context and the Playbook, evaluated inside your app by the SDK. Nothing in your request path calls RevTurbine to decide.
The modes differ only in where the Playbook comes from and where telemetry goes:
1. **`revturbine_server`** (default) — the SDK fetches the launched Playbook from the RevTurbine control plane, keeps it cached, and re-checks for a new release about once a minute. Server-derived context (plan, trial, payment state) arrives through a client session; events go to RevTurbine ingest.
2. **`local_only`** — you bundle the Playbook with your app. No account, and no network on the decision path. Runtime state is persisted in `localStorage`.
3. **`custom_endpoints`** — the same traffic as `revturbine_server`, routed through endpoints you host.
See [Runtime Modes](/revturbine-sdk/guides/runtime-modes/) for setup and cache lifetimes.
## Placement Type Registry
The SDK uses a registry pattern to map decision engine outputs to React components:
1. Built-in slot types (banner, modal, toast, etc.) are registered at init.
2. Customers can register custom slot types via `useRegisterPlacementType`.
3. `PlacementRenderer` resolves outputs → slot types via template → surface type → fallback chain.
## Personalization Tokens
Content fields support `{{token_name}}` syntax. The SDK resolves tokens against a `PersonalizationContext` before rendering:
```ts
const personalization = {
user_name: 'Jane',
plan_name: 'Pro',
usage_current: 8500,
usage_limit: 10000,
};
```
## Decision Lifecycle
1. `registerPlacement()` → deterministic placement ID
2. `getPlacementDecision()` → visibility + content + reason codes
3. `trackTreatmentInteraction()` → impression/dismiss/CTA tracking
4. Suppression rules prevent re-showing dismissed placements
## Provider Pattern
The SDK supports an optional provider strategy for `getPlacement`, `checkEntitlement`, and `identify`:
- **`provider`**: primary provider object or factory.
- **`providerFallbacks`**: ordered list of fallback providers.
- **`providerFailureSlotBehavior`**: slot behavior after provider-chain failure — `'invisible'` (default) or `'placeholder'`.
Providers are an escape hatch for teams that supply their own facts (entitlement grants, user context) from more than one source; the default path needs none. When the primary provider fails, the SDK logs a warning and tries each configured fallback in order. If every configured provider for that method fails, slots render according to `providerFailureSlotBehavior` rather than guessing.
See [Runtime Modes → Provider Fallback Strategy](/revturbine-sdk/guides/runtime-modes/#provider-fallback-strategy) for configuration details.
## Self-Documenting Exports
All public SDK exports use **TSDoc** comments that are:
1. Shown inline in VS Code / IDE intellisense
2. Extracted by **TypeDoc** into the [API Reference](/revturbine-sdk/api/)
3. Picked up by **Storybook** for component documentation pages
When adding or modifying SDK exports, include TSDoc with a summary sentence, `@example` code blocks, and `@param` / `@returns` for non-obvious function signatures.
# Core Concepts
> The smallest mental model for RevTurbine — three primitives (entitlements, gates, placements), the Playbook that configures them, user context, and where decisions run.
RevTurbine has a small vocabulary. Learn three primitives and you can read the rest of the docs.
## The three primitives
- **Entitlements — *who can do what*.** An entitlement answers "is this user allowed to do X, and how much is left?" Feature access, usage limits, credits, and seats are all entitlements. You check them with [`can()`](/revturbine-sdk/getting-started/api-overview/) / `checkEntitlement`.
- **Gates — *where your app asks*.** A gate is the checkpoint in your code or UI where you consult an entitlement before letting something happen — a `` around a subtree, a `gate('export', …)` around an action, or a bare `can()` check. Same question, different shapes.
- **Placements — *what to show when it matters*.** A placement is authored presentation that the SDK selects locally: an upgrade prompt when a gate denies, a "running low" banner near a limit, a trial-ending toast. You render one with a [``](/revturbine-sdk/getting-started/api-overview/).
That's the whole loop: an **entitlement** defines the policy, a **gate** asks the question in your app, and a **placement** is what RevTurbine shows when the answer should change behavior.
## What configures them: the Playbook
All three primitives are driven by your **Playbook** — the portable config RevTurbine evaluates. It holds the plans, entitlement rules (feature / usage-limit / credits / seats), trials, segments (targeting), and placements. A PM edits the Playbook in the RevTurbine dashboard and republishes; your app code doesn't change.
## User context
RevTurbine decides for a *specific* user, so you tell it who's asking: an id,
the user's current plan handle, reported usage, and any custom traits. You set
this with `rt.identify(userId, { plan_handle, usage, …})` or the
`` `options.user`. Entitlement checks and
[segment targeting](/revturbine-sdk/guides/experiments/) both read from it. The dedicated
[`RevTurbineUserContext` guide](/revturbine-sdk/user-context/) covers every field, update
semantics, and server-derived context.
## Runtime modes
The same primitives work whether the SDK reads a Playbook bundled with your app (local mode, `local_only`), syncs it from RevTurbine (hosted mode, `revturbine_server`), or syncs it through your own endpoints (`custom_endpoints`). See [Runtime Modes](/revturbine-sdk/guides/runtime-modes/).
## Where decisions run
**Tip — Local decisions, explicit delivery policy:**
The RevTurbine SDK runs inside your app, and on your backend where you verify. It evaluates entitlements and placements locally, as a pure function of your Playbook and the current user context. Hosted loading and refresh can fail; choose an explicit freshness and failure policy for the snapshot your app evaluates.
We push additional inputs to your local engine: aggregated frequency caps, prioritization scores as available. These are additive; nothing breaks or blocks if not present.
A check that can't justify a grant returns no access rather than guessing, so a misconfiguration never hands out a paid feature. For the money-safe re-check, see [Client vs Server Enforcement](/revturbine-sdk/concepts/enforcement/).
## Glossary
| Term | Meaning |
|---|---|
| **Entitlement** | What a user is allowed to do / how much is left (feature, usage limit, credits, seats) |
| **Gate** | A checkpoint where your app consults an entitlement (``, `gate()`, `can()`) |
| **Placement** | The UI the decision engine shows at the right moment (rendered by ``) |
| **Playbook** | The portable config RevTurbine evaluates (plans, entitlements, segments, placements) |
| **Segment** | A named rule over user traits used for targeting and experiments |
| **Slot** | The component that renders a placement (``) |
| **User** | An application-identified person; `user.id` is distinct from their account |
| **Account** | An organization or billing scope identified by `account_id`; membership is enforced by your app |
| **Trigger** | A supported event or condition that makes a placement eligible for consideration |
"Moment" can describe when your app asks for a decision, but SDK 0.12.4 has no
public `moment()` function. Use the documented trigger, `Gate`, `Slot`, and
headless APIs. Likewise, proposed `InlineSlot`/`OverlaySlot` names are not current
exports. A slot is a place to render; an overlay is a presentation choice of the
selected component/template, not a separate SDK decision service.
For the component/config name aliases (`SurfaceSlotComponent`, `Playbook`, …), see the [Recommended API Path](/revturbine-sdk/getting-started/api-overview/#the-vocabulary-slot--gate--playbook).
## Related
- [Recommended API Path](/revturbine-sdk/getting-started/api-overview/) — which call to reach for
- [Client vs Server Enforcement](/revturbine-sdk/concepts/enforcement/) — policy vs enforcement
- [Entitlements](/revturbine-sdk/guides/entitlements/) · [Placements](/revturbine-sdk/guides/placements/) — the primitives in depth
# Client vs Server Enforcement
> How RevTurbine splits responsibility — RevTurbine owns monetization policy, your app owns enforcement, a check that can't justify a grant denies, and billing-critical actions are re-checked on your server. Plus the difference between , gate(), and can().
Every team evaluating RevTurbine asks the same question: *"If entitlements are checked in the browser, what stops a user from bypassing them?"*
The browser can be tampered with, so a client check is a UX hint, not the authority. Your backend re-runs the check at the moment it matters, immediately before a billable action. The re-check is the same SDK evaluating the same Playbook, on your server. It calls our servers only for additive data. Verify wherever being wrong costs you money; pure display gating doesn't need it.
The rest of this page is the division of responsibility, and which tool to use where.
## The model, stated once
- **RevTurbine is authoritative for monetization *policy*.** Which plans exist, what each plan entitles, when a placement shows — that lives in your [Playbook](/revturbine-sdk/getting-started/api-overview/#the-vocabulary-slot--gate--playbook) and the control plane, and a PM changes it with no code deploy.
- **Your app owns *enforcement*.** RevTurbine tells you the policy answer; your app is what actually allows or blocks the action.
- **No grant, no access.** An entitlement check that can't justify a grant denies rather than guessing, so a misconfiguration never *leaks* a paid feature ([the reason codes](/revturbine-sdk/guides/entitlements/#fail-closed-semantics) say why).
- **Money-safe decisions belong on your server.** A browser check can be tampered with. For anything that costs money or invites abuse, re-run the check server-side before you grant access.
## Three tools, and where each runs
The SDK gives you three ways to act on an entitlement. They sound alike; they are not the same.
| Tool | Kind | Runs | Use it for |
|---|---|---|---|
| `` | React component | Client (UI) | Show/hide a subtree and render an upsell when denied |
| `rt.gate(action, fn)` | Imperative verb | Client | Run `fn` only if entitled; get the entitlement back to open a paywall |
| `rt.can(handle)` · `useCan` · `checkEntitlement` | Check | Client **or** server | Ask "is this allowed?" and get the full result — the one you re-run server-side |
`` and `gate()` are convenience wrappers around a `can()`/`checkEntitlement()` call. All three run in the browser and deny whenever they can't affirm a grant. The check itself — `checkEntitlement` / `can` — is also what you run on your server (via the server SDK or your own logic) when the decision has to be trusted.
**Caution — Custom resolvers own their network behavior:**
The built-in evaluator decides from the loaded Playbook in memory, so an entitlement check makes no per-check network call. If you inject `localRuntime.resolvers`, your resolver can introduce network I/O; its caching, timeout, and failure behavior are your responsibility. The SDK still requires an affirmative result before granting access.
## When do I need the server?
| Action | Client check enough? |
|---|---|
| Show or hide a button, badge, or menu item | ✅ Yes |
| Render an upgrade prompt or paywall | ✅ Yes |
| Branch UI copy or messaging | ✅ Yes |
| Start a paid export, consume credits, provision a seat | ❌ Re-check on your server |
| Anything a user could profit from by faking | ❌ Re-check on your server |
The rule: **the client check drives the UI; your server makes the money-safe decision.** They use the same entitlement — you're just not trusting the browser for the part that matters.
## In practice
Use the client check for a fast, friendly UX:
```tsx
import { Gate } from '@revturbine/sdk';
// Client: instant show/hide + upsell when denied; no grant means no access.
```
Then verify again on your server before doing the paid work — with the same SDK, evaluating the same Playbook in-process:
```ts
import { initRevTurbine, RuntimeMode } from '@revturbine/sdk/headless';
import playbook from './revturbine.playbook.json';
// Server: the trusted decision. The instance is built from a Playbook this
// process already loaded, so the check makes no call to RevTurbine.
export async function POST(request: Request): Promise {
const user = await getSignedInUser(request); // your own auth
const rt = await initRevTurbine({
runtimeMode: RuntimeMode.LocalOnly,
localRuntime: { playbook },
user: { id: user.id, plan_handle: user.planHandle },
});
const result = await rt.can('data_export');
if (!result.allowed) return Response.json({ error: 'upgrade_required' }, { status: 402 });
return runExport(request);
}
```
Two conventions worth keeping: answer a denied entitlement with **402** (upgrade to proceed) and reserve 403 for permission denials, so clients can tell the two apart; and build one instance per request from a Playbook you loaded once per process, because the SDK holds a single user context per instance. See [Server-Side Integration](/revturbine-sdk/getting-started/server-side/#headless-server-re-check) for why a server does not use hosted `Server` mode.
The client `` keeps the UI honest and fast; the server check keeps the revenue safe. Both evaluate the same Playbook locally, so neither depends on our servers at decision time.
## Related
- [Recommended API Path](/revturbine-sdk/getting-started/api-overview/) — when to reach for `can()` vs `gate()` vs ``
- [Server-Side Integration](/revturbine-sdk/getting-started/server-side/) — running entitlement checks on your backend
- [Entitlements](/revturbine-sdk/guides/entitlements/) — the entitlement model behind every check
- [Error Handling](/revturbine-sdk/guides/error-handling/) — placement degradation and what a check returns when it can't justify a grant
# Provider Architecture
> How the SDK's provider pattern lets you supply custom placement, entitlement, and identity logic.
By default the SDK evaluates placements and entitlements inside your app, from the Playbook — no provider is involved, and most apps never configure one. A **provider** is an escape hatch for teams that already have a placement or entitlement source of their own and want the SDK's rendering, tracking, caps, and cooldowns on top of it. A provider is a plain object that implements one or more domain methods — placement resolution, entitlement checks, placement type persistence, and user identification.
## The Provider Interface
```ts
interface RevTurbineSdkProvider {
getPlacement?: (config: RevTurbinePlacementRequestConfig) => Promise;
checkEntitlement?: (handle: string, context?: RevTurbineEntitlementContext) => Promise;
persistPlacementTypes?: (types: RevTurbinePlacementTypeEntity[]) => Promise;
identify?: (userId: string, contextOrTraits?: UserContextInput | SdkTraits) => void;
}
```
Every method is optional. Implement only the domains you need to customize — the SDK falls back to its built-in local runtime for anything you don't provide.
## Domain Methods
### `getPlacement`
Resolves a placement for a given slot and surface type. Called by `` (and its `FixedSurfaceSlot` / `MessageSurfaceSlot` variants), ``, and the headless API.
```ts
const analyticsProvider: RevTurbineSdkProvider = {
async getPlacement(config) {
// config.slotId — the slot requesting a placement
// config.componentType — the component type (button, modal, banner, etc.)
// config.entitlementHandle — for access gate slots
// Example: a placement source you already run (escape hatch — the
// default path evaluates the Playbook locally and needs no provider)
const res = await fetch(`/api/placements/${config.slotId}`);
if (!res.ok) return null;
return res.json();
},
};
```
**Input — `RevTurbinePlacementRequestConfig`:**
| Field | Type | Description |
|---|---|---|
| `slotId` | `string?` | Slot identifier |
| `componentType` | `RevTurbineComponentType?` | Component type (`button`, `modal`, `banner`, etc.) |
| `entitlementHandle` | `string?` | Entitlement to check (access gates) |
| `planHandle` | `string?` | Plan-specific placements |
| `placementHandle` | `string?` | Chaining from a prior CTA path |
**Return:** `PlacementOutput | null` — the resolved placement, or `null` if no placement matches.
### `checkEntitlement`
Checks whether the current user has access to a feature. Called by `` and `sdk.checkEntitlement()`.
```ts
const entitlementProvider: RevTurbineSdkProvider = {
async checkEntitlement(handle, context) {
// handle — e.g. "data_export", "brand_kit"
// context — optional entitlement context
const res = await fetch(`/api/entitlements/${handle}`);
const data = await res.json();
return {
status: data.allowed ? 'allowed' : 'denied',
allowed: data.allowed,
};
},
};
```
**Return — `EntitlementResult`:**
| Field | Type | Description |
|---|---|---|
| `status` | `string` | `'allowed'`, `'limited'`, or `'denied'` |
| `allowed` | `boolean` | Whether access is granted |
### `persistPlacementTypes`
Stores placement type metadata. Called during SDK initialization to register built-in and custom slot types.
```ts
const storageProvider: RevTurbineSdkProvider = {
async persistPlacementTypes(types) {
// types — array of { id, label, description, componentType, priority }
await fetch('/api/placement-types', {
method: 'POST',
body: JSON.stringify(types),
});
},
};
```
### `identify`
Called when a user is identified or their context changes. Use this for analytics, session tracking, or syncing identity to your backend.
```ts
const identityProvider: RevTurbineSdkProvider = {
identify(userId, contextOrTraits) {
analytics.identify(userId, contextOrTraits);
},
};
```
## Using a Provider
Pass your provider via the `provider` option:
```tsx
import { RevTurbineProvider, RuntimeMode } from '@revturbine/sdk';
import type { RevTurbineSdkProvider } from '@revturbine/sdk';
import playbook from './revturbine.playbook.json';
import { useMemo } from 'react';
// Typing the object as RevTurbineSdkProvider keeps `status` on the SDK's
// literal union — without it TypeScript widens it to `string` and the
// provider no longer satisfies the interface.
const myProvider: RevTurbineSdkProvider = {
async getPlacement(config) {
const res = await fetch(`/api/placements/${config.slotId}`);
if (!res.ok) return null;
return res.json();
},
async checkEntitlement(handle) {
const res = await fetch(`/api/entitlements/${handle}`);
const data = await res.json();
return { status: data.allowed ? 'allowed' : 'denied', allowed: data.allowed };
},
};
function App() {
const options = useMemo(() => ({
runtimeMode: RuntimeMode.LocalOnly,
localRuntime: { playbook },
provider: myProvider,
}), []);
return (
{/* slots will call myProvider.getPlacement() */}
);
}
```
## Provider Factory
If your provider needs access to the SDK's init options, use a factory function:
```ts
const myProviderFactory = (options) => ({
async getPlacement(config) {
// options.localRuntime, options.user, etc. are available here
return null;
},
});
const options = {
runtimeMode: RuntimeMode.LocalOnly,
localRuntime: { playbook },
provider: myProviderFactory,
};
```
## Fallback Chain
The SDK supports a chain of providers. If the primary provider returns `null` or throws, the next one is tried:
```tsx
const options = useMemo(() => ({
runtimeMode: RuntimeMode.LocalOnly,
localRuntime: { playbook },
provider: apiProvider,
providerFallbacks: [cachedProvider, staticFallback],
}), []);
```
If all providers fail:
| Behavior | Result |
|---|---|
| `'invisible'` (default) | Slots render nothing |
| `'placeholder'` | Slots show fallback content |
An entitlement check that can't justify a grant denies — here with `{ allowed: false, reason: 'sdk_disabled_provider_failure' }`. Slot rendering on provider failure is governed separately, by the table above.
## Analytics Provider
The SDK can forward all impressions, interactions, and lifecycle events to your analytics platform (Segment, Heap, Amplitude, PostHog, Mixpanel, or any custom sink). Use `createAnalyticsProvider()` to create a domain provider that receives every SDK event.
### Basic Setup
```tsx
import { RevTurbineProvider, createAnalyticsProvider, RuntimeMode } from '@revturbine/sdk';
import playbook from './revturbine.playbook.json';
import { useMemo } from 'react';
function App() {
const options = useMemo(() => {
const analytics = createAnalyticsProvider({
handler: (eventName, properties) => {
// Push to your analytics platform
// `segment` here is your own analytics SDK (Segment, Amplitude, …).
segment.track(eventName, properties);
},
});
return {
runtimeMode: RuntimeMode.LocalOnly,
localRuntime: { playbook },
domainProviders: [analytics],
};
}, []);
return (
{/* All impressions and interactions now flow to Segment */}
);
}
```
### Events Captured
The analytics provider receives every SDK event, including:
| Event | When |
|---|---|
| `placement_interaction` | A placement is shown, clicked, or dismissed |
| `placement_dismissed` | User closes a placement |
| `placement_converted` | User completes a CTA |
| `placement_snoozed` | User clicks "remind me later" |
| `page_view` | Page navigation |
| Trigger events | `trial_expiring`, `usage_limit_reached`, etc. |
Each event includes `user_id`, `anonymous_id`, `session_id`, `placement_id`, `interaction_type`, `url`, `page_title`, and the full event properties.
### Filtering Events
Only forward the events you care about:
```ts
const analytics = createAnalyticsProvider({
handler: (eventName, properties) => {
heap.track(eventName, properties);
},
filter: ['placement_interaction', 'placement_dismissed', 'placement_converted'],
});
```
### Transforming Events
Rename events, enrich properties, or drop events with a `transform` function. Return `null` to suppress an event.
```ts
const analytics = createAnalyticsProvider({
handler: (name, props) => posthog.capture(name, props),
transform: (name, props) => ({
eventName: `revturbine.${name}`,
properties: { ...props, source: 'revturbine-sdk' },
}),
});
```
### Platform Examples
**Segment:**
```ts
const analytics = createAnalyticsProvider({
handler: (eventName, properties) => {
segment.track(eventName, properties);
},
});
```
**Heap:**
```ts
const analytics = createAnalyticsProvider({
handler: (eventName, properties) => {
heap.track(eventName, properties);
},
filter: ['placement_interaction', 'placement_dismissed', 'placement_converted'],
});
```
**Amplitude:**
```ts
const analytics = createAnalyticsProvider({
handler: (eventName, properties) => {
amplitude.track(eventName, properties);
},
});
```
**PostHog (with prefix):**
```ts
const analytics = createAnalyticsProvider({
handler: (name, props) => posthog.capture(name, props),
transform: (name, props) => ({
eventName: `revturbine.${name}`,
properties: props,
}),
});
```
## Next Steps
- [Runtime Modes](/revturbine-sdk/guides/runtime-modes/) — choosing the right runtime mode
- [Component Gallery](/revturbine-sdk/components/) — interactive demos of every built-in slot
- [API Reference](/revturbine-sdk/api/) — full TypeDoc reference for all exports
# Runtime and Playbook
> The two halves of every RevTurbine decision — the Playbook you author and the Runtime that holds who is asking — how they combine locally, and which pieces are shipped today.
For the complete body inventory and reference relationships, see the
[Playbook field map](/revturbine-sdk/reference/playbook-map/).
Every RevTurbine decision, whether it is "may this user export?" or "which
banner should this user see?", is one calculation with two inputs:
- the **Playbook**: what you decided about plans, entitlements, targeting and
messages. It is the same for every user, and you author it.
- the **Runtime**: who is asking right now, and what they have used. It is
different for every user and changes while they use your app.
The SDK combines the two **locally**, in your app or on your server. Nothing in
that calculation waits on RevTurbine.
```text
Playbook (authored, versioned) Runtime (per user, per moment)
plans · entitlements · segments UserContext · usage · app answers
placements · payloads (AccountContext: planned)
\ /
\ /
+---- SDK: local evaluation ----+
|
entitlement result · winning placement
|
your UI renders it · your server enforces it
```
## The Playbook
The Playbook is the portable artifact RevTurbine evaluates: one JSON file,
`revturbine.playbook.json`, that you edit in the dashboard, with the CLI or with
your coding agent. Every launch is an immutable, versioned Release. In local
mode you bundle the file; in hosted mode the SDK fetches the launched Release.
Either way the SDK evaluates the same artifact the same way.
| Primitive | In plain English | Why it exists | Example |
|---|---|---|---|
| **Plan** | A tier a customer can be on. A plan can have more than one price, each called a plan variation (monthly, annual or regional); variations share the plan's tier, so switching between them is never an upgrade. | So your app asks "what is this user entitled to?" instead of "are they on Pro?" | `starter`, `pro` |
| **Entitlement** | One thing a user may be allowed to do, and how much: a feature, a usage limit, credits or seats. | One vocabulary for gating, metering and upgrade prompts. | `batch_export`, `api_calls` (limit 1,000) |
| **Entitlement rule** | Says which plans, segments or seat types get an entitlement, and how much. | Changing who gets what is a Playbook edit, not a deploy. | "Pro gets `api_calls` up to 1,000 a month" |
| **Segment** | A named group of users, defined by rules over their context. | Targeting and experiments: show this to trial users only. | `trial_users` |
| **Placement** | A message the engine may show at the right moment: a banner, modal, toast or button. Its **payload** is the copy, offer and call to action. | Upgrade prompts are decided by caps, cooldowns and priority, not by your code. | A "you have 5 exports left" banner |
Related terms you will meet:
- An **add-on** is something a customer buys on top of a plan.
- A **gate** is where your app asks an entitlement question (``, `gate()`,
`can()`); a **slot** is where your app can show a placement (``). Both
are covered in [Core Concepts](/revturbine-sdk/concepts/core-concepts/).
## The Runtime
The Runtime is everything the SDK knows about the moment of the decision.
| Piece | In plain English | Who supplies it |
|---|---|---|
| **UserContext** | The user: id, plan, usage balances, trial state, custom traits. | Your app at init and as things change, plus plan, trial and payment state from Stripe through a [client session](/revturbine-sdk/guides/client-sessions/) in hosted mode. See [`RevTurbineUserContext`](/revturbine-sdk/user-context/). |
| **Usage** | How much the user has consumed. Always an absolute balance, never a delta. | Your app (`update({ usage })`). |
| **App answers** | When your own system already decides an entitlement, you pass its answer in the `entitlements` field. This is Entitlement Mirroring. | Your app. |
| **Entitlement grant** | What a user ends up with for one entitlement, and where it came from: a rule, an answer your app passed, or an override. | Derived by the engine. |
Your app's own answer and the Playbook's can disagree. By default your app's
answer wins, and the Playbook's rule is then only used to tell you which plans
include the entitlement. That default is the Entitlement Mirroring precedence
setting; set it the same way in the browser and on your server so they agree.
It is separate from how overrides combine, below.
**Caution — Planned, not shipped: AccountContext and overrides:**
Two Runtime pieces are approved but not built yet, so do not write code against
them:
- **AccountContext**: account-level input that the SDK would merge with the
UserContext. When both carry the same key, the UserContext value would win,
with a warning if the values differ. The merge rules beyond that are still
being designed.
- **Entitlement overrides**: a plan, add-on or entitlement granted to one
account or one user, optionally until an end date. Overrides only add access.
Until they ship, supply account-level facts in the UserContext yourself. This
page will describe them as shipped when they are.
## How initialization drives a decision
1. **Initialize.** You give the SDK the Playbook (a bundled file, or a public
key so it can fetch the launched Release) and the user. See
[Initialize the SDK](/revturbine-sdk/getting-started/initialization/).
2. **Load.** In hosted mode the SDK fetches the Playbook once and re-checks for
a new Release about once a minute, keeping the last good copy if a refresh
fails. Until the first Playbook arrives, a check **denies** with
`config_unavailable`. It never grants on a guess.
3. **Change the context.** `identify()`, `update()` and a client-session refresh
change the Runtime. The SDK re-evaluates against the Playbook it already
holds. No Playbook is fetched for that.
4. **Decide.** `can()` returns an entitlement result; a `` returns the one
placement that won, after eligibility, caps, cooldowns and priority.
Local evaluation is what keeps this fast and independent of RevTurbine's
availability. Two boundaries matter:
- **Browser versus server.** The browser's decision drives the UI. It is a
convenience, because a user controls their own browser. Your server makes the
decision that protects money and data, by building its own instance from the
Playbook and **its own** record of the user. See
[Client vs Server Enforcement](/revturbine-sdk/concepts/enforcement/).
- **Local versus hosted.** Hosted changes where the Playbook and the
server-derived user state come from, not where the decision runs. See
[Runtime Modes](/revturbine-sdk/guides/runtime-modes/).
## A worked example
You sell a "batch export" feature, included in Pro, with a limit of 10 exports
a month on Starter.
- **Playbook:** the `batch_export` entitlement; one rule that gives Pro unlimited
and Starter a limit of 10; a placement that shows an upgrade banner when a
Starter user has used 8.
- **Runtime:** Maya is on Starter and has used 8 (UserContext and usage).
- **Decision:** `can('batch_export')` returns allowed, with 2 remaining. The
banner placement is eligible, so the `` shows it.
- **Change:** Maya upgrades. Stripe tells RevTurbine; the client session brings
the new plan to the SDK; the Runtime changes, and the same Playbook now says
unlimited. Nothing was redeployed.
## Where to go next
- [Core Concepts](/revturbine-sdk/concepts/core-concepts/): entitlements, gates and placements.
- [Entitlements](/revturbine-sdk/guides/entitlements/) and [Placements](/revturbine-sdk/guides/placements/): each primitive in depth.
- [What Owns What](/revturbine-sdk/concepts/source-of-truth/): which system is authoritative for each fact.
- [Initialize the SDK](/revturbine-sdk/getting-started/initialization/): the code.
# What Owns What
> The source-of-truth map across RevTurbine, your app, and Stripe — who owns plan definitions, prices, subscription status, feature-access policy, usage, checkout, and billing-critical enforcement.
RevTurbine sits between your app and Stripe, and the single biggest question a technical evaluator has is *"where does each piece of truth actually live?"* Here's the map.
## The map
| Concern | RevTurbine | Your app | Stripe |
|---|---|---|---|
| **Plan catalog** — which plans/tiers exist | ✅ **Authoritative** (the Playbook) | — | mirrors as products |
| **Prices** | mirror (UI helper) | — | ✅ **Authoritative** |
| **Subscription status** — active / plan / canceled | holds the server-derived plan and payment state (from Stripe webhooks) and delivers it to the SDK through the client session | may pass `plan_handle` as an optimistic override at the upgrade moment; supplies it always in local mode | ✅ **Authoritative** |
| **Feature-access policy** — what each plan entitles | ✅ **Authoritative** (entitlement rules) | — | — |
| **Usage counts** | evaluates them against limits | ✅ **reports** usage (`rt.update`) | — |
| **Checkout & payment** | starts it (a CTA opens checkout) | hosts the flow | ✅ **Processes** payment |
| **When to show a placement / upsell** | ✅ **Decides** (decision engine) | renders the `` | — |
| **Billing-critical enforcement** — actually granting a paid action | advises (policy) | ✅ **Enforces** (server-side) | — |
## Reading the map
- **RevTurbine owns *policy*, not money.** The plan catalog, what each plan entitles, and when a placement shows are all RevTurbine's — a PM edits them in the dashboard and releases, with no code deploy. RevTurbine never charges anyone.
- **Stripe owns *money and subscription state*.** Prices, who is subscribed, and payment processing are Stripe's. RevTurbine mirrors prices into the Playbook as a **UI helper** for rendering — but Stripe is the price authority. With Stripe connected, subscription webhooks update the customer's plan and payment health on RevTurbine's side and the SDK receives them through the client session — see [Billing & Stripe](/revturbine-sdk/guides/billing/). Your app can pass `plan_handle` in user context as an optimistic override (at the upgrade moment, before the webhook lands); in local mode it supplies the plan always.
- **Your app owns *enforcement and usage*.** You report usage, you host checkout, and — critically — **you enforce billing-critical actions on your server.** See [Client vs Server Enforcement](/revturbine-sdk/concepts/enforcement/).
**Note — Why prices live in Stripe, not the Playbook:**
The Playbook carries `price_amount` / `stripe_price_id` so the SDK can render a pricing card without a round-trip. That copy is denormalized for display only — Stripe remains the authority, and the mirror is kept fresh from Stripe. Never treat the Playbook's price as the number you charge.
## What this buys you
Because your app only ever knows **slots and entitlements** — never plan names or prices hard-coded in code — cascading monetization changes stay out of your codebase. Launch a new plan, run a discount, restructure tiers: the catalog and policy change in RevTurbine, the numbers change in Stripe, and your app keeps rendering the same slots and checking the same entitlements. The first monetization strategy is never the last — and this split is what keeps your code from having to change every time it moves.
## Related
- [Client vs Server Enforcement](/revturbine-sdk/concepts/enforcement/) — the policy-vs-enforcement split in detail
- [Placements](/revturbine-sdk/guides/placements/) — slots, CTA actions, and the empty-state rule
- [Entitlements](/revturbine-sdk/guides/entitlements/) — the entitlement rules RevTurbine owns
# Recommended API Path
> The small, blessed surface to reach for first — can() and gate() to check and protect, and to render, and the Slot / Gate / Playbook vocabulary. Everything else is Advanced.
The SDK has a large surface, but most integrations only need a handful of calls. Reach for these first; everything else is **Advanced**.
## Two questions, two verbs
Almost every monetization decision is one of two questions:
- **"Can this user do X?"** → *check* it with `can()` (or the `useEntitlement` hook).
- **"Only let them do X if they're entitled — and show an upsell if not."** → *protect* it with `gate()` (imperative) or `` (declarative React).
And when you just want to render whatever the control plane decides for a spot in your UI, drop in a **``**.
## What to reach for
| You want to… | Use | Kind |
|---|---|---|
| Check access imperatively | `rt.can(handle)` → `EntitlementResult` | verb |
| Check access in React | `useEntitlement({ handle })` / `useCan(handle)` | hook |
| Run an action only if entitled | `rt.gate(action, fn)` → `{ ran, entitlement }` | verb |
| Gate a React subtree + show an upsell | `…` | component |
| Render a control-plane placement | `` | component |
| Track an event | `rt.track(name, data)` | verb |
| Report usage | `rt.update({ usage })` | verb |
## `can()` vs `gate()` vs ``
They sound alike but do different jobs:
- **`can()`** *asks* — it returns the answer and does nothing else.
```ts
const access = await rt.can('data_export');
if (!access.allowed) showUpgrade();
```
It returns the rich `EntitlementResult` (`allowed`, `status`, `reason`, and — for limit-bearing entitlements — `limit` / `used` / `remaining`). `can()` is asynchronous by design, so custom resolvers can be injected.
- **`gate(action, fn)`** *asks and acts* — it runs `fn` only when the user is entitled, and hands back the entitlement so you can open a paywall otherwise.
```ts
import { useRevTurbine } from '@revturbine/sdk';
const { sdk: rt } = useRevTurbine(); // the browser SDK; `gate()` lives here, not on a headless session
const gated = await rt.gate('export_pdf', () => exportPdf());
if (!gated.ran) openPaywall(gated.entitlement);
```
- **``** is the declarative React version — it renders its children when access is granted and the resolved upsell placement when it isn't.
```tsx
```
**Caution — The client check is convenient, not authoritative:**
`can()`, `gate()`, and `` run in the browser, where a determined user can tamper with the result. For **billing-critical or abuse-sensitive** actions, treat the client check as a UX convenience and **re-run the same entitlement check on your server** (or via a server SDK) before you actually grant access. See [Client vs Server Enforcement](/revturbine-sdk/concepts/enforcement/) for the authoritative placement, entitlement, and outage behavior; your server owns the money-safe decision.
## The vocabulary: Slot · Gate · Playbook
Three names cover the customer-facing surface:
- **``** — the general placement slot. It renders whatever placement the control plane resolves for a spot (banner, modal, toast, meter, …).
```tsx
```
- **``** — the entitlement gate (above): render children when entitled, an upsell when not.
- **`Playbook`** — the portable monetization config the SDK evaluates against: plans, entitlements, segments, and placements. In local mode you load it (typically `revturbine.playbook.json`) at init; in live mode the SDK fetches it.
Each has an older name that stays exported — prefer the canonical one:
| Canonical | Also exported as | What it is |
|---|---|---|
| `` | `SurfaceSlotComponent`, `RTSlot` | general placement slot component |
| `` | `AccessGateSurfaceSlot` | entitlement-gate component |
| `Playbook` | `ExportedConfig`, `RevTurbineConfig` | the portable monetization config; both aliases are deprecated |
## Everything else is Advanced
Provider architecture, custom slot types, headless controllers (`usePlacement` and the imperative controllers), theming internals, and the generated object detail are all still available — they're just not the first thing you reach for. See the **Guides** and the **API Reference** when you need them.
## Related
- [Quickstart](/revturbine-sdk/) — gate one action end to end
- [Entitlements](/revturbine-sdk/guides/entitlements/) — the full entitlement model behind `can()` / `gate()`
- [Placements](/revturbine-sdk/guides/placements/) — what `` renders and how decisions resolve
# Initialize the SDK
> One canonical initialization example per runtime — React and headless in the browser, and your Node server — in local and hosted mode, with the option names the SDK actually reads.
Every other page links here for initialization, so these are the only
copy-paste starting points. Each one compiles against the published SDK in CI.
Which entry point you use depends on **where the code runs**:
| Where it runs | Entry point | Local mode | Hosted mode |
|---|---|---|---|
| Browser, React | `RevTurbineProvider` from `@revturbine/sdk` | `localRuntime: { playbook }` | `publicKey` + `clientSession` |
| Browser, no React | `initRevTurbine` from `@revturbine/sdk/headless` | `localRuntime: { playbook }` | `publicKey` + `clientSession` |
| Your Node server | `initRevTurbine` from `@revturbine/sdk/headless`, plus `RevTurbineServer` | the Playbook snapshot you loaded | server key for Playbook delivery and events; `RevTurbineServer` for client sessions |
The option names are fixed, and they are not interchangeable:
| Option | Where | What it is |
|---|---|---|
| `publicKey` | the browser | Your ingest key (`rtk_…`, type `public`). Safe to ship in a bundle. |
| `apiKey` | headless server initialization and `RevTurbineServer` | Your server key (`rtk_…`, type `server`). Secret. Never in a browser bundle, never a `NEXT_PUBLIC_` variable. |
| `localRuntime.playbook` | anywhere | The Playbook you load yourself (`revturbine.playbook.json`). |
| `clientSession` | the browser | A function that returns a short-lived session token from **your** server. See [Client sessions](/revturbine-sdk/guides/client-sessions/). |
In a browser, initialize with `publicKey`. On a trusted backend, headless
`RuntimeMode.Server` accepts `apiKey` for Playbook loading and event ingest.
The key never makes a hosted entitlement decision: evaluation runs locally
with the user's context and the loaded Playbook.
## Before you go hosted
1. **Launch a Playbook.** Hosted mode serves your launched Playbook:
`revturbine launch ./revturbine.playbook.json`.
2. **Mint an ingest key and list your origins.** In the dashboard, open
**Settings → API Token Management → Ingest Keys** and mint a key. Add every
origin that will load the SDK to its **Allowed origins**, including your
local dev origin (for example `http://localhost:3000`). The list is
fail-closed: a request from any other origin gets `403 origin_not_allowed`,
and every check then denies with `config_unavailable`.
3. **Mint a server key.** Under **Settings → API Token Management → Server
Token**. It is shown once. Put it in your server's environment as
`REVTURBINE_API_KEY`, next to `REVTURBINE_TENANT_ID`. The tenant id must be
the tenant the key belongs to.
4. **Add the client-session route** on your server, so the SDK can pick up
plan, trial and payment state from Stripe. See [Client sessions](/revturbine-sdk/guides/client-sessions/).
Local mode needs none of this. It is production-capable on its own, and it is
the right first step: see [Runtime Modes](/revturbine-sdk/guides/runtime-modes/).
## Browser, React
**Local**: the Playbook ships with your app.
```tsx title="src/App.tsx"
import { RevTurbineProvider } from '@revturbine/sdk';
import { useMemo } from 'react';
import playbook from './revturbine.playbook.json';
export default function App() {
const options = useMemo(() => ({
localRuntime: { playbook },
user: { id: 'user_123', plan_handle: 'starter' },
}), []);
return (
);
}
```
**Hosted**: the SDK fetches your launched Playbook with the public key, and
the `clientSession` callback brings in the plan Stripe says the user is on.
```tsx title="src/revturbine-provider.tsx"
import { RevTurbineProvider } from '@revturbine/sdk';
import { useMemo, type ReactNode } from 'react';
import { fetchClientSession } from './revturbine-client-session';
export function RevTurbine({ userId, children }: { userId: string; children: ReactNode }) {
const options = useMemo(
() => ({
publicKey: process.env.NEXT_PUBLIC_REVTURBINE_PUBLIC_KEY!,
user: { id: userId },
clientSession: fetchClientSession,
}),
[userId],
);
return {children};
}
```
`user` carries only the id: the plan arrives from Stripe through the client
session. `tenantId` is optional, because the key names its tenant. `endpoint`
is optional and defaults to RevTurbine's hosted control plane. `fetchClientSession`
is the browser half of the [client-session flow](/revturbine-sdk/guides/client-sessions/#the-browser-callback).
The provider needs a stable `options` object, so build it with `useMemo` as
above; a new object on every render re-initializes the SDK.
## Browser, headless
For Vue, Svelte, Angular or plain TypeScript. Same options, no React.
```ts title="src/revturbine.ts"
import { initRevTurbine } from '@revturbine/sdk/headless';
import { fetchClientSession } from './revturbine-client-session';
export async function startRevTurbine(userId: string) {
return initRevTurbine({
publicKey: process.env.NEXT_PUBLIC_REVTURBINE_PUBLIC_KEY!,
user: { id: userId },
clientSession: fetchClientSession,
});
}
```
`initRevTurbine` is asynchronous and resolves to a session. Use it directly
(`await session.can('export')`), or reach the core SDK as `session.sdk`. For
local mode, swap the two hosted options for `localRuntime: { playbook }`.
## Your Node server
A server has two jobs, and they use two different objects.
**Decide** whether a user may do something, before you do the paid work. The
headless SDK evaluates your Playbook in-process, for one user per instance.
Load the Playbook once per process and build a request-scoped instance:
```ts title="src/server/revturbine.ts"
import {
initRevTurbine,
RuntimeMode,
type RevTurbineUserContext,
} from '@revturbine/sdk/headless';
import playbook from './revturbine.playbook.json';
/** One instance per request: the SDK holds a single user context per instance. */
export function revturbineFor(user: RevTurbineUserContext) {
return initRevTurbine({
runtimeMode: RuntimeMode.LocalOnly,
localRuntime: { playbook },
user,
});
}
```
Get the Playbook with `revturbine download --live --save ./revturbine.playbook.json`
in your build or deploy step, so the server evaluates the Playbook you last
launched. A launch reaches a server when it next loads that file, so restart or
redeploy after a launch. The full local and connected patterns are on
[Server-Side Integration](/revturbine-sdk/getting-started/server-side/).
**Mint** client sessions and write server-only user state with your server key.
`RevTurbineServer` is one long-lived object per process, not one per request:
```ts title="src/server/revturbine-server.ts"
import { RevTurbineServer } from '@revturbine/sdk/headless';
export const revturbineServer = new RevTurbineServer({
tenantId: process.env.REVTURBINE_TENANT_ID!,
apiKey: process.env.REVTURBINE_API_KEY!,
endpoint: process.env.REVTURBINE_ENDPOINT ?? 'https://revturbine.com/app',
});
```
`RevTurbineServer` does not evaluate anything. What it can do: mint client
sessions (`clientSessions.create`), assign a seat type (`assignSeatType`),
write built-in dimensions (`setBuiltinDimensions`) and read them back
(`getBuiltinDimensions`). The server key stays in server code.
**Caution — Keep the server key out of the browser:**
RevTurbine refuses a server key presented from a browser, and a key in a
bundle is a leaked key. If it ever reaches the client, rotate it under
**Settings → API Token Management → Server Token**.
### Connected backend
For hosted Playbook updates, use the server runtime after authenticating the
caller and constructing their context from trusted application data:
```ts
import { initRevTurbine, RuntimeMode, type RevTurbineUserContext } from '@revturbine/sdk/headless';
export async function canExport(user: RevTurbineUserContext): Promise {
const session = await initRevTurbine({
runtimeMode: RuntimeMode.Server,
tenantId: process.env.REVTURBINE_TENANT_ID!,
apiKey: process.env.REVTURBINE_API_KEY!,
user,
analytics: false,
anonymousTelemetry: false,
});
try {
if (session.sdk.getPlaybookLoadFailure()) return false;
return (await session.can('data_export')).allowed;
} finally {
session.sdk.dispose();
}
}
```
Keep an identified session scoped to that user and operation. Share immutable
Playbook snapshots, not sessions, between callers. A connected instance treats
`localRuntime.playbook` as a startup seed and refreshes it from hosted delivery;
`LocalOnly` continues using the supplied snapshot without fetching.
The [cloud integration tutorial](/revturbine-sdk/tutorials/vercel-edge-entitlements/) adds authenticated
context tokens, an explicitly bounded shared Playbook cache, and provider adapters.
## Next
- [Client sessions](/revturbine-sdk/guides/client-sessions/): the full round trip, with the server route and the browser callback.
- [Server-Side Integration](/revturbine-sdk/getting-started/server-side/): re-check before paid work, and what a server can and cannot report.
- [Configuration reference](/revturbine-sdk/reference/configuration/): every option.
# Installation
> Install the RevTurbine SDK and configure your project.
## Web SDK (React)
### Install
`@revturbine/sdk` is published to the public npm registry — no auth, token, or registry configuration required.
**npm**
```bash
npm install @revturbine/sdk
```
**pnpm**
```bash
pnpm add @revturbine/sdk
```
**yarn**
```bash
yarn add @revturbine/sdk
```
### Peer Dependencies
The web SDK requires:
| Package | Version |
|---|---|
| `react` | `>=18.0.0` |
| `react-dom` | `>=18.0.0` |
### TypeScript
The SDK ships with full TypeScript declarations. No additional `@types/` packages are needed.
Recommended `tsconfig.json` settings:
```json
{
"compilerOptions": {
"strict": true,
"moduleResolution": "bundler",
"esModuleInterop": true,
"jsx": "react-jsx"
}
}
```
## Headless / Non-React
The same `@revturbine/sdk` package includes a headless entry point for use without React:
```ts
import { initRevTurbine } from '@revturbine/sdk/headless';
```
This import tree-shakes React dependencies. See the [Headless API guide](/revturbine-sdk/guides/headless-api/) for usage patterns with vanilla JS, Vue, Svelte, and Angular.
## Server-Side
Server SDKs are available for backend entitlement checks and placement resolution:
| Language | Package |
|---|---|
| Node.js | `@revturbine/sdk` (headless entry) |
| Python | `revturbine` (PyPI) |
:::note
Both server SDKs are published — `@revturbine/sdk` (npm) and `revturbine` (PyPI) — and produce byte-identical decisions for the same config and user context.
:::
## CLI
The **`@revturbine/cli`** package validates and launches Playbooks from the terminal. Install it as an exact development dependency so its bundled schema snapshot is pinned with your repository:
```bash
npm install -D --save-exact @revturbine/cli
npx revturbine --help
```
Or run it without installing:
```bash
npx @revturbine/cli validate ./revturbine.playbook.json
```
### Authenticate
The CLI signs in with a browser device-flow login — there are no API keys or environment variables to set. Authorize it once per machine:
```bash
revturbine login
```
This opens your browser to approve the device, then stores a tenant-scoped token at `~/.revturbine/credentials.json` (file mode `0600`). Run `revturbine status` to confirm you're connected — or `revturbine signup` to create an account from the terminal first. Commands that only read or validate a local file, like `validate`, need no login.
See the [CLI reference](/revturbine-sdk/reference/cli/) for the full command surface, file-selector rules, generated handle types, and the draft → Release lifecycle.
## Supported Runtimes
| Runtime | Minimum Version |
|---|---|
| Node.js | 22.13+ |
| Modern browsers | Chrome 90+, Firefox 90+, Safari 15+, Edge 90+ |
| React | 18.0+ |
| TypeScript | 5.0+ |
## Next Steps
- [Quickstart](/revturbine-sdk/) — render your first placement
- [Runtime modes](/revturbine-sdk/guides/runtime-modes/) — choose server, custom endpoints, or local-only
# Python SDK
> Headless, in-memory entitlement and placement decisioning for Python services — RevTurbineCustomerSdk.
The Python SDK is a **headless *server* library**: it decides entitlements and
placements **in-process**, with no network call and no persistence beyond
memory. The server holds the user context and a `Playbook` snapshot and
passes both to the SDK; the SDK decides locally.
It is intentionally a small surface — the same decision core as the TypeScript
SDK's headless `LocalRuntime`, exposed as a clean public class. It is **not** a
port of the browser SDK (no React, no hooks, no decision cache — see
[Non-goals](#non-goals)).
**Note:**
The Python decision output is **byte-identical** to the canonical TypeScript
`LocalRuntime` for the same inputs. A cross-language parity corpus
(`tests/parity/`) drives TypeScript, Python, and Rust through the same
fixtures and fails CI on any divergence — the Python port is never allowed
to drift.
## Install
The `revturbine` package (requires Python ≥ 3.10) is published to **PyPI**. Install it in a virtual environment:
**macOS / Linux**
```bash
python3 -m venv .venv
.venv/bin/python -m pip install revturbine
```
**Windows PowerShell**
```powershell
py -m venv .venv
.venv\Scripts\python.exe -m pip install revturbine
```
Use that environment's Python to run your app too. A virtual environment also handles PEP 668's “externally managed environment” restriction without modifying system Python.
Pin to a version whose schema matches the `Playbook` you deploy — see
[Refreshing the Playbook](#refreshing-the-playbook):
```bash
python -m pip install "revturbine=="
```
Here `python` means your virtual environment's interpreter, as in the commands above.
## Quick start
Construct one SDK per `(user_context, exported_config)`, then check
entitlements and decide placements:
```python
import json
from revturbine import RevTurbineCustomerSdk, UserContext
with open("revturbine.playbook.json") as f:
exported_config = json.load(f)
user_context: UserContext = {
"tenant_id": "tenant_abc",
"user_id": "user_123",
# Optional — the user's current plan and usage:
"plan_handle": "pro",
"usage": {"api_calls": {"used": 900.0, "limit": 1000.0}},
}
sdk = RevTurbineCustomerSdk(
user_context=user_context,
exported_config=exported_config,
)
# Entitlement gate
result = sdk.can("advanced_analytics")
if not result["allowed"]:
raise PermissionError(result["reason"])
# Placement decision (which payload, if any, to show)
decision = sdk.get_placement_decision(
{"placement_id": "pl_dashboard_upsell", "user_id": "user_123"}
)
if decision["visible"]:
render(decision["output"])
# Batch form — same decision path, order-preserving
decisions = sdk.get_placement_decisions(
[
{"placement_id": "pl_a", "user_id": "user_123"},
{"placement_id": "pl_b", "user_id": "user_123"},
]
)
```
### User context
| Key | Required | Meaning |
|---|---|---|
| `tenant_id` | ✅ | Tenant identifier. |
| `user_id` | ✅ | Current user identifier. |
| `plan_handle` | — | The user's current plan handle (feeds the plan + entitlements providers). |
| `plan_name` | — | Display name; defaults to `plan_handle`. |
| `usage` | — | Per-entitlement overrides: `{handle: {"used": float, "limit": float}}`. |
| `trial_status` | — | Already-derived trial state, overlaid onto the plan provider. |
| `payment_failed` | — | Hard-failure billing-recovery signal. |
| `payment_at_risk` | — | Soft billing-attention signal. |
| `tiers` | — | Current tier handle per `capability_tier` entitlement handle. |
| `segment_ids` | — | Tenant segment handles your app already resolved for this user. They count as segment membership for chipped payloads, segment-targeted entitlement rules, and the eligible plan and add-on helpers. |
| `custom` | — | Customer traits that segment predicates evaluate over, e.g. `{"sessions": 12}`. |
| `builtin_dimensions` | — | Built-in segment dimension values your server sets, e.g. `{"subscription_state": "paid"}`. This is the only way a built-in `rt..` segment can match on a server port. |
| `experiments` | — | Experiment-handle to variant-handle assignments, for experiment segments. |
`tenant_id` and `user_id` are the only required fields; an empty/missing one
raises `ValueError` at construction.
A user is in a segment only if `segment_ids` names it or its predicates match
`custom`, the plan or `builtin_dimensions`. Anything else fails closed: a
payload or rule chipped to a segment the SDK can't confirm is not served. Since
BL-0369 the SDK no longer treats every configured segment as matched.
`plan_handle` must equal the plan's `unique_handle` in the Playbook. Usage keys
must equal entitlement handles, and `used` is the current absolute balance,
not an increment.
**Caution — This is the compact server facade:**
Import `UserContext` from `revturbine`, as shown above. It is a `TypedDict` for
the local evaluator constructor—not the generated persisted model at
`revturbine.types.UserContext`, and not TypeScript's broader
`RevTurbineUserContext`. It intentionally has no `id`, `plan`, `trial`,
`custom`, `entitlements`, `experiments`, or `instances` fields. Map those app
values into the supported fields before construction. See the
[cross-language context mapping](/revturbine-sdk/user-context/#server-sdk-context-shapes).
### Public surface
| Method | Returns |
|---|---|
| `can(handle, context=None)` | `{status, allowed, reason, ...}`; preferred entitlement re-check |
| `check_entitlement(handle, context=None)` | `{status, allowed, reason, ...}` |
| `get_placement_decision(input)` | `{placement_id, visible, output, content, ...}` |
| `get_placement_decisions(inputs)` | ordered `list` of the above |
`can()` is an alias of `check_entitlement()`. There is **no storage/persistence parameter**
— the instance is stateless and in-memory by construction; construct a fresh
one per user context.
## Runtime model
Unlike the TypeScript SDK (which has `revturbine_server` / `custom_endpoints`
/ `local_only` runtime modes), the Python SDK is **local-only by design**.
There is no runtime-mode switch and no network path:
| | Python `RevTurbineCustomerSdk` |
|---|---|
| Network | None — decides from the supplied `Playbook` |
| Persistence | None — `InMemoryStorage` only; no file/db, no decision cache |
| State | None across calls; no interaction/suppression hydration |
| Inputs | `user_context` + `Playbook`, both supplied by the server |
This makes it safe on a request hot path: an entitlement gate is a pure
in-process computation, with no decision-API round-trip and no shared mutable
state between requests.
## Refreshing the Playbook
The `Playbook` is a snapshot exported from the RevTurbine control plane.
The SDK never fetches or caches it — your service owns its lifecycle:
1. Fetch/export the latest `Playbook` JSON (on boot, on a timer, or on a
webhook from the control plane).
2. Atomically replace the shared **Playbook snapshot**. Construct a new
`RevTurbineCustomerSdk` for each request with that request's authenticated
user context. Never share a user-bound SDK instance across requests.
```python
# Replace this snapshot on refresh, rather than modifying it in place.
playbook = load_config()
def check_request(user_context, entitlement):
sdk = RevTurbineCustomerSdk(
user_context=user_context,
exported_config=playbook,
)
return sdk.check_entitlement(entitlement)
```
Pin the installed SDK tag to a version whose schema matches the
`Playbook` you deploy; a snapshot newer than the SDK's schema can decide
differently than your TypeScript frontend.
## What is ported
Segment evaluation and targeting-state derivation from a raw user context are
ported and parity-locked, so a Python gate and a TypeScript render agree given
the same user context — not only the same pre-resolved traits. `segment_ids`
remains available as an explicit override.
## Non-goals
The Python SDK is the headless server decision core only. The following are
**intentionally not ported** — they are browser/full-SDK concerns with no
server equivalent:
- React / JSX components and hooks (no Python equivalent).
- Browser storage, `localStorage` theme persistence, `window.RevTurbine`.
- HTTP-backed / dual-mode dispatch (`runtime_mode`) — the Python library is
local-only.
- `identify`, `dismiss` / `snooze` / `convert`,
`track_treatment_interaction`, `get_trial_status`, `capture`,
`bootstrap_placement_decisions`, `get_user_context`, decision-cache /
interaction-state hydration.
**Caution:**
The package also contains an older thin-RPC client (`revturbine_server`). It is
unrelated to `RevTurbineCustomerSdk`; use `RevTurbineCustomerSdk` for all new
server integrations.
## Parity guarantee
Every public method delegates, with zero added decision logic, to the same
decision substrate the TypeScript SDK uses. Our parity suite runs all three
ports — TypeScript, Python, and [Rust](/revturbine-sdk/getting-started/rust/) — through
identical fixtures and asserts **byte-identical** normalized output; a
divergence is treated as a Python-port bug, never a fixture to loosen. This is what lets you gate in
Python and render in TypeScript and trust they decided the same thing.
# React Integration
> Use the RevTurbine SDK with React — Provider, hooks, and components.
The React integration provides a provider, hooks, and drop-in components for placement rendering and entitlement checks.
## RevTurbineProvider
Wrap your app (or a subtree) with the provider:
```tsx
import { RevTurbineProvider, RuntimeMode } from '@revturbine/sdk';
import playbook from './revturbine.playbook.json';
import { useMemo } from 'react';
export default function App() {
const options = useMemo(() => ({
runtimeMode: RuntimeMode.LocalOnly, // evaluate the bundled Playbook locally
localRuntime: { playbook },
user: {
id: 'user_123',
plan_handle: 'starter',
},
}), []);
return (
);
}
```
### Props
| Prop | Type | Description |
|---|---|---|
| `options` | `RevTurbineInitOptions` | SDK initialization options (see [Configuration Reference](/revturbine-sdk/reference/configuration/)) |
| `bootstrapPlacements` | `BootstrapPlacementInput[]` | Placements to preload on mount |
| `colorScheme` | `'light' \| 'dark' \| 'system'` | Placement palette; defaults to `'system'` and follows the device preference. Changing this prop does not reinitialize the SDK. |
| `children` | `ReactNode` | Your app |
Pass `colorScheme` directly to the provider, outside `options`. See [Theming](/revturbine-sdk/guides/theming/) for branding overrides and palette selection.
### Bootstrap Placements
Preload placement decisions during initialization to eliminate loading flicker:
```tsx
```
## Hooks
### useRevTurbine
Access the SDK instance and provider state:
```tsx
import { useRevTurbine } from '@revturbine/sdk';
function StatusBar() {
const { sdk, isReady, initStatus, colorScheme } = useRevTurbine();
if (!initStatus.ok) {
return (
SDK initialization failed during {initStatus.phase}: {initStatus.message}
{initStatus.remediation}
);
}
if (!isReady || !sdk) return Loading SDK…;
return SDK ready · {colorScheme} theme;
}
```
**Returns:**
| Field | Type | Description |
|---|---|---|
| `sdk` | `RevTurbineCustomerSdk \| null` | SDK instance (null until ready) |
| `isReady` | `boolean` | Whether the SDK has initialized |
| `error` | `string` | Error message if initialization failed |
| `initStatus` | `RevTurbineInitStatus` | Initialization outcome, available even when `sdk` is `null`: `{ ok, phase?, message?, remediation? }` |
| `colorScheme` | `'light' \| 'dark'` | Resolved placement palette; `'system'` has already been resolved. Set the provider prop to change it. |
| `setContext` | `(ctx: RevTurbineUserContext) => void` | Update user context dynamically |
Read `initStatus` before treating a missing SDK as loading. On failure, `ok` is `false` and the phase, message and remediation describe what to fix. During initialization, `ok` is still `true`; wait for `isReady` and a non-null `sdk` before making checks. Mount this diagnostic inside your app's actual provider so it tests the options and Playbook your app uses.
These fields are available from SDK 0.8.0. Development failure banners and theme warnings work in published packages from 0.8.9; `initStatus` remains available in production and in the earlier 0.8.x packages. See [Troubleshooting](/revturbine-sdk/guides/troubleshooting/) when initialization succeeds but a placement does not render.
### usePlacement
Resolve a placement for a surface slot:
```tsx
import { usePlacement } from '@revturbine/sdk';
function UpgradeBanner() {
const {
visible,
content,
isLoading,
dismiss,
ctaClick,
} = usePlacement({
surfaceSlot: { id: 'dashboard_banner', surfaceTemplateIds: ['banner_placement'] },
});
if (!visible) return null;
return (
{content?.header}
{content?.body}
);
}
```
**Options:**
| Field | Type | Default | Description |
|---|---|---|---|
| `surfaceSlot` | `RevTurbineSurfaceSlotConfig` | — | Slot configuration (id, surfaceTemplateIds) |
| `userId` | `string` | — | Override user ID |
| `contextMode` | `'auto' \| 'override'` | `'auto'` | Context resolution mode |
| `traits` | `Record` | — | Additional targeting traits |
| `ttlMs` | `number` | `300000` | Cache TTL in milliseconds |
| `autoLoad` | `boolean` | `true` | Load placement on mount |
**Returns:**
| Field | Type | Description |
|---|---|---|
| `isLoading` | `boolean` | Whether the placement is being resolved |
| `error` | `string` | Error message if resolution failed |
| `visible` | `boolean` | Whether the placement should be shown |
| `decision` | `RevTurbinePlacementDecision \| null` | Full decision object |
| `content` | `object \| null` | Resolved content (header, body, cta_label, etc.) |
| `refresh` | `() => Promise` | Re-resolve the placement |
| `dismiss` | `(cooldownMs?) => Promise` | Dismiss and suppress |
| `snooze` | `(seconds?) => Promise` | Temporarily suppress |
| `remindMeLater` | `(seconds?) => Promise` | Alias for snooze |
| `ctaClick` | `(target?) => Promise` | Record CTA click |
| `ctaComplete` | `(target?) => Promise` | Record CTA completion |
### useEntitlement
Check whether a user has access to a feature:
```tsx
import { useEntitlement } from '@revturbine/sdk';
function ExportButton() {
const { allowed, denied, gatedPlacement, recheck } = useEntitlement({
handle: 'data_export',
autoGate: true, // auto-resolve upgrade placement if denied
});
if (denied && gatedPlacement) {
return ;
}
return ;
}
```
**Options:**
| Field | Type | Default | Description |
|---|---|---|---|
| `handle` | `string` | — | Entitlement handle (required) |
| `autoCheck` | `boolean` | `true` | Check on mount |
| `autoGate` | `boolean` | `false` | Auto-resolve gated placement if denied |
| `gatePlacementRequest` | `object` | — | Placement config for the gate |
**Returns:**
| Field | Type | Description |
|---|---|---|
| `isLoading` | `boolean` | Whether the check is in progress |
| `error` | `string \| null` | Error message |
| `result` | `EntitlementResult \| null` | Full result |
| `allowed` | `boolean` | Access granted |
| `limited` | `boolean` | Approaching limit |
| `denied` | `boolean` | Access denied |
| `gatedPlacement` | `PlacementOutput \| null` | Upgrade placement (if `autoGate: true`) |
| `recheck` | `() => Promise` | Re-check entitlement |
### Keeping mounted gates current
`Gate` and `useEntitlement` subscribe to SDK context and usage notifications.
After `rt.update({ usage: { api_calls: total } })` or a supported context update,
the mounted gate rechecks; a forced remount or page reload is not required.
`recheck()` remains available for an explicit retry.
This subscription does not poll your billing database. When a subscription or
balance changes elsewhere, fetch fresh authoritative context and pass it to
the SDK. Updating React state that the SDK never receives cannot change its
decision. Preserve the provider's stable options object and update facts through
the SDK rather than rebuilding the provider for every balance change.
Verify a gate stays mounted while usage crosses its limit and then resets.
Also verify a paid-to-free user switch clears the old grants. See
[identity lifecycle](/revturbine-sdk/guides/identity-lifecycle/) and
[usage periods](/revturbine-sdk/guides/usage-periods/).
### useUsageSnapshot
Get current usage balances:
```tsx
import { useUsageSnapshot } from '@revturbine/sdk';
function UsagePanel() {
const { usage, refresh } = useUsageSnapshot();
return (
API calls: {usage.api_calls?.current} / {usage.api_calls?.limit}
);
}
```
### useRevTurbineTheme
Access the resolved theme:
```tsx
import { useRevTurbineTheme } from '@revturbine/sdk';
function ThemedCard({ children }) {
const theme = useRevTurbineTheme();
return (
{children}
);
}
```
## Drop-In Components
For the simplest integration, use `` — it resolves the placement and renders the matching built-in slot:
```tsx
import { Slot } from '@revturbine/sdk';
export function Surfaces() {
return (
<>
{/* Banner */}
{/* Modal gate */}
{/* Inline card */}
>
);
}
```
See the [Component Gallery](/revturbine-sdk/components/) for interactive demos of every built-in slot component.
## Dynamic Context Updates
Update the user context after login, plan change, or navigation:
```tsx
const { setContext } = useRevTurbine();
// After login
setContext({
id: user.id,
plan_handle: user.planHandle,
custom: { role: user.role },
});
```
All active placements and entitlement checks re-evaluate automatically when context changes.
## Next Steps
- [Entitlements Guide](/revturbine-sdk/guides/entitlements/) — feature gates, usage limits, and credits
- [Placements Guide](/revturbine-sdk/guides/placements/) — slot-based and entitlement-based patterns
- [Theming Guide](/revturbine-sdk/guides/theming/) — customize slot component appearance
- [Interactive Playground](/revturbine-sdk/playground/) — try live editable SDK scenarios
# Rust SDK
> Headless, in-memory entitlement and placement decisioning for Rust services — RevTurbineCustomerSdk.
The Rust SDK is a **headless *server* library**: it decides entitlements and
placements **in-process**, with no network call and no persistence beyond
memory. Your service holds the user context and a `Playbook` snapshot and
passes both to the SDK; the SDK decides locally.
It is the same decision core as the TypeScript SDK's headless `LocalRuntime`
and the [Python SDK](/revturbine-sdk/getting-started/python/), exposed as a small public type.
It is **not** a port of the browser SDK (no components, no hooks, no decision
cache — see [Non-goals](#non-goals)).
**Note:**
The Rust decision output is **byte-identical** to the canonical TypeScript
`LocalRuntime` for the same inputs. A cross-language parity corpus
(`tests/parity/`) drives TypeScript, Python, and Rust through the same fixtures
and fails CI on any divergence — the Rust port is never allowed to drift.
The decision core is **synchronous**. It performs no I/O, so there is no async
runtime dependency and no `.await` on the public surface — an entitlement gate
is a plain function call, safe on a request hot path.
## Install
The `revturbine` crate is published to **crates.io**:
```toml
[dependencies]
revturbine = "0.2"
```
The minimum supported Rust version is **1.88**, declared as `rust-version` in
the crate manifest. Pin to a version whose schema matches the `Playbook` you
deploy — see [Refreshing the Playbook](#refreshing-the-playbook).
## Quick start
Construct one SDK per `(user_context, playbook)`, then check entitlements and
decide placements:
```rust
use revturbine::runtime::PlacementDecisionInput;
use revturbine::sdk::{RevTurbineCustomerSdk, UserContext};
use serde_json::json;
let playbook: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string("revturbine.playbook.json")?)?;
let user = UserContext {
tenant_id: "tenant_abc".to_string(),
user_id: "user_123".to_string(),
// Optional — the user's current plan and usage:
plan_handle: Some("pro".to_string()),
usage: Some(json!({ "api_calls": { "used": 900, "limit": 1000 } })),
..Default::default()
};
let mut sdk = RevTurbineCustomerSdk::new(&user, &playbook)?;
// Entitlement gate
let check = sdk.check_entitlement("advanced_analytics", None);
if !check.allowed {
return Err(check.reason.unwrap_or_else(|| "denied".to_string()).into());
}
// Placement decision (which payload, if any, to show)
let decision = sdk.get_placement_decision(&PlacementDecisionInput {
placement_id: "pl_dashboard_upsell".to_string(),
user_id: "user_123".to_string(),
});
if decision["visible"] == true {
// Render `decision["output"]` with your own UI layer.
println!("{}", decision["output"]);
}
// Batch form — same decision path, order-preserving
let decisions = sdk.get_placement_decisions(&[
PlacementDecisionInput { placement_id: "pl_a".into(), user_id: "user_123".into() },
PlacementDecisionInput { placement_id: "pl_b".into(), user_id: "user_123".into() },
]);
```
**Caution:**
Read **`allowed`**, not `status`. The `degrade` enforcement mode returns
`status: "limited"` *and* `allowed: true` — gating on `status == "allowed"`
would wrongly block a user who is meant to be served a degraded experience.
### User context
| Field | Required | Meaning |
|---|---|---|
| `tenant_id` | ✅ | Tenant identifier. |
| `user_id` | ✅ | Current user identifier. |
| `plan_handle` | — | The user's current plan handle (feeds the plan + entitlements providers). |
| `plan_name` | — | Display name; defaults to `plan_handle`. |
| `usage` | — | Per-entitlement overrides: `{handle: {used, limit}}`. |
| `trial_status` | — | Already-derived trial state, overlaid onto the plan provider. |
| `payment_failed` | — | Billing-recovery signal for the retention qualifiers. |
| `payment_at_risk` | — | Billing-recovery signal for the retention qualifiers. |
| `tiers` | — | Current tier per `capability_tier` entitlement, for the tier gate. |
`UserContext` implements `Default`, so `..Default::default()` covers every
field you do not set. Only `tenant_id` and `user_id` are required; construction
rejects either one when it is empty.
`plan_handle` must equal the plan's `unique_handle` in the Playbook. Usage keys
must equal entitlement handles, and `used` is the current absolute balance,
not an increment.
**Caution — This is the compact server facade:**
Import `UserContext` from `revturbine::sdk`, as shown above. Do not substitute
the generated persisted model at `revturbine::types::UserContext`, and do not
copy TypeScript's broader `RevTurbineUserContext` shape. The local evaluator
accepts `plan_handle` plus optional `plan_name` rather than a `plan` object,
and `trial_status` rather than `trial`; it does not accept raw `custom`,
`entitlements`, `experiments`, or `instances`. See the
[cross-language context mapping](/revturbine-sdk/user-context/#server-sdk-context-shapes).
Omitting `trial_status` is the one easy mistake: without it every `trial_*`
gate reads "no trial" and silently declines rather than erroring. Supply it
whenever you run trial-triggered placements.
### Public surface
| Method | Returns |
|---|---|
| `check_entitlement(handle, context)` | `EntitlementCheckResult` |
| `get_placement_decision(input)` | `serde_json::Value` |
| `get_placement_decisions(inputs)` | `Vec`, order preserved |
| `get_placement(config)` | `Option` — surface-keyed slot resolution |
That is the entire public API. There is **no storage or persistence parameter**
— the instance is stateless and in-memory by construction; build a fresh one
per user context.
`check_entitlement` takes `&self`; the placement methods take `&mut self`
because within one instance they track which placements have already been
decided, which is what supersession reads.
**Caution:**
A malformed `Playbook` is an **error at construction**, not a degraded
decision: `RevTurbineCustomerSdk::new` returns `Err`. A partially-understood
Playbook can silently over-grant, so construction refuses it rather than guessing.
## Runtime model
Unlike the TypeScript SDK (which has `revturbine_server` / `custom_endpoints` /
`local_only` runtime modes), the Rust SDK is **local-only by design**. There is
no runtime-mode switch and no network path:
| | Rust `RevTurbineCustomerSdk` |
|---|---|
| Network | None — decides from the supplied `Playbook` |
| Persistence | None — in-memory only; no file/db, no decision cache |
| State | None across instances; no interaction/suppression hydration |
| Concurrency | Synchronous; no async runtime dependency |
| Inputs | `UserContext` + `Playbook`, both supplied by your service |
## Refreshing the Playbook
The `Playbook` is a snapshot exported from the RevTurbine control plane. The
SDK never fetches or caches it — your service owns its lifecycle:
1. Fetch/export the latest `Playbook` JSON (on boot, on a timer, or on a
webhook from the control plane).
2. Parse it **once** and share the `serde_json::Value`; construction borrows
the snapshot rather than taking ownership, so one parse serves every
request.
3. Build a **new** `RevTurbineCustomerSdk` per user context from that shared
snapshot. Because the type is stateless, "refresh" is just "re-parse and
swap the shared snapshot" — e.g. behind an `ArcSwap` or an `RwLock`.
Pin the crate to a version whose schema matches the `Playbook` you deploy; a
snapshot newer than the crate's schema can decide differently than your
TypeScript frontend.
## What is ported
Segment evaluation and targeting-state derivation from a raw user context are
ported and parity-locked, so a Rust gate and a TypeScript render agree given
the same user context — not only the same pre-resolved traits.
## Non-goals
The Rust SDK is the headless server decision core only. The following are
**intentionally not ported** — they are browser/full-SDK concerns with no
server equivalent:
- UI components, hooks, and theming (no Rust equivalent).
- Browser storage, `localStorage` theme persistence, `window.RevTurbine`.
- HTTP-backed / dual-mode dispatch — the Rust crate is local-only.
- `identify`, `dismiss` / `snooze` / `convert`, treatment-interaction
tracking, `capture`, `bootstrap_placement_decisions`, decision-cache and
interaction-state hydration.
## Parity guarantee
Every public method delegates, with zero added decision logic, to the same
decision substrate the TypeScript SDK uses. Our parity suite runs all three
languages through identical fixtures and asserts **byte-identical** normalized
output; a divergence is treated as a Rust-port bug, never a fixture to loosen.
TypeScript is canonical — including where it is arguably wrong, because a port
that is "more correct" in isolation is still divergent. Where the shared
contract itself needs to change, it changes in all three ports together.
This is what lets you gate in Rust and render in TypeScript and trust they
decided the same thing.
# Server-Side Integration
> Use RevTurbine server-side for entitlement checks, placement decisions, and SSR.
The headless SDK runs on Node.js and edge runtimes. On a server it evaluates
the Playbook snapshot you load, in-process, one user per instance, with no
decisioning network call. Your server key authenticates hosted Playbook loading and event ingest. The
separate `RevTurbineServer` helper mints [client sessions](/revturbine-sdk/guides/client-sessions/)
for the browser; it does not evaluate entitlements.
**Note — TypeScript uses RevTurbineUserContext:**
The `./headless` entry point accepts the same canonical, flat user shape as the
browser and React SDKs. Put the tenant in the top-level `tenantId` option and
the identity in `user.id`; do not use the compact Python/Rust
`{tenant_id, user_id}` shape here. See the
[cross-language context mapping](/revturbine-sdk/user-context/#server-sdk-context-shapes).
## When to Use Server-Side
| Use case | Client SDK | Server SDK |
|---|---|---|
| Render placements in React | ✅ | — |
| Gate API endpoints | — | ✅ |
| Check a usage allowance before processing | — | ✅ (your application owns atomic consumption) |
| SSR placement decisions | ✅ (local mode) | ✅ |
| Report events to RevTurbine | ✅ | ✅ TypeScript connected mode; see [Events](#what-a-server-reports) |
| Mint client sessions | — | ✅ `RevTurbineServer` |
## Headless Server Re-check
Use the `./headless` entry so a server or edge function does not load React.
Always repeat money-, data-, or compute-sensitive checks at this boundary.
**Load the Playbook once per process, and build one instance per request.** The
SDK holds a single user context per instance, so a shared instance would let two
concurrent requests overwrite each other's user. A request-scoped instance built
from an already-loaded Playbook makes no network call to decide:
```ts title="src/server/revturbine.ts"
import {
initRevTurbine,
RuntimeMode,
type RevTurbineUserContext,
} from '@revturbine/sdk/headless';
import playbook from './revturbine.playbook.json';
/** One instance per request. The Playbook is loaded once, when this module is. */
export function revturbineFor(user: RevTurbineUserContext) {
return initRevTurbine({
runtimeMode: RuntimeMode.LocalOnly,
localRuntime: { playbook },
user,
});
}
```
```ts title="src/server/export-route.ts" docs-check=strict
import type { RevTurbineUserContext } from '@revturbine/sdk/headless';
import { revturbineFor } from './revturbine';
import { getSignedInUser, runExport } from './app'; // your own code
export async function POST(request: Request): Promise {
const user = await getSignedInUser(request);
if (!user) return new Response('Unauthorized', { status: 401 });
const context: RevTurbineUserContext = {
id: user.id,
plan_handle: user.planHandle, // from your own billing record: the server decides, not the browser
usage: {
api_calls: {
entitlement_handle: 'api_calls',
unit: 'calls',
amount: user.apiCallCount,
limit: user.apiCallLimit,
},
},
};
const rt = await revturbineFor(context);
try {
const result = await rt.can('data_export');
if (!result.allowed) return new Response('Upgrade required', { status: 403 });
return await runExport(request);
} finally {
rt.sdk.dispose();
}
}
```
The Playbook file comes from your launched Playbook:
`revturbine download --live --save ./revturbine.playbook.json`, run in your
build or deploy step. A launch reaches a running server when it next loads that
file, so restart or redeploy after a launch.
Initial `user.usage` entries use the rich `UserUsageEntry` shape shown above.
After initialization, call `rt.update({ usage: { api_calls: total } })` with
absolute numeric balances, not deltas.
### Hosted delivery on a backend
TypeScript's `RuntimeMode.Server` supports a backend `apiKey` for Playbook
loading and event ingest. Browser integrations use `publicKey`; browser and
worker contexts never send the server secret. A backend public key without an
allowed browser origin is not a substitute for a server key.
Evaluation still runs locally over `UserContext` and `Playbook`. The application
owns requests and any shared snapshot; never mutate a shared user-bound SDK
instance to serve different users. The `LocalOnly` pattern above evaluates the
snapshot supplied by the application. For an explicitly connected instance, a
supplied `localRuntime.playbook` is a startup seed that hosted loading refreshes.
A failed load is visible through `getPlaybookLoadFailure()`. Without a readable
Playbook, entitlement checks fail closed with `config_unavailable`.
### What a server reports
A `LocalOnly` instance makes no hosted decision or event-ingest call. Its
`track()` calls do not reach RevTurbine ingest.
A TypeScript hosted backend instance authenticates `track()` events with its
server key. Call `await rt.flushEvents()` before ending a short-lived operation;
backend instances have no browser page-unload lifecycle. `flushEvents()` is
best-effort transport work, not a durable delivery receipt; call `rt.sdk.dispose()`
in `finally` after using the session. Events belong to that
instance's user context. This transport does not turn the SDK into a request
handler or share identity between users.
Python and Rust evaluate a Playbook supplied by the application and send no
events.
## Server-Owned CTA Actions
An authored CTA such as `extend_trial` starts in the browser but must mutate
state on your server. Register a `serverActions` handler with the client SDK;
it calls your route and returns fresh server-authoritative context:
```ts
const options = {
// ...your normal RevTurbineProvider options
serverActions: {
extend_trial: async ({ placement, params }) => {
const response = await fetch('/api/trial/extend', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
decisionId: placement.decision_id,
extensionDays: params.extension_days,
}),
});
if (!response.ok) return { success: false };
return { success: true, userContext: await response.json() };
},
},
};
```
The route repeats the entitlement check before it changes anything:
```ts docs-check=strict
import { revturbineFor } from './revturbine';
import { extendTrial, requireUser } from './app'; // your own code
export async function POST(request: Request): Promise {
const user = await requireUser(request);
const rt = await revturbineFor({ id: user.id, plan_handle: user.planHandle });
try {
const access = await rt.can('trial_extension');
if (!access.allowed) return new Response('Upgrade required', { status: 403 });
const trial = await extendTrial(user.id);
return Response.json({
plan_handle: user.planHandle,
trial: { in_trial: true, days_remaining: trial.daysRemaining },
});
} finally {
rt.sdk.dispose();
}
}
```
The SDK contains rejection and `{ success: false }`, leaves held context
unchanged, and records the result on the canonical `placement_interaction`
event. There is no client-side `extendTrial()` mutation.
## Bundled Local Runtime
The local runtime evaluates entitlements and placements entirely from a Playbook JSON snapshot. No API server connection required.
### Installation
```bash
pnpm add @revturbine/sdk
```
### Entitlement Checks
```ts
import { initRevTurbine, RuntimeMode } from '@revturbine/sdk/headless';
import playbook from './revturbine.playbook.json';
const sdk = await initRevTurbine({
runtimeMode: RuntimeMode.LocalOnly,
localRuntime: { playbook },
user: {
id: 'user_123',
plan_handle: 'starter',
entitlements: { data_export: false },
},
});
// Check an entitlement
const result = await sdk.can('data_export');
if (!result.allowed) {
// deny access — user needs to upgrade
console.log('Upgrade required:', result.status);
}
```
### Placement Decisions (SSR)
Resolve placements server-side for SSR or email templates:
```ts
import { initRevTurbine, RuntimeMode } from '@revturbine/sdk/headless';
import playbook from './revturbine.playbook.json';
const sdk = await initRevTurbine({
runtimeMode: RuntimeMode.LocalOnly,
localRuntime: { playbook },
user: {
id: req.user.id,
plan_handle: req.user.planHandle,
},
});
// Report current usage (balances live under `usage`; a bare handle is not
// a usage report)
sdk.update({ usage: { api_calls: req.user.apiCallCount } });
// Resolve a specific slot's placement
const banner = await sdk.getPlacement({ slotId: 'dashboard_banner' });
if (banner) {
// Render banner.content into your template
}
```
## SSR with React (Local Mode)
For frameworks like Next.js, resolve placements with the local runtime and hydrate client-side:
`sdk.hydrate(payload)` applies user context, entitlements, trial status, and usage.
Legacy payload placement decisions are not reused: they lack a binding to the
producer's complete evaluation context and Playbook. The browser evaluates
placements locally from its current inputs, without a decision HTTP request.
```tsx
// components/Dashboard.tsx
'use client';
import { RevTurbineProvider, Slot, RuntimeMode } from '@revturbine/sdk';
import playbook from './revturbine.playbook.json';
import { useMemo } from 'react';
export default function Dashboard({ user }) {
const options = useMemo(() => ({
runtimeMode: RuntimeMode.LocalOnly,
localRuntime: { playbook },
user: {
id: user.id,
plan_handle: user.planHandle,
entitlements: user.entitlements,
},
}), [user]);
return (
{/* rest of your dashboard */}
);
}
```
## Client Sessions
`RevTurbineServer`, exported from `@revturbine/sdk/headless`, holds your server
key and mints short-lived, browser-safe client sessions for one user at a time.
This is the hosted-mode job for your server: it is how plan, trial and payment
state from Stripe reaches the browser SDK. It is not a decision evaluator; use
the headless session above for checks.
```ts title="app/api/revturbine-session/route.ts"
import { RevTurbineServer } from '@revturbine/sdk/headless';
import { getSignedInUser } from '@/server/auth'; // your own auth: the signed-in user, or null
const revturbine = new RevTurbineServer({
tenantId: process.env.REVTURBINE_TENANT_ID!,
apiKey: process.env.REVTURBINE_API_KEY!, // the server key, never NEXT_PUBLIC_
endpoint: process.env.REVTURBINE_ENDPOINT ?? 'https://revturbine.com/app',
});
export async function POST(): Promise {
const user = await getSignedInUser();
if (!user) return new Response('Unauthorized', { status: 401 });
const { client_token } = await revturbine.clientSessions.create({ subject: user.id });
return Response.json({ client_token });
}
```
The browser half, an Express version, the failure behavior, and how the SDK
refreshes the token are on [Client sessions](/revturbine-sdk/guides/client-sessions/).
## Server SDK languages
TypeScript has both browser/React and headless entry points. Python and Rust are headless libraries for your backend; all three evaluate the same Playbook with parity-checked decisions.
| Language | Package | Guide |
|---|---|---|
| Node.js / edge | `@revturbine/sdk/headless` | This page; [Headless API](/revturbine-sdk/guides/headless-api/) |
| Python | `revturbine` (PyPI) | [Python SDK](/revturbine-sdk/getting-started/python/) |
| Rust | `revturbine` (crates.io) | [Rust SDK](/revturbine-sdk/getting-started/rust/) |
TypeScript supports local mode everywhere and hosted Playbook delivery in both browsers and trusted backends. A backend uses its server `apiKey` for delivery and evaluates locally in a request-scoped session; the separate `RevTurbineServer` helper mints browser client sessions. Python and Rust evaluate a supplied Playbook locally; the application owns refreshing that snapshot. Their guides describe installation, input shapes and the supported surfaces.
Python exposes the same re-check question as `can()`:
```python
result = sdk.can("data_export")
if not result["allowed"]:
raise PermissionError(result["reason"])
```
See the [Python SDK guide](/revturbine-sdk/getting-started/python/) and [Rust SDK guide](/revturbine-sdk/getting-started/rust/) for setup and usage.
## Next Steps
- [User Context](/revturbine-sdk/user-context/) — canonical fields and cross-language shapes
- [Entitlements Guide](/revturbine-sdk/guides/entitlements/) — entitlement types and checking patterns
- [Runtime Modes](/revturbine-sdk/guides/runtime-modes/) — choosing the right runtime mode
- [Headless API](/revturbine-sdk/guides/headless-api/) — imperative SDK usage without React
## Runnable cloud examples
Follow [Cloud and edge entitlement checks](/revturbine-sdk/tutorials/vercel-edge-entitlements/) for
Vercel, Firebase, AWS, Google Cloud and Azure. It keeps authentication and request
handling in your application, caches only immutable Playbooks, and constructs a
fresh SDK session for each operation. The [Vercel walkthrough](/revturbine-sdk/tutorials/vercel-edge-entitlements/)
also exercises the browser-compatible bundle in an Edge VM.
# Beyond Gating
> Gating one feature is the on-ramp. The control plane also does targeting, priority across competing placements, lifecycle triggers, and explainability — all editable without a deploy.
Gating one feature is the first thing you'll do. It's also the smallest thing RevTurbine does. One layer in, the control plane is deciding *what to show, to whom, and when* — and all of it moves in config, not code.
## Targeting
Every entitlement rule and placement can be scoped to a **segment** — a rule over user-context traits. That's how one placement shows different copy to different users, and how you [run experiments](/revturbine-sdk/guides/experiments/) by matching a variant assignment. Your app sets the traits; RevTurbine does the matching.
## Priority across competing placements
Multiple placements can be eligible for the same moment. `getPlacementDecision()` picks **one winner** using eligibility, presentation caps, cooldowns, and priority — so a user never gets three upsells stacked on top of each other. You render the winner with a ``; the arbitration happens in the engine.
## Lifecycle triggers
Beyond "is this allowed?", you can tell the engine *what just happened* with `emitTrigger()` — canonical lifecycle events it recognizes and can respond to with the right placement:
```ts
import { useRevTurbine } from '@revturbine/sdk';
const { sdk: rt } = useRevTurbine(); // with a headless session: `session.sdk`
await rt.emitTrigger('usage_limit_approaching', { usage_percent: 85, threshold: 80 });
await rt.emitTrigger('trial_expiring', { days_remaining: 2 });
await rt.emitTrigger('feature_gated', { feature: 'advanced_automation' });
```
The engine attaches standard context (user, plan, timestamp) and decides whether — and which — placement to surface, so a "trial ending" nudge or a "running low" banner appears at the right moment without you hard-coding the timing.
## Explainability
When you need to know *why* a decision resolved the way it did, [`explainPlacementDecision()`](/revturbine-sdk/operate/debugging/) returns the full reasoning — segment matches, entitlement outcomes, and suppression. It's the same payload the engine used, so you can build QA tooling or a "why am I seeing this?" panel on it.
## Experimentation
Segment-driven experimentation is shipped today: bring a variant assignment from your experimentation platform and RevTurbine branches the monetization response on it — see [Run an Experiment with Segments](/revturbine-sdk/guides/experiments/).
**Note — Roadmap: ML-scored optimization:**
Today you decide the strategy and RevTurbine executes it deterministically. **ML-scored propensity and auto-optimization** — RevTurbine choosing the winning variant or the best next offer for you — are on the roadmap, not shipped.
## Automate it
Because everything above is config, a coding agent can drive it on a cadence with deterministic guardrails — see [Add RevTurbine with a Coding Agent](/revturbine-sdk/guides/coding-agent/).
## Related
- [Placements](/revturbine-sdk/guides/placements/) — decision inputs, caps, and priority in depth
- [Run an Experiment with Segments](/revturbine-sdk/guides/experiments/)
- [Debugging Decisions](/revturbine-sdk/operate/debugging/)
# Billing & Stripe
> Connect your Stripe account, set up webhook destinations, and verify the pipeline in test mode.
RevTurbine reads billing truth from Stripe — the customer's current plan, payment
health, and subscription lifecycle all flow in through Stripe webhooks and land on
the customer record, where decisions read them. Stripe is the supported billing
provider today; others are planned. This page covers the three operational
pieces: connecting the account, wiring the webhook destinations, and proving the
pipeline works before real money moves.
## Connect your Stripe account
Connection happens in the dashboard — **Settings → Stripe**. There are two paths,
and neither involves pasting API keys:
- **Existing Stripe account** — use **Connect with Stripe** (OAuth). You're sent
to Stripe, approve the connection, and land back in Settings with the account
linked. RevTurbine receives scoped access through Stripe Connect; you can
revoke it from either side at any time.
- **New Stripe account** — use **Set up payments** (embedded onboarding). Stripe's
onboarding flow runs inside the dashboard via an account session; when Stripe
finishes provisioning, the account is linked automatically.
**Note:**
RevTurbine never asks for a Stripe secret key. If an integration guide tells you
to paste one, it isn't describing RevTurbine.
Once connected, plan variations link to Stripe prices (`stripe_price_id`), the
billing page gains a **Sync** action that compares RevTurbine's plans against
your Stripe catalog, and webhook events begin updating customer records.
## Webhooks: nothing to register
RevTurbine is a Stripe Connect platform. When you connect, Stripe starts
delivering your account's events to RevTurbine's platform-level webhook
endpoints — subscription and invoice events, payment-method and person/account
events — and RevTurbine verifies each one and resolves it to your tenant from
the connected account it came from. There are no destinations to create in
your Stripe Dashboard, no signing secrets to copy, and no URL to configure.
If billing state stops updating, the first place to look is **Settings →
Stripe** in the RevTurbine dashboard: the connection status and the
events-received summary show whether events are arriving.
### Link Stripe customers to accounts
Stripe knows who paid. RevTurbine knows which account and user used the
product. Until each paying Stripe customer is linked to one of your accounts,
that revenue is a payer nobody can attribute: it is missing from cohort and
retention figures, and no conversion can be credited to the prompt that drove
it. Link them from day one, in your own Stripe calls. **RevTurbine never writes
to your Stripe account**; the metadata below is yours to set.
RevTurbine looks for these three pieces of evidence for each payer:
1. **Customer metadata.** `revturbine_account_id` (preferred), or
`revturbine_user_id`, which resolves through your users to their account.
2. **An existing link** between the Stripe customer and an account.
3. **A customer your Checkout created**, resolved through the session's
metadata or `client_reference_id`.
They are not a priority list where the first hit silently wins. RevTurbine
collects all that exist, and when they agree on one account the payer is
linked. **When they point to two different accounts, the payer is left
unlinked** until you correct the data. Keep the keys, the Checkout metadata and
your customer records consistent.
Matching a payer by email is not available: no SDK call writes a user's email
to RevTurbine from your server yet, so there is nothing to match against. Do not
rely on `identify()` with an `email` to link a payer.
The first is explicit and the only one under your control from the start, so
**use the `revturbine_*` keys as the primary path** and set them everywhere you
create a customer. Use the same values you give the SDK: `revturbine_user_id`
is the `user.id`, and `revturbine_account_id` is the `account_id`.
**Fallback for billing layers such as Clerk.** When none of the three apply, a
plain `user_id` in the Customer metadata is used as a stand-in account
identity. It is a reasonable proxy for `account_id`, but it is **not a
RevTurbine user id**: RevTurbine user ids come from the user context you pass
to the SDK, never from this key. Do not rely on it when you can set
`revturbine_account_id`.
```ts docs-check=strict
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
/** Your signup or first-checkout code: create the customer already linked. */
export async function createStripeCustomer(
user: { id: string; email: string },
accountId: string,
) {
return stripe.customers.create({
email: user.email,
metadata: {
revturbine_account_id: accountId, // the account_id you pass to the SDK
revturbine_user_id: user.id, // the user.id you pass to the SDK
},
});
}
/** Customers that already exist: a one-off backfill, then keep it on every update. */
export async function linkExistingCustomer(
stripeCustomerId: string,
user: { id: string },
accountId: string,
) {
return stripe.customers.update(stripeCustomerId, {
metadata: { revturbine_account_id: accountId, revturbine_user_id: user.id },
});
}
```
When Checkout is what creates the customer, Stripe cannot set Customer metadata
inline. Create the customer first and pass `customer`, so the link exists
before the purchase:
```ts docs-check=strict
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function startCheckout(
user: { id: string },
accountId: string,
stripeCustomerId: string,
stripePriceId: string,
) {
const ids = { revturbine_account_id: accountId, revturbine_user_id: user.id };
return stripe.checkout.sessions.create({
mode: 'subscription',
customer: stripeCustomerId, // created above, with the metadata
line_items: [{ price: stripePriceId, quantity: 1 }],
client_reference_id: user.id,
metadata: ids, // on the session
subscription_data: { metadata: ids }, // and on the subscription
success_url: 'https://app.example.com/billing/done',
});
}
```
If you cannot pre-create the customer (a Checkout in `payment` mode, say, with
`customer_creation: 'always'`), copy the same two keys onto the Customer from
your `checkout.session.completed` handler with `stripe.customers.update`. The
placement attribution bag from `checkoutMetadata()` below goes on the same
session and subscription metadata.
#### Use the same account id in the SDK
Pass the same `account_id` to the SDK that you put in `revturbine_account_id`,
so what the SDK reports and what Stripe bills describe the same account. Never
use an email as the id.
```tsx docs-check=strict
import { useRevTurbine } from '@revturbine/sdk';
import { useEffect } from 'react';
/** Mount once, inside the provider, once the signed-in user is known. */
export function IdentifyUser({ user }: { user: { id: string; accountId: string } }) {
const { sdk } = useRevTurbine();
useEffect(() => {
if (!sdk) return; // the SDK is null until the provider has initialized
sdk.identify(user.id, { account_id: user.accountId });
}, [sdk, user.id, user.accountId]);
return null;
}
```
The same field works at init (`user: { id, account_id }` on
`RevTurbineProvider`'s `options`) and through `setUserContext()` / `update()`.
#### What linking unlocks
- **Cohort and retention figures** for paying accounts, because revenue now
belongs to an account with a signup date and usage.
- **Attribution**: a purchase is credited to the placement the user saw, even
for renewals and checkout-less billing events, with no time-window guess.
- **Server-derived plan state** in the browser, through the
[client session](/revturbine-sdk/guides/client-sessions/).
A payer with none of these links cannot be attributed to an account until
one exists, so set the metadata before the first purchase rather than after.
### What the events drive
- `customer.subscription.created/updated/deleted` → the customer's current plan
— which flows into SDK decisions automatically: the client context carries the
server-derived plan handle, and hosted evaluation defaults to it when the
caller doesn't supply one.
- `invoice.payment_failed` / `payment_intent.payment_failed` → the customer's
billing-health issues, which drive the `payment_failed` retention qualifier
server-side (and clear again on `invoice.payment_succeeded`).
- Account/person (v2) events → connected-account status shown in Settings.
## Verify in test mode
Walk this once after connecting, before anything real depends on the pipeline:
1. **Fire synthetic events on your connected account.** With the Stripe CLI,
in test mode:
```bash
stripe trigger customer.subscription.created --stripe-account acct_…
stripe trigger invoice.payment_failed --stripe-account acct_…
```
2. **Watch them land.** Settings → Stripe shows an events-received summary; a
triggered subscription event should move a test customer's plan, and the
payment-failure event should flip that customer's billing health to
`attention_required` (visible on the customer record).
3. **Run the catalog comparison.** The billing page's **Sync** action compares
RevTurbine plans against the Stripe catalog and reports mismatched, missing,
and orphaned prices — a clean report means plan↔price linkage is coherent.
4. **Confirm recovery.** `stripe trigger invoice.payment_succeeded` should clear
the billing-health issue again.
## Integration settings
**Settings → Stripe** is the home for the connection itself: connected-account
status, the events-received summary, and the Sync comparison. Stripe is the
first integration with a settings surface; outbound analytics forwarding
(PostHog — `createPostHogIntegration` in the SDK) is configured in code today,
with a settings surface planned.
## Where checkout fits
RevTurbine decides *what* to offer; your app owns the checkout itself. A CTA
with the `open_checkout` action resolves to `open_checkout_modal` carrying the
plan handle — your resolver opens Stripe Checkout (or your own flow) for the
matching price. After payment, the subscription webhook updates the customer's
plan, and access follows without further app code.
### Tell Stripe which placement led here
Your server creates the Checkout Session, and Stripe only knows what you put
on it. `checkoutMetadata(outputId)` returns the `revturbine_*` bag for the
output the user clicked — the user, account, placement, decision, output, rule
and target plan the SDK holds for it — so the purchase joins the presentation
and click it came from with **confirmed** credit instead of a time-window
inference. Send it to your server with the price the user chose:
```ts
import { useRevTurbine } from '@revturbine/sdk';
// Browser: `output` is the decision the slot rendered (`decision.output`).
const { sdk: rt } = useRevTurbine(); // with a headless session: `session.sdk`
const checkout = rt.checkoutMetadata(output.output_id);
// `checkout` is null for an output this SDK did not decide.
```
Then put the bag on the session **and** on the subscription, and the user id on
`client_reference_id`:
```ts docs-check=false reason="server-side Stripe SDK call; the stripe package is not part of the SDK toolchain"
// Server: `checkout` is what the browser sent.
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: stripePriceId, quantity: 1 }],
customer: stripeCustomerId,
client_reference_id: checkout.client_reference_id,
metadata: checkout.metadata,
subscription_data: { metadata: checkout.metadata },
});
```
- `metadata` on the session is what `checkout.session.completed` carries.
- `subscription_data.metadata` mirrors the bag onto the Subscription, so every
`customer.subscription.*` event carries it and every invoice snapshots it
under `parent.subscription_details.metadata` — renewals, failed payments and
recoveries stay attributed to the placement without a customer mapping.
- `client_reference_id` is Stripe's own caller-side key for the session;
RevTurbine reads it as the user id when the metadata is absent.
The bag's keys are `revturbine_user_id`, `revturbine_account_id`,
`revturbine_placement_id`, `revturbine_decision_id`, `revturbine_output_id`,
`revturbine_rule_id` and `revturbine_plan_handle`. A key the SDK does not hold
is absent rather than empty — no id is ever invented — so an unidentified user
yields no `revturbine_user_id` and no `client_reference_id`, and a CTA that
names no plan yields no `revturbine_plan_handle`.
**Caution — Customer metadata cannot be set from Checkout:**
Checkout cannot set metadata on the Stripe Customer inline. Create the
Customer yourself with `metadata.revturbine_user_id` before opening Checkout
(see above), or, if you let Checkout create it, follow up with
`customers.update({ metadata })` from your `checkout.session.completed`
handler. The session and subscription copies of the bag identify the purchase
either way; the Customer copy is what makes later, checkout-less billing
events resolvable without a customer mapping.
# Client sessions
> The full round trip for hosted mode — your server route that mints a session token with the server key, the browser callback that fetches it, and what the SDK does with it.
In hosted mode RevTurbine knows things your browser does not: the plan Stripe
says this user is on, whether a trial is running, whether a payment failed. A
**client session** is how that state reaches the SDK without you wiring a
refresh loop.
It has two halves, and you write both:
- a **route on your server** that checks who is signed in, mints a short-lived
token with your server key, and returns it;
- a **callback in the browser** that calls that route and hands the token to the
SDK.
```text
browser your server RevTurbine
| POST /api/revturbine-session | |
|------------------------------->| who is signed in? (your auth) |
| | POST /api/sdk/client-sessions |
| | (server key, subject = user) |
| |------------------------------->|
| |<-- { client_token, expires_at }|
|<-- { client_token, expires_at }| |
| GET /api/sdk/client-context (Bearer client_token) |
|---------------------------------------------------------------->|
|<-- plan, trial, payment health (client-safe fields only) |
```
The server key never leaves your server. The browser holds only the token,
in memory, for about ten minutes.
## The server route
The route does three things in order: authenticate **your** user, mint with
**your** server key, return **only** the token.
1. **Authenticate with your own auth.** RevTurbine does not know who is signed
in to your app. Mint for the user your session says is signed in, and take
the id from your session, never from the request body: the token carries
that subject, and the SDK will apply whatever plan it resolves to the user
it is initialized for.
2. **Mint with the server key.** `RevTurbineServer` holds the key and calls
`POST /api/sdk/client-sessions` for you. Create it once per process.
3. **Return only `client_token`** (and `expires_at` if you want it). Do not
return the server key, the tenant id, or any other secret.
Create the server object once, in a module of its own:
```ts title="src/server/revturbine-server.ts"
import { RevTurbineServer } from '@revturbine/sdk/headless';
export const revturbineServer = new RevTurbineServer({
tenantId: process.env.REVTURBINE_TENANT_ID!,
apiKey: process.env.REVTURBINE_API_KEY!, // the server key. Never NEXT_PUBLIC_.
endpoint: process.env.REVTURBINE_ENDPOINT ?? 'https://revturbine.com/app',
});
```
### Next.js route handler
```ts title="app/api/revturbine-session/route.ts"
import { revturbineServer } from '@/server/revturbine-server';
import { getSignedInUser } from '@/server/auth'; // your own auth: the signed-in user, or null
export async function POST(): Promise {
const user = await getSignedInUser();
if (!user) return new Response('Unauthorized', { status: 401 });
try {
const { client_token, expires_at } = await revturbineServer.clientSessions.create({
subject: user.id,
});
return Response.json({ client_token, expires_at }, { headers: { 'cache-control': 'no-store' } });
} catch {
// The error carries only a status and a request id, never the key.
return new Response('Could not create a RevTurbine session', { status: 502 });
}
}
```
### Express
```ts title="src/server/routes/revturbine-session.ts"
import express from 'express';
import type { Request, Response } from 'express';
import { revturbineServer } from '../revturbine-server';
import { requireSignedIn } from '../auth'; // your own auth middleware: sets req.user or answers 401
export const revturbineSession = express.Router();
revturbineSession.post('/api/revturbine-session', requireSignedIn, async (req: Request, res: Response) => {
try {
const { client_token, expires_at } = await revturbineServer.clientSessions.create({
subject: req.user.id,
});
res.set('cache-control', 'no-store').json({ client_token, expires_at });
} catch {
res.status(502).send('Could not create a RevTurbine session');
}
});
```
Use this one route name everywhere: `/api/revturbine-session`. The SDK does not
care what it is called, but your docs, your CSRF rules and your team will.
**Note — What the token can do:**
A client session carries one capability, `context:read`: it reads the
client-safe context for the user it was minted for, and nothing else. It cannot
write, cannot read another user, and cannot call a management API. It expires
after about ten minutes. Server-only detail, such as raw Stripe ids and failed
payment records, is never in the response.
## The browser callback
`clientSession` is a **function**, not a token. The tokens are short-lived, so a
value captured at init would go stale mid-session.
```ts title="src/revturbine-client-session.ts"
export async function fetchClientSession(): Promise {
const response = await fetch('/api/revturbine-session', {
method: 'POST',
credentials: 'same-origin',
});
if (!response.ok) {
throw new Error(`RevTurbine client session failed (${response.status})`);
}
const body: { client_token: string } = await response.json();
return body.client_token;
}
```
Then pass it at init, as on [Initialize the SDK](/revturbine-sdk/getting-started/initialization/):
```tsx title="src/revturbine-provider.tsx"
import { RevTurbineProvider } from '@revturbine/sdk';
import { useMemo, type ReactNode } from 'react';
import { fetchClientSession } from './revturbine-client-session';
export function RevTurbine({ userId, children }: { userId: string; children: ReactNode }) {
const options = useMemo(
() => ({
publicKey: process.env.NEXT_PUBLIC_REVTURBINE_PUBLIC_KEY!,
user: { id: userId },
clientSession: fetchClientSession,
}),
[userId],
);
return {children};
}
```
## What the SDK does with it
1. It calls your callback **the first time it needs a token**, which is at init.
2. It sends the token as a Bearer credential to `GET /api/sdk/client-context`,
and folds the plan, trial and payment-health fields it gets back into the
user's context. Decisions then re-evaluate, locally.
3. When you call `identify()` with a new user, it drops the held token and calls
your callback again, because a token is minted for one user.
4. When RevTurbine answers `401` (the token expired or was revoked), it calls
your callback once for a fresh token and retries once. It does not loop.
The token is held in memory only. It is never persisted, never logged, never put
in a URL, and never merged into the user context you can read.
### When something goes wrong
Enrichment is best-effort, and it never breaks your app.
| What fails | What happens |
|---|---|
| Your route returns an error or your callback throws | The SDK keeps the context you passed at init. No plan arrives from Stripe, so pass `plan_handle` yourself if your signed-out or error state needs one. |
| Your callback returns an empty string | Treated as no token: same as above. |
| The token expires mid-session | One automatic re-mint and retry. |
| A different user signs in | Call `identify(newId)` (or re-create the provider, as the example above does when `userId` changes): the held token is dropped and your callback mints one for the new user. |
| Nobody is signed in | Your route answers `401`, so the callback throws and the SDK keeps the context you passed. |
| You omit `clientSession` | Nothing is requested. Server-derived plan and trial state never reach the SDK. |
Because failures are silent by design, check the route yourself: sign in, open
the network tab, and confirm `POST /api/revturbine-session` answers `200` with a
`client_token`, followed by `GET …/api/sdk/client-context` answering `200`.
## Which language mints
TypeScript on Node mints today, with `RevTurbineServer` as above. The Python and
Rust packages evaluate a Playbook you supply; they do not mint client sessions.
If your backend is Python or Rust, call `POST /api/sdk/client-sessions` yourself
with the server key as a Bearer token and `{ "subject": "" }` as the
JSON body. It answers `201` with `{ client_token, expires_at }`; return the
`client_token` to the browser.
## Related
- [Initialize the SDK](/revturbine-sdk/getting-started/initialization/)
- [Configuration reference → `clientSession`](/revturbine-sdk/reference/configuration/#server-derived-context-clientsession)
- [Production Readiness → Keys](/revturbine-sdk/operate/production-readiness/#keys)
- [Billing & Stripe](/revturbine-sdk/guides/billing/)
# Cloud and Edge Entitlements
> Run the same trusted UserContext and Playbook pattern across five cloud providers.
The SDK evaluates a **UserContext and Playbook in your application**. It can run
in a browser, a regional server function, or a compatible edge runtime. RevTurbine
delivers configuration; your application's protected operation enforces the result.
The [complete example](https://github.com/revt-eng/revturbine-sdk/tree/main/examples/cloud-entitlements)
uses the published SDK 0.12.4 and includes local tests. Start with the
[cloud export tutorial](/revturbine-sdk/tutorials/vercel-edge-entitlements/), then choose an adapter.
| Provider | Server implementation | Edge implementation |
| --- | --- | --- |
| Vercel | Node.js Function | Standalone Edge Function |
| Firebase | Functions v2 | Hosting CDN rewrites to the regional function |
| AWS | Lambda with HTTP API v2 | Lambda@Edge origin-request; not CloudFront Functions |
| Google Cloud | Cloud Run HTTP container | Global load balancer routes to regional Cloud Run |
| Azure | Functions v4 HTTP | Front Door routes to regional Functions |
The CDN/front-door variants for Firebase, Google Cloud, and Azure do not execute
the SDK at the edge. They preserve the same authenticated regional enforcement.
## Shared request lifecycle
1. Authenticate the application-issued context token using a trusted JWKS endpoint.
2. Validate its issuer, audience, tenant, subject, type, and short expiry.
3. Load a verified, bounded Playbook snapshot for that deployment's tenant.
4. Initialize a fresh SDK session with that user's authoritative context.
5. Check the entitlement, enforce resource ownership, and perform the operation.
6. Dispose the session in `finally`, including denied and failed operations.
Only immutable Playbook snapshots and public keys are shared across requests.
Never keep a mutable SDK session in a global variable and switch users between
requests. Ignore client-supplied plan or account claims unless authenticated by
your trusted issuer; a signed token must originate from authoritative records.
## Cache and security contract
The example cache uses authenticated HTTPS bootstrap, approved HTTPS origins,
tenant/environment checks, manifest validity, schema compatibility, byte length,
and SHA-256 integrity. It refreshes within 60 seconds and never beyond manifest
expiry. A failed refresh returns 503; it does not grant from stale policy.
It does not verify the optional Ed25519 manifest signature. This is an online,
authenticated-bootstrap trust model, not offline signature verification.
Context tokens expire within five minutes. This bounds, but does not eliminate,
the delay before a changed subscription or revoked membership becomes visible.
Choose shorter tokens or an application revocation check when needed.
These are application-issued JWTs, not `rt_client_` RevTurbine client-session
tokens. They have separate issuers, audiences, and verification paths. Delivery
uses a four-second timeout **per fetch**, while JWKS resolution has its own
three-second timeout. These are not a total-operation deadline; budget for
sequential requests and enforce your application's overall timeout separately.
Do not cache personalized responses at a CDN. The adapters emit `private,
no-store`; also disable the provider's route caching and forward Authorization.
Keep server tokens out of public bundles, logs, URLs, and downstream CDN requests.
The sample `data_export` entitlement is boolean. Metered operations additionally
need atomic application-side quota reservation/consumption. An SDK check followed
by a separate write is not an atomic quota transaction.
## Local validation
From `examples/cloud-entitlements`, run `npm ci`, `npm run typecheck`, and
`npm test`. Tests build adapters, exercise realistic HTTP/event boundaries, verify
JWTs and real SDK decisions, and execute the browser bundle in Edge Runtime.
No cloud accounts, deployments, or live credentials are required for acceptance.
See the example README for configuration, packaging, and provider limitations.
Provider deployment remains an optional adoption step, not a claim made by the
local tests. Compare this cache with the simpler
[native server initialization](/revturbine-sdk/getting-started/initialization/).
## Provider references
- [Vercel Edge runtime](https://vercel.com/docs/functions/runtimes/edge)
- [Firebase Hosting function rewrites](https://firebase.google.com/docs/hosting/functions)
- [Lambda@Edge restrictions](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-at-edge-function-restrictions.html)
- [HTTP API payload formats](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html)
- [Cloud Run container contract](https://cloud.google.com/run/docs/container-contract)
- [Azure Functions Node programming model](https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node)
- [Front Door caching](https://learn.microsoft.com/en-us/azure/frontdoor/front-door-caching)
# Add RevTurbine with a Coding Agent
> Paste-in prompts to add RevTurbine to an existing app or build it into a new one with Claude Code, Cursor, or your agent of choice — plus how to automate the recurring config loop through the CLI with deterministic guardrails.
If you integrate through a coding agent, you don't have to hand-wire anything. Pick the prompt that matches your situation — adding RevTurbine to an app you already have, or building a new app with RevTurbine from the start. The agent does the inspection and the edits, and you review the diff.
## Existing app: replace one plan check
```text
You're adding RevTurbine (@revturbine/sdk) to this app to gate one paid feature.
1. Inspect the app and find ONE non-critical feature currently gated by a
hard-coded plan check (e.g. `user.plan === 'pro'`). Pick the safest one.
2. Run `npm create revturbine@latest`. It installs @revturbine/sdk, pins the CLI
and writes a starter revturbine.playbook.json. Wrap the app in
in local_only mode with that Playbook, so it runs with no
account.
3. Edit the Playbook so it has a free and a pro plan, one entitlement for the
feature, and a gate placement (upgrade modal) for free users. Refer to plans and
entitlements by their unique_handle. Run
`npx revturbine validate ./revturbine.playbook.json` until it reports no errors
or warnings.
4. Replace the hard-coded check with , keeping
the existing UI as the gated children and letting the placement render the upsell.
5. Do NOT touch any billing-critical or server-side enforcement path. Leave those
checks in place — the client Gate is UI only.
6. Run the type checker, then explain the diff: what moved into config, what stayed
in code, and why the server-side check (if any) was left alone.
```
**Tip — Why 'one non-critical feature':**
The prompt deliberately scopes the agent to a single, safe feature and forbids touching enforcement paths. Monetization changes ship as config, so the first change should be reversible and low-stakes. See [Client vs Server Enforcement](/revturbine-sdk/concepts/enforcement/).
## New app: build RevTurbine in from the start
```text
You're building a new app with RevTurbine (@revturbine/sdk) handling its plans,
entitlements, usage limits and upgrade prompts.
1. Scaffold the app with its framework first (e.g. create-next-app), then run
`npm create revturbine@latest` inside it. It installs the SDK, pins the CLI,
writes a starter revturbine.playbook.json and installs the RevTurbine agent
skills. Follow the revturbine-start-here skill.
2. Put monetization in the Playbook, not in code: every plan, entitlement, usage
limit and upgrade or sign-in prompt. No plan names, prices or limit numbers in
app code. Build on the starter Playbook and refer to plans and entitlements by
their unique_handle.
3. Run `npx revturbine validate ./revturbine.playbook.json` after every Playbook
change and fix every error and warning.
4. Start in local_only mode with the bundled Playbook. Use or can() for
access, for prompts, and update() to report usage as the running total
for the current period (your app keeps that count).
5. Re-check every paid or billable action on the server with
@revturbine/sdk/headless before it runs, and answer a denial with 402. Client
checks only drive the UI.
6. If people can use the app before signing in, give them a stable id your server
can see (for example a cookie) and pass it as user.id.
7. When the docs don't cover something you need, say so and ask. Don't guess.
8. Run `npx revturbine generate types ./revturbine.playbook.json --out
src/revturbine-handles.ts` and the type checker, then explain what lives in
the Playbook, what lives in code, and what's left for hosted mode.
```
**Tip — Why the Playbook comes first:**
Everything in the Playbook can change without a deploy once you move to hosted mode, so a new app should never hard-code what RevTurbine decides. The validator catches a reference that would silently grant nothing. See [Runtime Modes](/revturbine-sdk/guides/runtime-modes/) and [Client vs Server Enforcement](/revturbine-sdk/concepts/enforcement/).
## Automate the config loop with the CLI
Everything RevTurbine decides lives in the Playbook, and the CLI (`@revturbine/cli`, command `revturbine`) manages the Playbook lifecycle from the terminal — so a customer's own agent can enhance monetization on a cadence with deterministic guardrails. Anything you can do in the dashboard, you can do through the CLI.
A recurring loop looks like:
```bash
# 1. Your agent proposes a config change (new segment, adjusted limit, new copy).
# 2. Validate it against the real schema before anything ships.
revturbine validate ./revturbine.playbook.json
# 3. Upload it as the open draft, then preview and evaluate that draft.
revturbine upload ./revturbine.playbook.json
revturbine preview
revturbine evaluate --draft --user ./user-context.json --entitlement data_export
# 4. Launch it through the review lifecycle.
revturbine launch --draft
```
The guardrails are the point: the agent proposes, the CLI **validates against its pinned schema** and lets you **preview the impact** before launch, and the draft becomes a Release through the same guarded lifecycle a human uses. Your app code never changes — the agent only ever edits the Playbook.
**Note — Command surface:**
The exact command set, selectors, and flags live in the [CLI reference](/revturbine-sdk/reference/cli/). Treat `revturbine --help` from the project-pinned CLI as authoritative.
## Related
- [Replace Hard-Coded Plan Checks](/revturbine-sdk/guides/migrate-plan-checks/) — the before/after the agent produces
- [Beyond Gating](/revturbine-sdk/guides/beyond-gating/) — what the config can express
- [Client vs Server Enforcement](/revturbine-sdk/concepts/enforcement/) — the paths the agent must not touch
# Custom CTA Actions and Prices
> Keep authored CTA configuration, resolved actions, and display price units distinct.
The authored Studio CTA shape uses `label`, `path`, and optional string-valued
`config`. The selected action resolves into a placement's `cta_path`. Do not
invent a nested `config.ui_path` object to select a custom action.
```json title="A Studio CTA fragment, not a complete Playbook"
{
"label": "Open account settings",
"path": "open_account_settings",
"config": { "tab": "billing" }
}
```
For a custom/unmapped path, the resolver carries the action name into the
resolved path's `type` and preserves the configured parameters. The SDK's CTA
registry normalizes those parameters for the registered action. Register your
resolver under the same action name. A label is presentation text, not a
dispatch key.
The renderer reads normalized `cta_path` and accepts `ui_path` as a compatibility
alias. Those output fields are distinct from an authored Studio CTA's
`path`/`config` and from a payload's reference to `content_ui_paths`. Follow the
[CTA action guide](/revturbine-sdk/guides/placements/#cta-actions) for resolver registration.
The resolver performs your application's navigation or backend call; authoring
a CTA does not implement that action automatically.
## Display prices use currency minor units
Supply `price_amount` using the currency's minor-unit convention. For USD,
`4900` displays as $49.00; it is not $4,900. Other currencies can have different
fraction digits, so do not unconditionally divide every currency by 100.
The schema accepts a nonnegative number; that structural validation alone does
not prove the amount uses the right currency units.
Playbook prices are display metadata. Stripe or your billing backend determines
what is actually charged. Pass a trusted price identifier into checkout and
verify it server-side rather than charging an amount from a browser-rendered
card. See [source of truth](/revturbine-sdk/concepts/source-of-truth/).
Test the resolved action type and parameters, missing-resolver behavior, a
successful action, a rejected backend action, and price formatting for each
currency your application supports.
# Custom Slot Types
> Register custom placement components beyond the built-in slots.
The SDK ships with 11 built-in slot components (banner, modal, toast, etc.). When those don't fit your use case, you can register custom slot types.
## Built-In Slot Types
| ID | Component | Component Type |
|---|---|---|
| `banner` | `BannerSlot` | `banner` |
| `modal` | `ModalSlot` | `modal` |
| `toast` | `ToastSlot` | `toast` |
| `inline_embed` | `InlineEmbedSlot` | `inline_embed` |
| `button` | `ButtonSlot` | `button` |
| `quota_meter` | `QuotaMeterSlot` | `quota_meter` |
| `full_page` | `FullPageSlot` | `full_page` |
| `cli` | `CliSlot` | `cli` |
| `credit_balance` | `CreditBalanceSlot` | `credit_balance` |
| `tooltip` | `TooltipSlot` | `tooltip` |
| `agent_connector` | `AgentConnectorSlot` | `agent_connector` |
:::tip[Visual gallery]
Every built-in slot has a co-located `.stories.tsx` file in `web-sdk/placements/slots/`.
Run `pnpm storybook` in the SDK repo to preview all variants, or try the
[Playground](/revturbine-sdk/playground/) for live Sandpack demos.
:::
## Registering a Custom Slot
Create a `PlacementSlotType` definition and register it:
```tsx
import { PlacementTypeRegistry, useRevTurbineTheme } from '@revturbine/sdk';
import type { PlacementSlotProps } from '@revturbine/sdk';
// 1. Define the component
function FeedbackWidget({ content, onDismiss, onCtaClick, exposureRef }: PlacementSlotProps) {
const theme = useRevTurbineTheme();
return (
{content?.body}
);
}
// 2. Register it
const registry = new PlacementTypeRegistry();
registry.register({
id: 'custom:feedback-widget',
label: 'Feedback Widget',
description: 'In-app feedback collection prompt',
// One of the SDK's component types — see the table below.
componentType: 'in_page',
component: FeedbackWidget,
priority: 10,
// The template id lives on the decision's surface, not at the top level.
accepts: (output) => output.surface.template === 'feedback_v1',
});
```
### Registration Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string` | ✅ | Unique identifier (prefix with `custom:`) |
| `label` | `string` | ✅ | Human-readable label (shown in the dashboard) |
| `description` | `string` | ✅ | What this slot type does |
| `componentType` | `RevTurbineComponentType` | ✅ | Canonical component type |
| `component` | `ComponentType` | ✅ | React component to render |
| `accepts` | `(output) => boolean` | — | Predicate to match specific placements |
| `priority` | `number` | — | Higher = evaluated first (default: 0) |
| `defaultProps` | `Partial` | — | Default props merged into component |
## PlacementSlotProps Contract
All slot components (built-in and custom) receive the same props:
```ts
interface PlacementSlotProps {
placement: PlacementOutput;
content: ResolvedContent;
uiPath: PlacementUiPath;
promotion?: PlacementPromotion;
// Renderer callbacks
onCtaClick: () => void;
onSecondaryCtaClick?: () => void;
onDismiss: () => void;
onRemindLater?: () => void;
visible: boolean;
className?: string;
style?: React.CSSProperties;
exposureRef?: (element: Element | null) => void;
}
```
:::caution
Never narrow the `PlacementSlotProps` interface — only extend it. Custom slots should accept all props even if they don't use them.
:::
## Using the Registry
Pass your registry to ``:
```tsx
```
The SDK evaluates registered types by priority, calling `accepts()` on each until one matches.
## Theme Integration
Custom slots obtain the active theme with `useRevTurbineTheme()`:
```tsx
function CustomCard({ content, onCtaClick }: PlacementSlotProps) {
const theme = useRevTurbineTheme();
return (
{content?.header}
{content?.body}
);
}
```
## Next Steps
- [Theming Guide](/revturbine-sdk/guides/theming/) — customize colors, typography, and shapes
- [Component Gallery](/revturbine-sdk/components/) — built-in slot demos
- [API Reference](/revturbine-sdk/api/) — `PlacementTypeRegistry` and `PlacementSlotProps` docs
# Entitlements
> Feature gates, usage limits, credits, seats, and capability tiers — check access and enforce limits with the SDK.
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
| 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
**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`
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:
```tsx
import { useCan } from "@revturbine/sdk";
import { DemoApp } from "./DemoApp";
function ExportButton() {
const { can, limited } = useCan("data_export");
if (!can) return ;
return ;
}
// 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 (
);
}
```
### Full surface — `useEntitlement`
For the complete reactive result (`allowed` / `denied` / `limited` / `isLoading` / `error` / `gatedPlacement` / `recheck`), use `useEntitlement`:
```tsx
import { useEntitlement } from '@revturbine/sdk';
function ExportButton() {
const { allowed, denied, isLoading } = useEntitlement({
handle: 'data_export',
});
if (isLoading) return ;
if (denied) return ;
return ;
}
```
### Declarative 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:
```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 (
);
}
```
:::caution[`` must render inside a client boundary]
In App-Router apps, `` (and every SDK component/hook) must be used **inside a `'use client'` component**. The SDK creates its React context at module scope, so importing it from a Server Component fails the build with `createContext is not a function`. Keep the page shell a Server Component if you like — but the subtree that renders `` needs the `'use client'` directive at its top. Server-side enforcement belongs to `checkEntitlement` on your API routes (see the client-vs-server section below), not to rendering `` on the server.
:::
**A `` 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
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](/revturbine-sdk/playground/#access-gates): [Branding gate](/revturbine-sdk/playground/#gate-inline) and [Brand Kit gate](/revturbine-sdk/playground/#gate-card).
**Data Export gate** — a blocking modal gate on a paid feature.
_Live example: /playground/#gate-modal_
### With Auto-Gate
When `autoGate: true`, the hook automatically resolves an upgrade placement if the entitlement is denied:
```tsx
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
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` |
```ts
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
```
## Entitlement Result
Every entitlement check returns an `EntitlementResult`:
```ts
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
Report usage to keep entitlement checks accurate:
```ts
// 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 Entitlement Handle Types
Generate a union from your Playbook so `can()` accepts only handles that exist:
```bash
npx revturbine generate types ./revturbine.playbook.json --out src/revturbine.generated.ts
```
```ts
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](/revturbine-sdk/reference/cli/#generate-typed-handles) for selectors and all
supported generated type families.
### Reading Usage
```tsx
import { useUsageSnapshot } from '@revturbine/sdk';
function UsagePanel() {
const { usage, refresh } = useUsageSnapshot();
return (
{Object.entries(usage).map(([handle, data]) => (
{handle}: {data.current} / {data.limit ?? '∞'}
))}
);
}
```
## Patterns by Entitlement Type
### Feature Gate
```tsx
const { allowed } = useEntitlement({ handle: 'brand_kit' });
// allowed: true/false
```
### Usage Limit
```tsx
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
```
### Credits
```tsx
const { limited, result } = useEntitlement({ handle: 'render_credits' });
// result.status / result.allowed reflect access as credits run low
// result.remaining: the balance the evaluation used
```
### Seats
```tsx
const { denied, result } = useEntitlement({ handle: 'team_members' });
if (denied) {
// seat counts: read from useUsageSnapshot() (see "Reading Usage")
// Show "seat limit reached" prompt
}
```
### Capability Tier
```tsx
const { denied, result } = useEntitlement({ handle: 'advanced_analytics' });
if (denied) {
// result.current_tier: 'professional'
// Show "upgrade to Professional" prompt
}
```
## 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:
```ts
// 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](/revturbine-sdk/guides/error-handling/#entitlement-reason-codes) for the complete emitted set.
:::tip
In `local_only` mode, all entitlement checks resolve instantly from the cached Playbook — no network calls. A configured Playbook gives real allow/deny answers, so the fail-closed fallback only fires when there's genuinely no config to evaluate against.
:::
:::caution[`price_per_unit` and `rate_limit` are not gates]
Those types carry metering and rate-limit parameters; they do not grant access.
Calling `can()` on either returns `{ status: 'denied', allowed: false,
reason: 'not_access_entitlement_kind' }` and reports a developer warning.
Use an access entitlement to gate a feature, and read the parameters for your
application's metering or rate limiter. Display tokens continue to expose the
metadata.
:::
## Parameter entitlements
`price_per_unit` and `rate_limit` configure application behavior; they are not
always-allowed feature gates. `can()` returns `allowed: false`, `status: 'denied'`,
and `reason: 'not_access_entitlement_kind'` for these kinds. Do not reinterpret
that denial as a grant or use a parameter handle in `Gate`.
Use a separate access entitlement for authorization. The application reads
parameter metadata from its admitted Playbook/provider data and implements the
meter or rate limiter itself. Resolve the appropriate rule for the current
plan/target; do not simply take the first rule or infer unlimited access from an
absent value. Display tokens can expose metadata for presentation, but a display
token is not enforcement. This version does not provide a new universal
`getParameter()` or `getRateLimit()` API.
For example, inspect the configured rules through the initialized SDK's
`session.sdk.getPlaybook()` accessor. This lists metadata for a handle; it deliberately
does **not** select an effective rule for a particular user:
```ts
import { initRevTurbine } from '@revturbine/sdk';
function inspectParameterRules(
session: Awaited>,
entitlementHandle: string,
) {
const playbook = session.sdk.getPlaybook();
if (!playbook) throw new Error('No admitted Playbook is available');
return playbook.entitlement_rules
.filter((rule) => rule.entitlement_id === entitlementHandle)
.map((rule) => ({
targets: rule.targets,
segment_ids: rule.segment_ids,
rate_value: rule.rate_value,
period_scope: rule.period_scope,
amount_cents: rule.amount_cents,
currency: rule.currency,
}));
}
```
Keep absent values absent. A rate value needs its period, and a price needs its
currency; do not treat either as an access grant. Use this inspection for
configuration/debugging or presentation. Application-owned enforcement must
apply the intended plan, add-on, and segment policy rather than taking the first
row returned here. If the admitted artifact does not include this metadata,
use your application's authoritative configuration source instead of inventing
a default.
For mirrored access grants, `UserContext.entitlements` and `entitlementMerge`
control how application facts combine with Playbook evaluation. The default
gives application mirrors precedence. A result with `entitlement_mirrored`
therefore need not have a Playbook `rule_handle`. See
[SDK upgrades](/revturbine-sdk/guides/sdk-upgrades/) before copying old rule-only assertions.
## Next Steps
- [Placements Guide](/revturbine-sdk/guides/placements/) — render upgrade prompts for denied entitlements
- [Events & Analytics](/revturbine-sdk/guides/events/) — track entitlement check outcomes
- [Error Handling](/revturbine-sdk/guides/error-handling/) — fail-closed checks and recovery patterns
- [Try it live → Data Export Gate](/revturbine-sdk/guides/entitlements/#gate-modal)
# Error Handling
> SDK error model — what a placement does when it can't resolve, what an entitlement check returns when it can't justify a grant, the provider failure cascade, and graceful degradation patterns.
The SDK never throws into your app and never blocks your render. But *placements* and *entitlement checks* degrade in opposite directions, on purpose: a placement that can't resolve **renders nothing**, while an entitlement check that can't resolve **denies**.
## Two degradation modes
**A placement that can't resolve renders nothing** (or your configured fallback), so it can never take your product down.
**Entitlement checks are [fail-closed](/revturbine-sdk/guides/entitlements/#fail-closed-semantics).** If a check can't produce an affirmative grant, it returns `{ status: 'denied', allowed: false }` rather than granting access. The Playbook is cached and persisted locally, so a configured runtime evaluates real allow/deny answers with no network round-trip; the failure fallback only fires when the SDK has *no basis to answer at all* — no config, no cache, nothing reachable — which is exactly where denying is the safe, non-leaking default. The `reason` code is preserved so you can still tell an infrastructure failure apart from a real denial.
| API failure scenario | SDK behavior |
|---|---|
| Launched Playbook not fetched yet (hosted mode) | Placements return `visible: false` |
| Entitlement check fails | Returns `{ status: 'denied', allowed: false, reason: 'config_unavailable' }` |
| Config fetch fails | Falls back to cached Playbook; with none, entitlement checks deny |
| Event delivery fails | Events are buffered and retried silently |
## Error Surface
### Hook Level
Hooks expose errors as strings — they never throw:
```tsx
const { error, isLoading } = usePlacement({ placement: { name: 'hero_banner' } });
const { error: entError } = useEntitlement({ handle: 'data_export' });
if (error) {
// Non-critical — log and continue
console.warn('Placement error:', error);
}
```
### Controller Level
Headless controllers surface errors through state:
```ts
const ctrl = new PlacementController(sdk, config);
await ctrl.load();
if (ctrl.state.error) {
console.warn('Controller error:', ctrl.state.error);
}
```
### SDK Level
Most SDK methods fail silently and return sensible defaults:
```ts
// Denies ({ allowed: false }) when it can't justify a grant
await sdk.can('data_export');
// Silently drops event on delivery failure
await sdk.track('page_viewed');
// Returns null decision on failure
await sdk.getPlacement({ slotId: 'banner' });
```
## Provider Failure Behavior
When the provider chain is exhausted (all providers failed), slots behave according to `providerFailureSlotBehavior`:
```tsx
```
| Value | Behavior |
|---|---|
| `'invisible'` | Slots render nothing — your layout stays intact |
| `'placeholder'` | Slots render fallback placeholder content |
### Recommendation
Use `'invisible'` (default) for production. Your app should work fine without any placement rendering.
Use `'placeholder'` during development to visually verify that slots are wired correctly even when a provider fails.
## Reason Codes
Placement decisions include `reasonCodes` that explain why a placement was hidden or shown. This is the complete set the SDK emits, generated from the public contract in `tests/reason-contract.json`.
An interaction-flush failure and a client-side cap-enforcement fallback are each documented in their own place on the [Error Codes reference page](/revturbine-sdk/reference/errors/) instead of the table below, since neither is part of that contract.
| Code | Meaning |
|---|---|
| `config_unavailable` | The Playbook could not be fetched, or a fetch is still in flight — the SDK has no config to decide against yet. |
| `entitlement_gate_unmet` | The candidate's entitlement-gate trigger did not match the user's current entitlement state. |
| `entitlement_not_denied` | The Access Gate placement's entitlement is not denied or limited for this user, so the gate does not fire. |
| `gate_outside_access_gate` | An Access Gate placement was requested from a slot that is not an Access Gate slot; gates render only in gate slots. |
| `no_eligible_candidate` | No candidate placement survived eligibility filtering for this slot. |
| `no_gate_for_entitlement` | The Access Gate slot asks for an entitlement that no gate placement is authored for, so the slot shows its access-denied placeholder. |
| `no_resolver_configured` | No placement resolver is configured, so the SDK has nothing to evaluate against. |
| `placement_not_found` | No candidate output matched the requested placement id or name. |
| `placement_not_registered` | The payload targets a placement handle the app never registered with the SDK. |
| `placement_retired` | The candidate's rule is hidden — for example, superseded by a conversion — per impression history. |
| `plan_target_mismatch` | The user's plan or billing period doesn't match the candidate's plan targeting. |
| `qualifier_trigger_unmet` | The candidate's qualifier trigger condition was not satisfied for the user's plan or category. |
| `sdk_disabled_provider_failure` | The SDK disabled itself after its configured providers failed, so it fails closed rather than risk showing something wrong. |
| `segment_target_mismatch` | The user doesn't belong to any of the candidate's target segments. |
| `suppressed_by_dismiss_cooldown` | The user dismissed this placement and its cooldown window has not elapsed. |
| `suppressed_by_payload_cap_day` | The payload's per-day impression cap has been reached. |
| `suppressed_by_payload_cap_lifetime` | The payload's lifetime impression cap has been reached. |
| `suppressed_by_payload_cap_month` | The payload's per-month impression cap has been reached. |
| `suppressed_by_payload_cap_session` | The payload's per-session impression cap has been reached. |
| `suppressed_by_payload_cap_week` | The payload's per-week impression cap has been reached. |
| `suppressed_by_payload_cooldown` | The payload is still inside its configured cooldown window since it was last shown. |
| `suppressed_by_presentation_cap` | Every eligible candidate is over an overall presentation cap rule from Placement Settings. |
| `suppressed_by_system_cooldown` | Every eligible candidate is a discretionary nudge inside the session cooldown since the last one shown. |
| `suppressed_until_remind_window` | The user chose "remind me later" and that reminder window has not elapsed yet. |
| `threshold_trigger_unmet` | The candidate's threshold trigger did not match the user's current entitlement usage. |
| `trial_trigger_unmet` | The candidate's trial trigger did not match the user's trial state. |
| `unknown_trigger_type` | The candidate uses a trigger type this SDK does not recognize, so it is not shown. |
### Inspecting Reason Codes
```tsx
const { decision } = usePlacement({ placement: { name: 'hero_banner' } });
if (decision?.reasonCodes?.includes('suppressed_by_payload_cap_day')) {
// User has hit this payload's daily impression cap
}
```
## Entitlement Reason Codes
Entitlement checks are **fail-closed**. When the SDK cannot produce an
affirmative grant — the Playbook never arrived, no rule grants the entitlement
to the user's plan, the SDK was disabled — it returns
`{ status: 'denied', allowed: false }` with a `reason` naming the cause. It
never invents a grant. Treat `allowed` as the answer and `reason` as the
explanation; the codes below are the complete set the SDK emits.
### Denied because a rule said so
| Reason | Meaning |
|---|---|
| `no_matching_entitlement_rule` | No rule grants this entitlement to the user's plan. Plan targeting is explicit — an entitlement with no enabling rule for that plan is not granted. |
| `feature_not_enabled_for_plan` | A matching `feature` rule has `enabled: false`. |
| `usage_limit_reached` | The user is at or over a `usage_limit` rule's limit. |
| `credit_balance_exhausted` | The user is at or over a `credits` rule's allowance. |
`usage_limit_reached` and `credit_balance_exhausted` carry a suffix reflecting
the rule's `enforcement` mode:
| Suffix | `enforcement` | Outcome |
|---|---|---|
| *(none)* | `hard_block` | `denied` — hard stop |
| *(none)* | unset | `limited`, **not** allowed |
| `_block_with_upsell` | `block_with_upsell` | `denied` — render the upsell placement |
| `_degraded` | `degrade` | `limited` but **allowed** (throttled, not blocked) |
| `_overage` | `allow_overage` | `allowed` — metered overage |
Two modes produce no suffix and they are **not** equivalent: `hard_block`
denies, while leaving `enforcement` unset returns `limited` and does not allow.
Branch on `allowed`, never on the presence of a suffix.
:::note[Renamed in schema v15]
`block_with_upsell` was previously called `soft_block`. The name was misleading —
`soft_` reads as permissive, but the mode **denies**; it just tells you to render
an upsell rather than a hard stop. Behaviour is unchanged, and the old spelling
is still accepted when it appears in an already-compiled Playbook, so existing
payloads keep deciding exactly as before. The reason code it produces is now
`..._block_with_upsell` in every case.
:::
### Denied because the SDK could not decide
| Reason | Meaning |
|---|---|
| `config_unavailable` | The launched Playbook could not be fetched (hosted mode). Not a rule decision — an infrastructure failure. |
| `entitlement_not_in_playbook` | Local mode with no Playbook and no cached result: nothing describes this entitlement, so there is no basis to grant it. |
| `sdk_disabled_provider_failure` | The SDK disabled itself after a provider failure. |
### Allowed with a reason
| Reason | Meaning |
|---|---|
| `granted_by_reverse_trial` | Granted by an active reverse trial rather than by the user's plan. |
:::note[Renamed in 0.3.0]
`local_runtime_default_allow` is now `entitlement_not_in_playbook`. The old name
stated a verdict the result does not have — it denies — and was also emitted by
the headless runtime on an *allowed* result, so one code meant opposite things
on two surfaces. There is no deprecated alias; update any `switch` on the old
string.
:::
:::caution[Two entitlement types are not gates]
`price_per_unit` and `rate_limit` are parameter metadata, not access gates.
Checking them as access entitlements returns a denied result with
`not_access_entitlement_kind`. Read their configured values for presentation
or application-owned enforcement; use a boolean or usage entitlement for an
access decision. See [parameter entitlements](/revturbine-sdk/guides/entitlements/) for the
boundary between metadata and access checks.
:::
## Retry Behavior
| Operation | Retry Strategy |
|---|---|
| Placement resolution | No auto-retry. Call `refresh()` to retry manually. |
| Entitlement check | No auto-retry. Call `recheck()` to retry manually. |
| Event delivery | Auto-buffered and retried on next batch interval. |
| Config fetch | Keeps the last-known-good Playbook; hosted mode re-checks for a new release about once a minute. |
### Manual Retry
```tsx
const { refresh, error } = usePlacement({ placement: { name: 'hero_banner' } });
const { recheck, error: entError } = useEntitlement({ handle: 'data_export' });
// Retry after transient failure
if (error) await refresh();
if (entError) await recheck();
```
## Design for the empty state
Structure your components so the baseline UI never depends on a placement being present:
```tsx
function Dashboard() {
return (
{/* Baseline UX — always works */}
{/* Placement — renders nothing when no placement matches */}
);
}
```
When no placement matches the current user, `` renders nothing and the baseline dashboard is unchanged. Targeting, frequency caps and cooldowns can all produce that outcome, so the layout must hold up without it.
## Debugging Errors
Enable verbose logging to diagnose issues:
```ts
// In browser console
localStorage.setItem('revturbine:debug', 'true');
```
This logs decision resolution, provider chain evaluation, and error details to the browser console.
## Next Steps
- [Configuration Reference](/revturbine-sdk/reference/configuration/) — error-related configuration options
- [Error Codes Reference](/revturbine-sdk/reference/errors/) — enumerated error codes
# Events & Analytics
> Track user interactions, engagement, and placement lifecycle — automatically, declaratively, or by hand — with consent, redaction, and a typed event pipeline.
RevTurbine reports what it did — a placement was shown, a CTA was clicked — and, when you opt in, what the user was doing around it. Placement lifecycle is tracked automatically; everything else is opt-in, additive, and privacy-safe by default. An integration that sets no telemetry options behaves exactly as before.
Every event carries a sortable, unique `event_id`, is PII-redacted before it leaves the browser, and is batched and delivered best-effort (a failed send never throws into your app, and a retry resends the identical row so it can't duplicate).
## Automatic tracking
Placement components and headless controllers instrument the placement lifecycle for you — you don't wire these up:
| Event | When |
|---|---|
| `impression` | A placement is presented (see [viewport exposure](#viewport-exposure)) |
| `placement_interaction` | The canonical interaction event, discriminated by `interaction_type`: `dismiss` · `remind_me_later` · `cta_clicked` · `cta_completed` · `suppress` |
| `placement_rendered` / `placement_exposed` | The visual root rendered / entered the viewport (when you attach `exposureRef`) |
`placement_interaction` is the one placement interaction event; there are no separate dismiss/snooze/convert events.
## Billing facts and application events
With Stripe connected and identity mapping configured, RevTurbine records
subscription, trial, renewal, and payment facts from Stripe webhooks. Your app
does not need to emit a second billing fact merely because a checkout page
returned successfully. A browser return URL is not authoritative payment proof.
The billing event names are `subscription_started`, `subscription_renewed`,
`subscription_expanded`, `subscription_canceled`, `trial_started`,
`trial_converted`, `trial_expired`, `payment_succeeded`, and `payment_failed`.
For example, record your app's checkout-button interaction as an application
event; let the webhook integration report the resulting `payment_succeeded`
and subscription fact. Do not send a duplicate `subscription_started` from
the browser's checkout-success page.
Use application events for what your app knows: feature use, an onboarding step,
or an interaction that is not a Stripe billing occurrence. `track()` is the
generic lane and namespaces collisions with platform event names. The typed
`emitPlatformEvent` API is the platform-event lane and requires that event's
payload contract; renaming an arbitrary `track()` event does not make it a
canonical billing fact.
`billing_ref` is a producer-owned stable billing-fact identity, not the SDK
transport `event_id`. Preserve it when retrying the same fact. Do not reuse it
for unrelated occurrences or invent a new one on each retry. Newer managed
billing profiles distinguish occurrence identity, source revision, delivery ID,
and the legacy billing reference; a legacy reference alone does not identify
every repeated subscription transition. Prefer the Stripe integration for
Stripe-owned facts rather than reproducing its accounting logic in the browser.
The SDK does not emit the removed `growth_signal_observed` event. Use the current
documented event/trigger contracts and keep custom names in the application lane.
For identity mapping and webhook setup, see [Billing & Stripe](/revturbine-sdk/guides/billing/).
## Custom events
In React, `useTrack` returns a `track(name, data?, options?)` bound to the surrounding [scope](#scoping-with-telemetryscope). Reserved names (canonical identity + provenance fields) are dropped so you can't overwrite a system value; without a provider, `track` is a safe no-op.
```tsx
import { useTrack } from '@revturbine/sdk';
function AdvancedFilters() {
const track = useTrack();
return (
);
}
```
Headless / server code uses `sdk.track`:
```ts
await sdk.track('checkout_completed', { plan: 'professional', revenue: 99 });
```
### Track options
| Option | Meaning |
|---|---|
| `area` / `action` | Scope labels; merge outer → inner → invocation |
| `purpose` | **Advisory** intent (e.g. `'engagement'`). A server-side allowlist — not this value — decides whether an event feeds scoring |
| `once` | Emit at most once for the hook's lifetime |
| `dedupeKey` | Emit at most once per key |
| `immediate` | Send now instead of batching |
## Scoping with TelemetryScope
`TelemetryScope` sets ambient `area` / `action` / `purpose` for its descendants. It renders its children unchanged — no wrapper element, no added `role` — and nested scopes merge inner-over-outer, with per-event options winning.
```tsx
import { TelemetryScope, useTrack } from '@revturbine/sdk';
{/* every useTrack() here inherits area="billing" */}
```
## Declarative tracking
| Component / hook | Fires |
|---|---|
| `TrackOnView` | Its event **once** when the element scrolls into view (falls back to render when `IntersectionObserver` is unavailable). Strict-Mode safe |
| `EngagementArea` | A one-shot `engagement_view`, accrues `engagement_dwell` (`dwell_ms`) only while onscreen **and** the tab is visible, and bubbles descendant clicks as `engagement_interaction` |
| `Track` (`asChild`) | Composes onto a child's `onClick` (no wrapper) — telemetry fires only if the child didn't `preventDefault`; leaves the child's accessible name and disabled state unchanged |
| `useTelemetryProps` | Telemetry props to spread onto a primitive that can't take a wrapper |
```tsx
import { TrackOnView, Track } from '@revturbine/sdk';
export const Examples = () => (
<>
>
);
```
## Tracked & gated actions
`useTrackedAction` wraps an async action, emitting `${name}_started` then `${name}_completed` or `${name}_failed` (with a non-sensitive `error_category` — never the raw message). It preserves the return value and re-throws, so it's a drop-in wrapper.
```tsx
import { useTrackedAction } from '@revturbine/sdk';
const { run, isRunning } = useTrackedAction('export_pdf', () => exportPdf());
```
`useGatedAction` is the React analog of `rt.gate(action, fn)`: it delegates to the SDK so it emits the same `gate_attempted` → `gate_allowed` / `gate_denied` sequence (not a fork), running the action wrapped in tracked-action telemetry only when allowed. A passively-rendered `` instead emits `gate_evaluated`.
## Consent
`telemetry.consent` gates event **creation** ahead of every destination:
| Value | Effect |
|---|---|
| `granted` (default) | Emit normally |
| `denied` | No event is created — nothing reaches ingest, consumers, or integrations |
| `pending` | Dropped in MVP; never persisted |
Change it at runtime with no remount:
```ts
sdk.setTelemetryConsent('denied'); // stop; 'granted' resumes on the next event
```
The keyless anonymous SDK-init beacon (`anonymousTelemetry`) is controlled separately and is unaffected by consent.
## Annotated DOM capture
Opt into hand-authored capture with `domCapture` on the provider. One delegated listener per event reads **only** allowlisted `data-rt-*` attributes — never element text, input values, `href`s, or selectors — and always skips password / file / hidden / payment-autocomplete controls. A `data-rt-no-capture` ancestor opts an element and its subtree out. Collected values pass through the same PII redactor.
```tsx
```
## Viewport exposure
`placementExposure` controls when the presentation-writing `impression` fires:
| Mode | Impression fires |
|---|---|
| `legacy_resolution` (default) | At decision resolution — exactly as today |
| `render` | When the placement renders |
| `viewport` | When it scrolls into the viewport; falls back to resolution (`exposure_basis: 'render_fallback'`) when `IntersectionObserver` is unavailable |
`usePlacement` returns `exposureRef`; slot components receive the same ref through the additive `exposureRef` prop on `PlacementSlotProps`. The renderer threads it to every slot automatically, and a **custom component** must attach it to its true visual root. The built-in slots do not consume the ref, so their viewport mode uses the render fallback.
:::caution[Metric-migration note]
Only `placementExposure: 'viewport'` **with `IntersectionObserver` present** moves the `placement_presentations` denominator to viewport-qualified presentations — a deliberate metric-definition change. CTR and conversion-rate for a placement switched to `viewport` reflect *seen* presentations from that point forward and no longer compare against pre-switch history. `legacy_resolution` (the default) leaves the denominator — and every existing dashboard — unchanged.
:::
## Third-party analytics
Forward the same semantic events to a third-party tool without adding a second transport. `createPostHogIntegration` adds an opt-in identity lifecycle (all sync flags default `false`, so it behaves like the capture-only provider until you turn one on); PostHog is injected, never bundled.
```ts
import posthog from 'posthog-js';
import { createPostHogIntegration } from '@revturbine/sdk';
initRevTurbine({
domainProviders: [createPostHogIntegration({ posthog, syncIdentity: true })],
// ...
});
```
A throwing integration never blocks RevTurbine ingest, and an ingest failure never blocks a configured mirror.
## Trigger events
Trigger events are semantic lifecycle signals the SDK uses to re-evaluate targeting. Emit one and all active placements re-evaluate:
```ts
await sdk.emitTrigger('trial_expiring', { days_remaining: 2 });
await sdk.emitTrigger('usage_limit_approaching', {
entitlement_handle: 'api_calls', usage_percent: 95,
});
```
Built-in triggers: `trial_midpoint` · `trial_expiring` · `trial_expired` · `usage_limit_approaching` · `usage_limit_reached` · `credit_balance_low` · `seat_limit_reached` · `feature_gated` · `cancel_intent` · `payment_failed` · `auto_renewal_reminder` · `onboarding_complete` · `invite_teammate_prompt` · `referral_offer` · `plan_upgrade_nudge`. The compiler rejects any other name.
:::tip
Triggers are the primary way to activate message-type placements (toasts, banners, modals). Dashboard targeting rules reference them to decide when to show a placement.
:::
## Marking test traffic
Set `test: true` at init when an SDK instance generates **test traffic** — a staging deploy, an integration test, a QA walkthrough:
```ts
import { initRevTurbine } from '@revturbine/sdk';
const rt = await initRevTurbine({
publicKey: process.env.NEXT_PUBLIC_REVTURBINE_PUBLIC_KEY!, // public key — safe in the browser
test: true, // this instance's events are test traffic
});
```
Every emitted event (clickstream and treatment interactions) is then stamped `test: true`, and RevTurbine analytics **exclude those events by default** from every metric, rollup, and denominator. An `include_test=1` toggle on the analytics pipes reveals them when you want to inspect test traffic.
This is a deliberate, code-passed decision: pass it explicitly from your own configuration. The SDK never infers it from the environment — no `NODE_ENV` sniffing. Whether traffic is test traffic is your call, not a deployment artifact. Omitted or `false`, events are emitted unchanged (byte-identical to earlier SDK versions).
:::note[Not the same as placement "Test Mode"]
Placement **Test Mode** (`off / test_users / all_traffic` on a placement's settings) controls **who is eligible to see** a placement during SDK evaluation and carries its own decision marker. The `test` init option is a *caller-declared* marker on **emitted events**. A production user in a placement's test audience is not test traffic; a staging deploy emitting real-looking events is. The two are independent.
:::
## Silent-failure diagnostics
When a placement or entitlement can't resolve for **infrastructure** reasons, the SDK reports it instead of failing silently — a `resolution_failure` diagnostic on the anonymous meta channel:
| Reason | Emitted when |
|---|---|
| `placement_not_registered` | A decision is requested for a placement the app never registered — the classic "my payload renders nothing, silently" |
| `config_unavailable` | The Playbook never arrived, so a placement fell back or an entitlement check denied without a rule saying no |
| `sdk_disabled_provider_failure` | The provider chain failed: entitlement checks deny with this reason and slots follow `providerFailureSlotBehavior` |
Diagnostics carry **only** author-defined handles (placement, slot, plan, entitlement) and closed reason codes — never user context, free text, or your tenant identifier (deployments are counted through a one-way hash). They're deduplicated per session and capped, fire from keyed production installs as well as keyless ones, and are silenced by **either** opt-out:
```ts
initRevTurbine({ analytics: false }); // silences diagnostics too
initRevTurbine({ anonymousTelemetry: false }); // likewise
```
Rule-based denials and local mode's default behavior are not failures and never emit.
## Your taxonomy in analytics
Events that carry the `area` / `action` / `purpose` taxonomy (from `TelemetryScope`, `useTrack` options, `EngagementArea`, and tracked actions) are additionally stored with the taxonomy as first-class analytics columns — so engagement queries group by area and action directly, with no JSON unpacking and no configuration on your side. Untagged events are unaffected.
## Privacy & delivery
- **Redaction.** Email- and card-shaped values in properties and traits are scrubbed to `[REDACTED]` before any destination — one sanitized envelope, every mirror. Best-effort, not a guarantee: don't put PII in event data.
- **Advisory purpose, server-owned scoring.** A client-declared `purpose` never grants engagement eligibility; a static server-side allowlist decides which event names feed scoring, regardless of purpose.
- **Delivery.** Events batch and flush on size, interval, or page-unload. A transient failure is retried with the byte-identical row, so storage collapses it to one; a permanent failure is dropped silently. In `local_only` mode nothing is sent at all — the right mode for demos and examples.
## Next steps
- [Placements Guide](/revturbine-sdk/guides/placements/) — placement lifecycle and interactions
- [Runtime Modes](/revturbine-sdk/guides/runtime-modes/) — local vs hosted mode
- [Error Handling](/revturbine-sdk/guides/error-handling/) — graceful event-delivery failures
# Run an Experiment
> Let RevTurbine assign variants natively, or bring the assignment your GrowthBook or Optimizely client already made — either way, decisions, placements and telemetry read the same assignment.
RevTurbine can allocate a native experiment or consume the assignment made by
the experimentation SDK already running in your application. For an external
assignment, your application owns and initializes the vendor client. A small
RevTurbine domain provider maps the vendor's keys to canonical RevTurbine
experiment and variant handles.
The normalized assignment becomes part of an ephemeral effective user context.
Segments, entitlements, placements, direct variant reads, and telemetry all use
that same resolved assignment. RevTurbine never persists the vendor client or
its credentials and never mutates the user object supplied by your app.
**Note — Client providers and data connections are different:**
Configure assignment adapters in application code. **Experiment Data
Connections** in the RevTurbine dashboard are server-side connections for
evidence or analysis; they do not install or configure a browser assignment
SDK.
## The flow
1. In the experiment editor, choose the assignment source: **native** (RevTurbine assigns) or **Customer SDK provider** (your vendor SDK assigns).
2. Native: register the built-in bucketer (next section). Vendor: initialize the vendor SDK as usual and create a RevTurbine adapter with explicit experiment and variant mappings.
3. Pass the provider in `domainProviders` when initializing RevTurbine.
4. Use `getExperimentVariant()` when application code needs the selected variant directly; RevTurbine decisions and telemetry already use the same assignment.
## Native allocation
No vendor SDK needed. Register the built-in bucketer as a domain provider. It assigns deterministically per subject, so the same user lands in the same arm on every load, and everything downstream — targeting, placements, `getExperimentVariant()`, telemetry — reads that one assignment.
```ts
import { initRevTurbine, createBasicExperimentProvider } from '@revturbine/sdk';
const rt = initRevTurbine({
tenantId: 'tenant_abc',
apiKey: 'rtk_…', // publishable ingest key
endpoint: 'https://revturbine.com/app',
mode: 'snippet',
domainProviders: [
createBasicExperimentProvider({
subject: () => currentUserId, // re-read on every resolve
experiments: {
pricing_test: { variants: { control: 1, treatment: 1 } }, // relative weights
onboarding_copy: { variants: ['a', 'b'], exposure: 0.2 }, // 20% enrolled
},
}),
],
});
```
A subject outside `exposure` is **not enrolled** — the experiment is simply absent from their assignment, which is different from being in control. No stable subject, no assignment: bucketing an anonymous id would hand the same person a different arm on the next load.
An allocation authored in the dashboard — an Experiment version with its handle, traffic allocation and weighted variants — drives `createNativeExperimentAssignmentProvider` instead, which also emits the assignment fact that ties results back to that version. See the API reference for its options.
## GrowthBook
The GrowthBook adapter accepts an initialized customer-owned client. It is a
separate optional entry, so GrowthBook is not included when your application
imports only the base SDK.
```ts
import { GrowthBook } from '@growthbook/growthbook';
import { initRevTurbine } from '@revturbine/sdk';
import { createGrowthBookExperimentAssignmentProvider } from '@revturbine/sdk/growthbook';
const userId = 'user_123';
const growthbook = new GrowthBook({
attributes: { id: userId },
});
await growthbook.init({
apiHost: 'https://cdn.growthbook.io',
clientKey: 'sdk-...',
});
const experimentProvider = createGrowthBookExperimentAssignmentProvider({
client: growthbook,
bindings: [{
experimentHandle: 'pricing_test',
featureKey: 'pricing-variant',
allowedVariants: ['control', 'treatment'],
}],
});
const rt = initRevTurbine({
tenantId: 'tenant_abc',
publicKey: 'rtk_…', // public key — safe in the browser
user: { id: userId, plan_handle: 'free' },
domainProviders: [experimentProvider],
});
```
Keep the GrowthBook attributes current using the GrowthBook client. When the
user changes in RevTurbine, `identify()` invalidates the effective context and
resolves the provider for the new identity.
## Optimizely
The Optimizely adapter also accepts an initialized client and derives the
Optimizely user id from the current RevTurbine context by default.
```ts
import { createInstance } from '@optimizely/optimizely-sdk';
import { initRevTurbine } from '@revturbine/sdk';
import { createOptimizelyExperimentAssignmentProvider } from '@revturbine/sdk/optimizely';
const optimizely = createInstance({ sdkKey: 'your-optimizely-sdk-key' });
await optimizely.onReady();
const experimentProvider = createOptimizelyExperimentAssignmentProvider({
client: optimizely,
bindings: [{
experimentHandle: 'pricing_test',
experimentKey: 'pricing-experiment',
allowedVariants: ['control', 'treatment'],
}],
attributes: ({ userContext }) => ({
plan: userContext.plan_handle,
}),
});
const rt = initRevTurbine({
tenantId: 'tenant_abc',
publicKey: 'rtk_…', // public key — safe in the browser
user: { id: 'user_123', plan_handle: 'free' },
domainProviders: [experimentProvider],
});
```
Mappings are explicit: vendor keys do not leak into Playbooks. An allowed
variant list prevents a misspelled or newly introduced vendor value from being
silently treated as a valid RevTurbine variant.
## Read the selected variant
Use the structured selection when your application must render variant-specific
behavior itself:
```ts
// `sdk` is the SDK instance: `useRevTurbine().sdk` in React, `session.sdk` headless.
const selection = await sdk.getExperimentVariant('pricing_test');
if (selection.status === 'assigned') {
renderPricingVariant(selection.variantHandle);
} else {
// Missing, unavailable, conflicting, and unsupported assignments are never
// fabricated as "control". Keep the existing safe experience.
renderDefaultPricing();
}
```
The result distinguishes `assigned`, `not_assigned`, `unavailable`, and
`unsupported`. This lets you keep non-enrollment separate from vendor failure
and diagnose ownership or assignment conflicts without guessing.
For diagnostics, the same immutable context used by local evaluation is
available from `getEffectiveUserContext()`:
```ts
const effectiveContext = await sdk.getEffectiveUserContext();
console.log(effectiveContext.experiments?.pricing_test);
```
## Supply an assignment directly
An adapter is optional when your code has already normalized the assignment.
Use the canonical `experiments` map, not an arbitrary custom trait:
```ts
import { initRevTurbine } from '@revturbine/sdk';
const rt = await initRevTurbine({
publicKey: process.env.NEXT_PUBLIC_REVTURBINE_PUBLIC_KEY!, // public key — safe in the browser
user: {
id: 'user_123',
plan_handle: 'free',
experiments: { pricing_test: 'treatment' },
},
});
```
Direct and provider assignments coalesce when they agree. If they disagree,
RevTurbine reports an assignment conflict and does not invent a winner. Two
providers also cannot own the same experiment handle.
## Target decisions and measure outcomes
Create a Segment for the experiment in the RevTurbine dashboard (Targeting Studio) and reference it
from an entitlement rule or placement payload. The SDK resolves the provider
before evaluating the Playbook, so experiment-backed Segment membership and
the resulting placement or entitlement use the effective assignment.
Track the conversion normally:
```ts
await rt.track('upgrade_completed', {
plan: 'professional',
source: 'pricing_experiment',
});
```
The SDK stamps the effective experiment assignment on telemetry from the same
resolution used by direct selection and local decisions. It does not call the
vendor separately for each consumer.
## Migrating from custom traits
If an existing integration stores a variant under
`custom.pricing_experiment`, move it to either a client provider binding or
`experiments.pricing_test`. Update the Segment to use the canonical experiment
handle, validate the vendor's variant vocabulary, and then remove the old
custom-trait predicate. Do not configure an Experiment Data Connection for
client assignment.
## Related
- [Placements](/revturbine-sdk/guides/placements/) — how placement payloads, targeting, and priority resolve
- [Entitlements](/revturbine-sdk/guides/entitlements/) — entitlement rules and precedence
- [Events & Analytics](/revturbine-sdk/guides/events/) — signals used to measure the experiment
# Headless API
> Use the RevTurbine SDK without React — from vanilla JS, Vue, Svelte, Angular, or server-side code.
The headless API provides three controllers and a factory function:
| Export | Purpose |
|---|---|
| `initRevTurbine()` | Async factory — creates an initialized `SdkSession` |
| `SdkSession` | Session wrapper with user context, placement, and entitlement APIs |
| `PlacementController` | Register → decide → impression-track → interact lifecycle |
| `EntitlementGate` | Check → auto-gate pipeline for feature access |
```ts
import {
initRevTurbine,
PlacementController,
EntitlementGate,
} from '@revturbine/sdk/headless';
```
## The headless contract
Headless is intended for **entitlement checks and user-context updates** —
`session.can()`, `session.update()`, usage reporting, event tracking. Two
rules follow from that:
1. **You supply the inputs.** Pass a `user` context at init, and in local mode
pass the Playbook (`localRuntime: { playbook }`). There is no React
provider doing this for you.
2. **Placements need an explicit slot.** To resolve a placement headlessly you
provide a slot id **and** the surface template ids it accepts
(`surfaceSlot: { id, surfaceTemplateIds }`). The React entry's automatic
gate-placement resolution (``) is a React affordance — in headless
code, denied-state upsell surfaces resolve through the slot config you
declare.
## Quick Start
```ts
import { initRevTurbine } from '@revturbine/sdk/headless';
import playbook from './revturbine.playbook.json';
// Local mode — no account, no network: user + Playbook are the inputs.
const session = await initRevTurbine({
runtimeMode: 'local_only',
localRuntime: { playbook },
user: { id: 'user_123', plan_handle: 'pro' },
});
// Hosted mode — swap the local runtime for your tenant credentials:
// const session = await initRevTurbine({
// tenantId: 'tenant_abc',
// publicKey: 'rtk_…', // public key — safe in the browser
// user: { id: 'user_123', plan_handle: 'pro' },
// });
// Entitlement — the primary headless verbs
const result = await session.can('brand_kit');
if (!result.allowed) {
console.log('Denied:', result.reason);
}
session.update({ usage: { api_calls: 150 } });
// Placement — explicit slot id + the surface template ids it accepts
const banner = session.placement({
surfaceSlot: { id: 'upsell_banner', surfaceTemplateIds: ['general_banner'] },
});
await banner.load();
if (banner.visible) {
console.log('Show banner:', banner.content);
}
// Entitlement gate with a denied-state surface
const gate = session.entitlement({ handle: 'brand_kit', autoGate: true });
await gate.check();
if (gate.denied) {
console.log('Show upgrade gate:', gate.gatedPlacement);
}
```
## `initRevTurbine(options)`
Creates a fully-initialized session. Handles SDK initialization, user identification, theme resolution, and optional placement bootstrapping.
```ts
import { initRevTurbine, RuntimeMode } from '@revturbine/sdk/headless';
const session = await initRevTurbine({
publicKey: process.env.NEXT_PUBLIC_REVTURBINE_PUBLIC_KEY!, // public key — safe in the browser
user: { id: 'user_123', plan_handle: 'pro' },
runtimeMode: RuntimeMode.Server,
bootstrapPlacements: [
{ placement: { name: 'pricing_banner' } },
],
});
```
### Options
Extends `RevTurbineInitInputOptions` with:
| Option | Type | Description |
|---|---|---|
| `bootstrapPlacements` | `Array<{placement, userId?, contextMode?, overrides?, traits?, ttlMs?}>` | Placements to preload on creation |
All standard init options apply: `tenantId`, `publicKey`, `endpoint`, `runtimeMode`, `user`, `localRuntime`, `provider`, `providerFallbacks`, etc.
## `SdkSession`
The session returned by `initRevTurbine()`. Provides the full imperative API.
### User Context
```ts
session.identify('user_456', { plan_handle: 'enterprise' });
session.setUserContext({ personalization: { company: 'Acme' } });
const ctx = session.getUserContext();
session.resetIdentity();
// update() patches any context field except identity `id`. Usage goes under
// the `usage` key — a bare handle is not a context field and is dropped with
// a dev warning.
session.update({ usage: { api_calls: 150 } });
session.update({ plan_handle: 'enterprise', plan: { handle: 'enterprise', name: 'Enterprise' } });
const fullCtx = await session.fetchUserContext('user_456');
const trial = await session.getTrialStatus();
const usage = session.getUsage();
```
### Factory Methods
```ts
const ctrl = session.placement({
surfaceSlot: { id: 'upsell_banner', surfaceTemplateIds: ['general_banner'] },
});
const gate = session.entitlement({ handle: 'brand_kit', autoGate: true });
const decision = await session.getPlacementBySlotId('upsell_banner');
const result = await session.can('brand_kit');
await session.track('feature_used', { feature: 'export_csv' });
// Short-lived process (script, job, edge handler)? Flush before exit so
// buffered events aren't lost:
await session.flushEvents();
```
## `PlacementController`
Manages the full placement lifecycle: register → decide → impression-track → interact.
### Basic Usage
```ts
const ctrl = session.placement({
surfaceSlot: { id: 'pricing_banner' },
});
await ctrl.load();
if (ctrl.visible) {
renderBanner(ctrl.content);
}
```
### Options
| Option | Type | Default | Description |
|---|---|---|---|
| `surfaceSlot` | `{id, name?, surfaceTemplateIds}` | — | Canonical surface slot (preferred). Headless resolution needs the slot id AND the surface template ids it accepts |
| `placement` | `RevTurbinePlacementConfig` | — | Alternative: placement config |
| `userId` | `string` | SDK user | Override target user |
| `contextMode` | `RevTurbineContextMode` | `'auto'` | Context resolution mode |
| `overrides` | `RevTurbinePlacementDecisionOverrides` | — | Override segment/plan/usage for testing |
| `traits` | `Record` | — | Custom traits for decision request |
| `ttlMs` | `number` | — | Decision cache TTL |
| `autoTrackImpression` | `boolean` | `true` | Auto-record impression on visible decision |
| `route` | `string \| (() => string \| null \| undefined)` | — | App route this slot renders on, stamped onto slot lifecycle events (a string or a getter read at emission time). Normalized like the React path; omitted, headless emits `route: null`. See [Route surface](/revturbine-sdk/guides/placement-cookbook/#route-surface) |
### State & Getters
```ts
ctrl.visible; // boolean — is the placement visible?
ctrl.content; // resolved content or null
ctrl.decision; // full decision object or null
ctrl.placementId; // registered placement ID
ctrl.state; // full PlacementControllerState snapshot
```
### Interactions
```ts
await ctrl.dismiss(); // dismiss with 24h cooldown
await ctrl.dismiss(7200_000); // custom cooldown (ms)
await ctrl.snooze(); // snooze for 1 hour
await ctrl.snooze(7200); // custom snooze (seconds)
await ctrl.ctaClick('upgrade'); // CTA click
await ctrl.ctaComplete('upgrade'); // CTA completed (hides placement)
await ctrl.refresh(); // re-fetch decision
ctrl.reset(); // reset all state
```
### Reactive Updates
```ts
const unsub = ctrl.onChange(() => {
console.log('State changed:', ctrl.state);
updateUI(ctrl.state);
});
unsub(); // stop listening
```
## `EntitlementGate`
Checks feature access and optionally resolves a gated placement for denied users.
### Basic Usage
```ts
const gate = session.entitlement({
handle: 'brand_kit',
autoGate: true,
});
await gate.check();
if (gate.allowed) {
enableFeature();
} else if (gate.denied && gate.gatedPlacement) {
showUpgradeModal(gate.gatedPlacement);
}
```
### Options
| Option | Type | Default | Description |
|---|---|---|---|
| `handle` | `string` | — | Entitlement handle (e.g. `'brand_kit'`) |
| `context` | `RevTurbineEntitlementContext` | — | Optional usage/tier context |
| `autoGate` | `boolean` | `false` | Auto-resolve gated placement when denied |
| `gatePlacementRequest` | `Omit<...>` | — | Custom placement request for auto-gate |
### State & Getters
```ts
gate.allowed; // boolean
gate.limited; // boolean — usage partially exhausted
gate.denied; // boolean
gate.result; // raw EntitlementResult or null
gate.gatedPlacement; // PlacementOutput (when denied + autoGate)
gate.state; // full EntitlementGateState snapshot
```
### Reactive Updates
```ts
const unsub = gate.onChange(() => {
updateAccessUI(gate.state);
});
await gate.recheck(); // re-run the check
```
## Framework Integration Patterns
**Vanilla JS**
```ts
const session = await initRevTurbine({ /* ... */ });
const banner = session.placement({ surfaceSlot: { id: 'top_banner' } });
banner.onChange(() => {
const el = document.getElementById('banner');
if (!el) return;
el.style.display = banner.visible ? 'block' : 'none';
if (banner.content) {
el.innerHTML = banner.content.headline || '';
}
});
await banner.load();
document.getElementById('dismiss-btn')?.addEventListener('click', () => {
banner.dismiss();
});
```
**Vue 3**
```ts
import { ref, onMounted, onUnmounted } from 'vue';
import { initRevTurbine } from '@revturbine/sdk/headless';
export function usePlacement(slotId: string) {
const state = ref({ visible: false, content: null });
let unsub: (() => void) | undefined;
onMounted(async () => {
const session = await initRevTurbine(options); // your init options
const ctrl = session.placement({ surfaceSlot: { id: slotId } });
unsub = ctrl.onChange(() => {
state.value = { visible: ctrl.visible, content: ctrl.content };
});
await ctrl.load();
});
onUnmounted(() => unsub?.());
return state;
}
```
**Svelte**
```ts
import { writable } from 'svelte/store';
import { initRevTurbine } from '@revturbine/sdk/headless';
export function createPlacementStore(slotId: string) {
const store = writable({ visible: false, content: null });
(async () => {
const session = await initRevTurbine(options); // your init options
const ctrl = session.placement({ surfaceSlot: { id: slotId } });
ctrl.onChange(() => {
store.set({ visible: ctrl.visible, content: ctrl.content });
});
await ctrl.load();
})();
return store;
}
```
## Comparison: Headless vs React
| Concern | React hooks | Headless controllers |
|---|---|---|
| State management | React `useState` | Manual via `onChange()` |
| Lifecycle | `useEffect` | Explicit `load()` / `check()` |
| Cleanup | Hook unmount | Call `unsub()` / `reset()` |
| Framework | React only | Any JS runtime |
| Bundle | Includes React dep | Zero framework deps |
The React hooks (`usePlacement`, `useEntitlement`) delegate to the headless controllers internally — they share the same business logic.
## Live example
Runs in `local_only` mode with no backend. Two more headless scenarios are on the [Interactive Playground](/revturbine-sdk/playground/#headless-api): [PlacementController](/revturbine-sdk/playground/#headless-placement) and [SdkSession one-shot](/revturbine-sdk/playground/#headless-session).
**EntitlementGate** — an entitlement check without React.
_Live example: /playground/#headless-gate_
# Visitor, User, and Account Identity
> Handle signed-out visitors, sign-in, sign-out, and account changes without carrying another user's grants.
A **visitor** is not yet signed in. A **user** is a person identified by your
application's stable internal ID. An **account** is the organization or billing
scope that person belongs to. These identifiers are different: use `user.id`
for the person and `account_id` for their current account. An account ID is not
a replacement user ID, and a Stripe payer may map to an account separately.
## Before sign-in
Omit `user` intentionally for a signed-out visitor. The SDK uses anonymous
identity for attribution; anonymous identity is not authentication or proof of
a paid plan. Supplying a user object with a blank ID logs an error and falls
back to anonymous identity. Do not use a blank string as your loading state.
If the app needs a stable guest identity across its own sessions or devices,
the app owns that identity and its authentication policy. The SDK's anonymous
storage is not an account system. Do not assume it persists across server
requests, cleared storage, or isolated browser contexts.
## Sign-in and user switches
Resolve the authenticated user's ID and authoritative account/plan before
granting paid access. Use `identify` when the identity changes, and `update`
for changing facts about the same user. Use a stable internal ID, never an email
address; email can change and is personal data.
An anonymous visitor becoming an identified user retains applicable accumulated
state. Switching from one identified user to a different identified user clears
the previous user's context, usage, trial state, and application-provider
snapshot before applying the new user's facts. Do not rely on a user switch to
load facts your application has not supplied.
For sign-out, call the SDK's `resetIdentity()` and clear your application's own
authenticated state. Then return to the visitor experience. Clear private
application caches and cancel in-flight user work as part of your app's logout;
the SDK cannot clear application state it does not own.
## Account changes
The same person may switch accounts without changing their user ID. Refresh the
complete account-scoped context from your trusted backend, including plan,
usage, add-ons, and mirrored grants. Explicitly clear obsolete values rather
than merging a new account ID onto the old account's entitlements. On a server,
construct a fresh session for the selected user/account operation.
An account membership check belongs to your application. A context token should
be issued only after that check; never sign an arbitrary client-provided
`account_id` or plan. See [cloud enforcement](/revturbine-sdk/guides/cloud-entitlements/).
## Lifecycle test
Start signed out, sign in as a paid user, sign out, then sign in as a free user
without reloading the application. Assert the actual SDK context and gate result
after every transition. Repeat with one user switching accounts. The free user
must not retain the paid user's grants or balances.
# Replace Hard-Coded Plan Checks
> Watch a single `if (user.plan === 'pro')` grow into a tangle of business exceptions — beta overrides, comps, segmented copy, usage limits, grandfathered plans — then collapse it to one gate() call plus config.
You already have a check like `if (user.plan === 'pro')` somewhere. It works — until the requirements arrive. Here's that one check growing up, and where it lands.
## The check that grows
### 1. The naive check
```tsx
function ExportButton({ user }) {
if (user.plan !== 'pro') return ;
return ;
}
```
Fine on day one.
### 2. …plus a beta list and a comp window
Sales comped a few accounts; beta users get everything:
```tsx
const BETA_ACCOUNTS = ['acct_123', 'acct_456'];
function ExportButton({ user }) {
const entitled =
user.plan === 'pro' ||
BETA_ACCOUNTS.includes(user.accountId) ||
(user.compedUntil && user.compedUntil > Date.now());
if (!entitled) return ;
return ;
}
```
### 3. …plus segmented upgrade copy
Marketing wants different messaging by plan cadence and region:
```tsx
function ExportButton({ user }) {
const entitled =
user.plan === 'pro' ||
BETA_ACCOUNTS.includes(user.accountId) ||
(user.compedUntil && user.compedUntil > Date.now());
if (!entitled) {
const message =
user.billing === 'annual' ? 'Unlock exports on your annual plan' :
user.region === 'EU' ? 'Passez au plan Pro pour exporter' :
'Unlock exports — upgrade to Pro';
return ;
}
return ;
}
```
### 4. …plus a usage limit and a grandfathered plan
Free users get 3 exports a month; the retired `starter_2022` plan keeps unlimited:
```tsx
function ExportButton({ user }) {
const isLegacy = user.plan === 'starter_2022';
const entitled =
user.plan === 'pro' || isLegacy ||
BETA_ACCOUNTS.includes(user.accountId) ||
(user.compedUntil && user.compedUntil > Date.now());
const overLimit = !entitled && user.exportsThisMonth >= 3;
// ...and now the copy branch has to know about isLegacy and overLimit too.
}
```
By step 4 it's a knot of business exceptions living in your component — and changing the beta list, the copy, or the limit is a code change, a deploy, and an eng ticket.
## The after
```tsx
import { Gate } from '@revturbine/sdk';
function ExportButton() {
return (
);
}
```
The beta list, the comp window, the segmented copy, the usage limit, and the grandfathered plan all move into the **Playbook** — entitlement rules, segments, and the placement copy. Your component asks one question: *can this user export?*
## Still your job: the server check and the CTA
The `after` is not a magic one-liner — your app still owns two things:
```ts
// 1. The money-safe decision, on your server, before the paid work runs —
// the same SDK, evaluating the same Playbook in-process.
import { initRevTurbine, RuntimeMode } from '@revturbine/sdk/headless';
import playbook from './revturbine.playbook.json';
export async function POST(request: Request): Promise {
const user = await getSignedInUser(request); // your own auth
const rt = await initRevTurbine({
runtimeMode: RuntimeMode.LocalOnly,
localRuntime: { playbook }, // loaded once per process
user: { id: user.id, plan_handle: user.planHandle },
});
const result = await rt.can('data_export');
if (!result.allowed) return Response.json({ error: 'upgrade_required' }, { status: 402 });
return runExport(request);
}
```
```tsx
// 2. The upgrade navigation, wired to the placement the renders when denied.
router.push(path.url ?? '/pricing')}>
```
See [Client vs Server Enforcement](/revturbine-sdk/concepts/enforcement/) for why the server check stays.
## The real difference
**Tip — It's not about line count:**
In the **before**, changing the beta list or the upgrade copy is a code change + deploy + eng ticket, and your app has to *know about plans*. In the **after**, your app knows only **slots and entitlements** — a PM edits the targeting and copy in the dashboard, and nothing ships. The first monetization strategy is never the last; this is what keeps your code from changing every time it moves.
## Related
- [Recommended API Path](/revturbine-sdk/getting-started/api-overview/) — `can()` vs `gate()` vs ``
- [Entitlements](/revturbine-sdk/guides/entitlements/) — feature / usage-limit / credits / seats rules
- [Run an Experiment with Segments](/revturbine-sdk/guides/experiments/) — the segmented-copy branch, done in config
# Paywall Host Pattern
> One always-mounted slot, a paywall store, and a 402 to slot map — the reference architecture for gating a whole app.
Most apps need a paywall in more than one place: a feature button, a quota
ceiling hit mid-action, a server rejecting a request. The obvious approach —
mount a slot everywhere a paywall might appear — scatters placement logic across
the codebase and makes it very hard to answer the one question that matters when
something doesn't show up: *is this slot mounted right now?*
The paywall host pattern inverts that. **One slot, mounted once, always.** Every
other part of the app asks a store to open it.
## The three pieces
### 1. One always-mounted slot
Mount a single `FixedSurfaceSlot` — the fixed-category variant of [``](/revturbine-sdk/guides/placements/#slot-types),
with an `onDismissed` callback — high in the tree: in your root layout, above
the router, so it is mounted on every route.
```tsx
// app/layout.tsx
import { FixedSurfaceSlot } from '@revturbine/sdk';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
{/* Mounted once, on every route. Nothing else in the app mounts a paywall. */}
);
}
```
Because it is always mounted, "the slot wasn't there" stops being a possible
explanation for a paywall that didn't appear. That removes an entire class of
debugging.
### 2. A paywall store
The store holds *why* the paywall should be open, not *what* to render — the
content comes from the Playbook.
The store below uses only React — no state library — because the one property
that matters is awkward to get from a hook alone: it must be **settable from
outside React**, so a `fetch` wrapper can open the paywall. Zustand, Redux,
signals or Context all work; swap this for whatever your app already uses.
```tsx
// lib/use-paywall.ts
import { useSyncExternalStore } from 'react';
let blockedEntitlement: string | null = null;
const listeners = new Set<() => void>();
/** Imperative half — callable from anywhere, including non-React code. */
export const paywall = {
open(entitlementHandle: string): void {
blockedEntitlement = entitlementHandle;
listeners.forEach((listener) => listener());
},
close(): void {
blockedEntitlement = null;
listeners.forEach((listener) => listener());
},
};
/** Reactive half — the entitlement the user bumped into, or null when closed. */
export function usePaywall(): string | null {
return useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => { listeners.delete(listener); };
},
() => blockedEntitlement,
// Server snapshot: nothing is open during SSR.
() => null,
);
}
```
Any component can now request a paywall without knowing anything about
placements:
```tsx
async function handleExport() {
const result = await sdk.can('feature_export');
if (!result.allowed) {
paywall.open('feature_export');
return;
}
await runExport();
}
```
### 3. A 402 → slot map
Client-side checks cover the cases the client knows about. The server is the
authority, and it will sometimes reject a request the client thought was fine —
a quota consumed on another device, a subscription that lapsed mid-session, a
race. Map that rejection back into the same paywall.
Entitlement checks fail **closed**, so a check that cannot complete denies rather
than grants. The server rejection and the client check agree on direction.
```ts
// lib/api-client.ts
import { paywall } from './use-paywall';
/** Which entitlement a 402 refers to, by the header the API sends back. */
const ENTITLEMENT_BY_CODE: Record = {
export_limit: 'feature_export',
seat_limit: 'feature_seats',
ai_credits: 'feature_ai_generation',
};
export async function apiFetch(input: RequestInfo, init?: RequestInit) {
const response = await fetch(input, init);
if (response.status === 402) {
const code = response.headers.get('x-entitlement-code') ?? '';
const handle = ENTITLEMENT_BY_CODE[code];
if (handle) {
paywall.open(handle);
}
// Fall through: the caller still sees a failed response and can render its
// own inline state. The paywall opens in addition to, not instead of, the
// caller's own error handling.
}
return response;
}
```
Keep the map explicit. A `402` with an unrecognized code should **not** silently
open a generic paywall — you will never find out the code was wrong, and the user
gets a paywall for something they can already do.
## Handling dismissal
A dismissal is not the same as "nothing matched". `FixedSurfaceSlot` renders its
`fallback` when no placement matches — correct for an always-present slot — and
renders nothing at all once the user dismisses. Use `onDismissed` to clear your
store so the app's own state agrees with what is on screen:
```tsx
paywall.close()}
/>
```
Without this the store still believes a paywall is open after the user closed it,
and the next `open()` for the same entitlement looks like a no-op.
Dismissal is also remembered by the SDK: a dismissed placement stays suppressed
for its authored cooldown, and a **converted** one is retired permanently. Your
store does not need to track either — asking again is safe, and the SDK answers.
## Why a config-side audit cannot verify this
This is the part worth internalising, because we got it wrong ourselves.
When an integration reported that its paywalls never appeared, we audited the
Playbook: the placements existed, the entitlements existed, the targeting was
right, and the fixed slots were configured. We concluded the slots were not
mounted.
**They were mounted.** The call sites passed slot ids that no placement targeted
— a slot id typo on one side and a stale slot id on the other. The config was
fine and the mounting was fine; the two simply did not refer to the same slots.
A config-side audit **cannot see call sites**. It can tell you a placement
targets `slot_paywall`; it cannot tell you your code mounts `slot_paywall_v2`.
Both directions fail silently and they fail differently:
| situation | what you observe |
|---|---|
| A placement targets a slot no code mounts | **Nothing at all.** The decision path is never asked about a slot that was never mounted, so there is no error, no warning, and no reason code. |
| Code mounts a slot no placement targets | The fallback renders forever, which looks exactly like "the user isn't eligible". |
The paywall host pattern shrinks this problem to a single slot id, which is why
it is worth adopting on its own. To check the rest, ask the running app:
```ts
const diagnosis = sdk.diagnoseSlotInventory();
console.log(diagnosis.authoredButUnmounted); // placements that can never show
console.log(diagnosis.mountedButUnauthored); // slots that render fallback forever
```
`diagnoseSlotInventory()` diffs the Playbook's placement triggers against the
slots this app actually mounted, in both directions. Only the running app knows
what it mounted, so this check cannot live in config tooling.
Check `configAvailable` before believing an empty result. If no Playbook reached
the SDK, `authored` is empty for a completely different reason than "nothing is
authored", and the two are indistinguishable without it.
## Why one slot, restated
- **One id to get right.** The failure above becomes a single line to check.
- **One place to mount.** "Is it mounted?" is answered by reading the layout.
- **Routing lives in your app, targeting lives in the Playbook.** The store
decides *when*; the Playbook decides *what* and *to whom*. Neither needs to
know about the other.
- **Server and client agree.** A `402` and a failed `sdk.can()` open the same
paywall through the same path, so there is one rendering to style, test, and
measure.
# Placement Cookbook
> Copy-paste patterns for each core surface type and lifecycle callback.
This guide provides ready-to-use patterns for each surface type supported by the SDK.
## Surface Types at a Glance
| Surface Type | Component | Use Case |
|---|---|---|
| `banner` | `BannerSlot` | Full-width sticky banner (top/bottom) |
| `modal` | `ModalSlot` | Centered overlay dialog |
| `in_page` | `InlineEmbedSlot` | Inline card in page flow |
| `toast` | `ToastSlot` | Ephemeral auto-dismiss notification |
| `button` | `ButtonSlot` | Single CTA button |
| `full_page` | `FullPageSlot` | Dedicated full-page (plans/upgrade) |
| `cli` | `CliSlot` | CLI-style monospace message |
| `in_page` (quota) | `QuotaMeterSlot` | Usage meter (bar/gauge/numeric) |
| `in_page` (credits) | `CreditBalanceSlot` | Depleting credit balance display |
## Banner
```tsx
```
> [Try it live → Usage Warning Banner](/revturbine-sdk/playground/#msg-banner)
## Modal
```tsx
```
> [Try it live → Data Export Gate](/revturbine-sdk/guides/entitlements/#gate-modal)
:::caution[Modal safety rule]
Only request a modal at **safe moments**: after a user action (clicked a button, hit a feature gate), completed a task, or reached a natural transition. Never on passive page render.
:::
## In-Page
```tsx
```
## Toast
```tsx
```
## Button
```tsx
```
> [Try it live → Upgrade Button](/revturbine-sdk/guides/placements/#fixed-button)
## Full-Page
```tsx
```
## CLI
```tsx
```
## Quota Meter (In-Page)
```tsx
```
> [Try it live → Quota Meter](/revturbine-sdk/playground/#fixed-usage-counter)
## Credit Balance (In-Page)
```tsx
```
## Lifecycle Callbacks
```tsx
import type { PlacementUiPath } from '@revturbine/sdk';
const createPlacementCallbacks = (
sdk: import('@revturbine/sdk').RevTurbineCustomerSdk,
placementId: string,
) => ({
onImpression: () => {
void sdk.trackTreatmentInteraction({
userId: 'user_123',
placementId,
interactionType: 'impression',
});
},
onDismiss: () => {
void sdk.trackTreatmentInteraction({
userId: 'user_123',
placementId,
interactionType: 'dismiss',
});
},
onCtaClick: () => {
void sdk.trackTreatmentInteraction({
userId: 'user_123',
placementId,
interactionType: 'cta_clicked',
});
},
});
```
## Object Signature Placement Requests
Use typed helper creators for placement requests:
```ts
import {
createSlotPlacementRequest,
createEntitlementPlacementRequest,
createChainedPlacementRequest,
} from '@revturbine/sdk';
const slotRequest = createSlotPlacementRequest('dashboard_banner', 'banner');
const entitlementRequest = createEntitlementPlacementRequest('mp4_download', {
componentType: 'modal',
});
const chainedRequest = createChainedPlacementRequest('upgrade_follow_up', {
slotId: 'settings_footer',
componentType: 'in_page',
});
```
## Route surface
Every slot lifecycle event (`slot_evaluated`, `slot_filled`, `slot_suppressed`,
`slot_empty`) is stamped with the app **route** it was evaluated on
(`slotContextBase.route`, BL-0207 / ruling D-30). Ingestion-driven surface-slot
discovery persists that route on the discovered slot, so the same slot
rendered at `/projects/42` and `/projects/43` is recognized as one recurring
surface instead of two — that's what lets discovery, the placement dashboard,
and needs-attention surfacing group slots by *where* they live in your app
rather than by every distinct URL a user happened to visit.
A route is always a **path**, never a URL — no origin, no query string, no
fragment. Query strings are where tokens, emails and search terms live, so
they never reach the wire.
### `` boundary
`` declares the route pattern for every slot
rendered beneath it. It's renderless (no DOM node), and the innermost boundary
wins if you nest them:
```tsx
import { RevTurbineRoute } from '@revturbine/sdk';
function ProjectLayout({ projectId, children }: { projectId: string; children: React.ReactNode }) {
return (
{children}
);
}
```
Mounting it is **optional**. Without it, slots fall back to
`window.location.pathname` (see below). Mount it once your router knows the
route *pattern*, so `/projects/42` and `/projects/43` collapse to one route
instead of being reported as two.
### Next.js recipe
Pair it with `routePatternFromParams(pathname, params)`, which rebuilds the
framework route pattern from `usePathname()` + `useParams()` without the SDK
importing `next/navigation`:
```tsx
'use client';
import { usePathname, useParams } from 'next/navigation';
import { RevTurbineRoute, routePatternFromParams } from '@revturbine/sdk';
export function RouteBoundary({ children }: { children: React.ReactNode }) {
const route = routePatternFromParams(usePathname(), useParams());
return {children};
}
```
`/projects/p_42/settings` with `useParams()` returning `{ projectId: 'p_42' }`
becomes `/projects/[projectId]/settings`; a catch-all param
(`{ slug: ['a', 'b'] }`) becomes `[...slug]`. Mount `` once,
near the root layout — every slot beneath it inherits the pattern.
### The pathname fallback and `:id` normalization
When no `` is mounted (or none of its ancestors declared a
route), a slot reports the browser's `window.location.pathname`, with
identifier-like segments templated to `:id` so neither PII nor per-entity
cardinality reaches discovery. A segment is templated when it looks like a
number, a UUID, a long hex string, a prefixed id (`cus_Q1w2E3r4`,
`tn_4bcd07d7-…`), an opaque token, or contains an `@` (email-shaped):
```ts
import { normalizeSlotRoute } from '@revturbine/sdk';
normalizeSlotRoute('/accounts/4821/billing?tab=x');
// → '/accounts/:id/billing' (numeric id templated, query string dropped)
normalizeSlotRoute('/projects/9b1f2e3a-4c5d-4e6f-8a9b-0c1d2e3f4a5b');
// → '/projects/:id' (UUID templated)
```
A route pattern segment the host already templated (`[id]`, `[...slug]`,
`:id`, `*`) is left verbatim rather than re-templated.
### The 512-character cap
A normalized route is truncated to **512 characters** if it exceeds that
length (`SLOT_ROUTE_MAX_LENGTH`, matching scaffold's
`SURFACE_SLOT_ROUTE_MAX_LENGTH`) before it's stamped on an event — an
unbounded pathname (deep-linked search state, long catch-all segments) never
grows the event payload without limit.
### The headless route option
Outside React, pass `route` directly to a headless `PlacementController` — a
string, or a getter read at emission time so a long-lived controller reports
the route it was evaluated on after a client-side navigation:
```ts
import { initRevTurbine } from '@revturbine/sdk/headless';
const session = await initRevTurbine({
tenantId: 'tenant_abc',
publicKey: 'rtk_…',
endpoint: 'https://edge.example.com',
mode: 'snippet',
});
const ctrl = session.placement({
surfaceSlot: { id: 'pricing_banner' },
route: () => window.location.pathname,
});
```
Both forms are normalized the same way as the React path. If `route` is
omitted, a headless controller emits `route: null` — there's no browser
`window.location` fallback outside React, so headless callers that want route
attribution supply it explicitly.
# Placements
> Slot-based and entitlement-based placement patterns — how the SDK decides what to show, where, and when.
**Tip — Visual gallery:**
Each built-in slot component has a co-located Storybook story with 3-5 variants.
Run `pnpm storybook` in the SDK repo to browse them, or see the
[Playground](/revturbine-sdk/playground/) for live Sandpack demos.
Placements are the SDK's core output — they're the decisions about what content to render in which slot, for which user, at what moment. This guide covers placement configuration, resolution, lifecycle, and chaining.
## How Placements Work
1. You define **slots** in your UI — named positions where placements can appear
2. The SDK evaluates **targeting rules** against the current user context
3. If a rule matches, the SDK resolves a **placement decision** with content, surface template, and CTA path
4. The slot component renders the decision using a built-in or custom template
## Slot Configuration
Every slot needs an `id` and optionally a list of accepted `surfaceTemplateIds`:
```tsx
```
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Unique slot identifier (matches server-side placement rules) |
| `surfaceTemplateIds` | `string[]` | Templates this slot accepts (filters available placements) |
:::tip[Design for the empty state]
A slot renders nothing whenever no placement matches the current user — targeting, frequency caps and cooldowns all produce that outcome. Lay the page out so it holds up with the slot empty, and don't reserve space that only looks right when it's filled. Where that isn't possible, give the slot a `fallback` so there is baseline UI when nothing renders.
:::
## Slot Types
### Fixed Slots
Always-visible inline placements — buttons, cards, meters:
```tsx
import { Slot } from "@revturbine/sdk";
import { DemoApp } from "./DemoApp";
// Carol is on Starter with an active trial — the engine serves her an upgrade
// button in this slot. Switch to user="user_eve" and it renders nothing.
export default function App() {
return (
);
}
```
Fixed slots render nothing if no placement matches — your layout stays intact.
> [Try it live → Upgrade Button](/revturbine-sdk/guides/placements/#fixed-button)
### Access Gate Slots
Wrap premium content and show an upgrade prompt when access is denied:
```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 (
);
}
```
If the entitlement is granted, children render normally. If denied, the gate renders an **upgrade placement** in their place.
That denial UI is itself a placement, not hardcoded markup: the decision engine picks the winning placement for the gate (scoped by `surfaceTemplateIds`) and renders its content — modal or banner, headline, offer, and CTA. So *what a denied user sees* is controlled by your monetization config and can be changed, targeted, or A/B-tested in the dashboard without a code deploy. `deniedFallback` is shown only when no placement matches.
> [Try it live → Data Export Gate](/revturbine-sdk/guides/entitlements/#gate-modal)
### Message Slots
Page-level overlays triggered by targeting rules — toasts, modals, banners:
```tsx
import { Slot } from "@revturbine/sdk";
import { DemoApp } from "./DemoApp";
// A message slot renders nothing until a targeting rule fires. Alice's usage
// trips the warning rule, so the engine serves her a banner here.
export default function App() {
return (
);
}
```
Message slots render nothing until a targeting rule triggers. Place them at the top level of your layout.
:::caution[Modals only at safe moments]
A modal takes over the screen. Request one only when the user has just performed an action, completed a task, or reached a natural transition — never on passive page render. Banners, inline cards, buttons and toasts are safe on render.
:::
> [Try it live → Usage Warning Banner](/revturbine-sdk/playground/#msg-banner)
## Placement Output
When a placement resolves, the decision contains:
```ts
interface PlacementOutput {
output_id: string;
category: string;
surface: {
type: RevTurbineComponentType;
template?: string;
slot_id?: string;
};
content: Record; // toast duration is in seconds
cta_path?: Record;
promotion?: Record;
rule_id: string;
decision_id: string;
config_version: string;
present_upsell: boolean;
}
```
`config_version` lets you detect a stale Playbook; `decision_id` is the ID to quote in debugging and analytics; `present_upsell` tells you whether the placement carries an upgrade or expansion offer.
## Placement Lifecycle
### Impression → Interaction → Outcome
```text
┌─────────┐ ┌──────────┐ ┌──────────┐
│ Resolve │ ──► │ Visible │ ──► │ Interact │
└─────────┘ └──────────┘ └──────────┘
│ │
│ ┌─────┴─────┐
│ │ dismiss │
│ │ snooze │
│ │ ctaClick │
│ │ ctaComplete│
│ └───────────┘
│
▼
(auto-tracked impression)
```
### Interaction Methods
```tsx
const { dismiss, snooze, ctaClick, ctaComplete } = usePlacement({ placement: { name: 'hero_banner' } });
// Dismiss — suppresses for default cooldown
await dismiss();
// Dismiss with custom cooldown (30 minutes)
await dismiss(30 * 60 * 1000);
// Snooze — temporarily suppress (1 hour)
await snooze(3600);
// Record CTA click
await ctaClick();
// Record CTA completion (e.g., checkout finished)
await ctaComplete();
```
Each interaction is automatically tracked as a treatment interaction event.
## Cap Policies
Placements can author cap policies that limit how often they appear:
```ts
const capPolicy = {
caps: {
max_per_period: { count: 3, period: 'session' },
cooldown_days: 1,
},
};
```
Authoring a cap in the Playbook enables client-side cap enforcement for that placement. The decision API returns `visible: false` with a `cap_exceeded` reason when capped; the convenience `getPlacement()` API returns `null`.
## Placement Chaining
A CTA can point to another placement via `cta_path.placement_handle`:
```ts
import { parseUiPath } from '@revturbine/sdk';
// First placement decides: show upgrade banner
const banner = await sdk.getPlacement({ slotId: 'dashboard_banner' });
// banner.cta_path = { type: 'open_rt_placement', placement_handle: 'checkout_offer' }
// On CTA click, resolve the follow-up placement
const path = banner?.cta_path ? parseUiPath(banner.cta_path) : undefined;
const followUp = path?.placement_handle
? await sdk.getPlacement({ placementHandle: path.placement_handle })
: null;
```
## CTA actions
Every placement carries a `cta_path` — the action its button triggers. Its `type` is the `action_type` of a `content_ui_paths` entry in your Playbook: one of the built-in types (`open_checkout`, `view_plans`, `book_demo`, `contact_sales`, `complete_onboarding`, `invite_teammate`, `refer_friend`, `verify_work_email`, `update_payment_method`, `enable_auto_renewal`, `manage_subscription`, `switch_billing_period`, `extend_trial`, `open_rt_placement`, `dismiss`, `snooze`) or a name you define — a `custom` action flows through under its own name. The starter Playbook uses `navigate_to_plans` and `open_checkout_modal`.
The SDK never navigates, opens checkout, or calls your backend on its own. On click it looks up a resolver for `cta_path.type` and calls it with the authored `cta_path` record (`plan_handle`, `promotion_id`, `placement_handle`, `url`, plus any custom params). If no resolver is registered, it tracks the click and calls the slot's `onCtaClick` fallback — nothing else happens. `snooze` has a built-in resolver; everything else needs one from you.
Register resolvers once, at init, keyed by action type:
```tsx
router.push('/pricing'),
open_checkout_modal: (path) => openCheckout(path.plan_handle, path.promotion_id),
open_rt_placement: async (path) => {
const next = await rt.getPlacement({ placementHandle: path.placement_handle });
if (next) showPlacement(next);
},
},
}}
>
```
At init the SDK checks that every `action_type` in `content_ui_paths` has a resolver and warns about gaps; `rt.validateUiPathResolvers({ throwOnMissing: true })` turns the warning into an error, which is useful in tests. Actions that must run on your server — `extend_trial`, for example — go in `serverActions` instead: see [Server-owned CTA actions](/revturbine-sdk/getting-started/server-side/#server-owned-cta-actions).
## Using the Hook
For custom rendering, use `usePlacement` directly:
```tsx
const {
visible,
content,
decision,
isLoading,
error,
refresh,
dismiss,
ctaClick,
} = usePlacement({
surfaceSlot: {
id: 'dashboard_banner',
surfaceTemplateIds: ['banner_placement'],
},
ttlMs: 300_000, // Cache for 5 minutes
autoLoad: true, // Load on mount
});
```
## Live examples
One scenario runs here; the full gallery — every built-in slot type, editable, switching demo users — lives on the [Interactive Playground](/revturbine-sdk/playground/).
**Upgrade Button** — an always-visible CTA in a nav slot.
_Live example: /playground/#fixed-button_
More on the Playground: [Plans & Pricing Card](/revturbine-sdk/playground/#fixed-in-page) · [Quota Meter](/revturbine-sdk/playground/#fixed-usage-counter) · [Annual Nudge Banner](/revturbine-sdk/playground/#fixed-banner) · [Usage Warning Banner](/revturbine-sdk/playground/#msg-banner) · [Usage Exhausted Modal](/revturbine-sdk/playground/#msg-modal) · [Trial Welcome Toast](/revturbine-sdk/playground/#msg-toast).
## Route surface
Every slot stamps the app route it rendered on onto its lifecycle events, so
discovery groups the same slot across `/projects/42` and `/projects/43`
instead of treating each URL as a separate surface. See
[Route surface](/revturbine-sdk/guides/placement-cookbook/#route-surface) in the Placement
Cookbook for the `` boundary, the Next.js recipe, and the
pathname fallback.
## Next Steps
- [Component Gallery](/revturbine-sdk/components/) — interactive demos of every built-in slot component
- [Custom Slot Types](/revturbine-sdk/guides/custom-slots/) — register your own slot components
- [Events & Analytics](/revturbine-sdk/guides/events/) — track impressions and interactions
- [Interactive Playground](/revturbine-sdk/playground/) — try all slot types live
# Plans Without a Subscription
> Supply an explicit free-plan identity and understand application versus hosted plan context.
A free user does not need a Stripe subscription. They do need an explicit plan
identity when a Playbook rule targets a plan. The SDK resolves `plan_handle`
first, then `plan.handle`. If neither exists, it does not select the cheapest
public plan or invent a default free plan; plan-targeted checks can deny with
`no_plan_identity`.
## Choose your no-subscription policy
Define a free plan in the Playbook and have your application supply its handle
for users who should receive it. Public visibility is a merchandising setting,
not an instruction to assign that plan to every unidentified user. Keep the
same policy on your backend's authoritative context builder.
```ts
rt.update({ plan_handle: 'free' });
```
Use your actual Playbook handle. Do not translate a display name into a handle
or send the retired `plan: { id: ... }` shape.
## Hosted context and application context
Hosted client-context delivery can provide a plan derived from the current
customer/account state. When a server plan is present, it can replace an earlier
application-supplied plan. When the response omits a plan, that omission does
not necessarily clear the SDK's existing application plan.
Therefore, a missing subscription is not a universal reset-to-free operation.
After cancellation, account change, or logout, refresh the authoritative state
and explicitly apply or clear the relevant context. Cancellation timing also
matters: cancellation at period end may intentionally retain paid access until
the paid period ends. Do not infer that all canceled Stripe states have the
same access policy.
An application-supplied plan can provide an immediate UI update while billing
synchronizes. It is not payment proof. The backend must derive access from
trusted billing/application records, and a later hosted plan can supersede the
optimistic value.
Test a new free user, a paid user, cancellation at period end, an expired paid
period, and a response that contains no plan. Check the held SDK context as well
as the final entitlement result. See [identity lifecycle](/revturbine-sdk/guides/identity-lifecycle/)
and [Billing & Stripe](/revturbine-sdk/guides/billing/).
# Runtime Modes
> Local mode and hosted mode, what each gives you, and where decisions run in both.
Two modes matter for most apps: **local** and **hosted**. A third, `custom_endpoints`, routes selected context and telemetry calls through your own proxy while Playbook delivery stays with RevTurbine.
## Hosted mode
**Hosted mode is how you change monetization without a deploy.** RevTurbine stores and versions your Playbook; the SDK in your app keeps a fresh local copy and decides from it.
What it gives you:
- **Change without deploying.** Edit plans, entitlements, targeting and prompts in the RevTurbine dashboard, with your coding agent, or through the CLI. Draft, review, launch. The SDK picks up the new release on its next refresh.
- **Versioned Playbooks.** Every launch is an immutable Release with history. Roll back by restoring any earlier one.
- **Analytics.** Conversion and usage analytics, so you can see what's working. Growth adds deeper analytics and self-driving growth recommendations.
- **Signals your app doesn't hold.** Billing status from Stripe, trial state, per-customer exceptions and end-user behavior follow the user. Caps and cooldowns hold across devices, and an exception applies wherever that user shows up.
What doesn't change: the RevTurbine SDK runs inside your app, and on your backend where you verify. It evaluates entitlements and placements locally, as a pure function of your Playbook and the current user context. Hosted mode changes where the Playbook and user state come from, not where decisions run. We add no latency, and our availability never affects your product.
In code: `runtimeMode: RuntimeMode.Server` (`revturbine_server`), with the browser's `publicKey` or a trusted backend's `apiKey`. Both load hosted Playbooks and evaluate locally. Backend sessions belong to one user and operation; keep server keys out of browser bundles. See [Initialize the SDK](/revturbine-sdk/getting-started/initialization/) and [Server-Side Integration](/revturbine-sdk/getting-started/server-side/#hosted-delivery-on-a-backend).
## Local mode
**Local mode ships the Playbook with your app.** You version it in your own repo, and changing it is a deploy. No account, always free, and production-capable on its own. Every decision is made the same way as in hosted mode.
In code: `runtimeMode: RuntimeMode.LocalOnly` (`local_only`) with `localRuntime: { playbook }`.
## Which one
1. Getting started, building a demo, or shipping a Playbook that rarely changes? **Local.**
2. Want to change monetization without deploying, see analytics, or set per-customer exceptions? **Hosted.**
3. Need context or telemetry calls to go through your own proxy? **Hosted, with `custom_endpoints`.**
## Mode comparison
| Mode | Where the Playbook comes from | What RevTurbine provides | Required config |
|---|---|---|---|
| **Local** (`local_only`) | Bundled with your app | Nothing at runtime; optional anonymous adoption beacon | `localRuntime: { playbook }` |
| **Hosted** (`revturbine_server`) | RevTurbine, cached locally and refreshed in the background | Playbook delivery and versioning; user state (billing, trials, exceptions, behavior); analytics | `publicKey` (and a `clientSession` route for server-derived state). `tenantId` and `endpoint` are optional |
| **Hosted with custom endpoints** (`custom_endpoints`) | RevTurbine, cached locally as in hosted mode | Same as hosted; selected context and telemetry calls use your endpoints | Hosted config + `endpointOverrides` |
### Keep the Playbook intact
Use a Playbook checked into your app for local mode, or let the SDK retrieve the launched Playbook from RevTurbine in hosted mode. Pass the validated artifact intact. An app endpoint that reshapes, filters or strips fields creates a different artifact from the one you validated.
`endpointOverrides` does not reroute Playbook delivery. Keep `endpoint` set to RevTurbine and override only the supported context and telemetry calls. See the [endpoint reference](/revturbine-sdk/reference/configuration/#revturbineendpointoverrides) for the complete list.
## Where decisions run, and what is cached
In every mode the SDK evaluates entitlements and placements inside your app, from the Playbook it holds. No check and no placement decision makes a network call.
In `revturbine_server` mode the SDK fetches the launched Playbook at init, keeps it in memory, and re-checks for a new release at most once a minute — a launched Playbook is immutable per version, so polling more often would only waste requests. If a refresh fails, the SDK keeps the last-known-good Playbook and never throws. Before the first Playbook has arrived, an entitlement check denies with an infrastructure reason (`config_unavailable`) rather than guessing.
Set the mode with the typed **`RuntimeMode`** constant rather than a raw string:
```ts
import { RuntimeMode } from '@revturbine/sdk';
// runtimeMode: RuntimeMode.LocalOnly | RuntimeMode.Server | RuntimeMode.CustomEndpoints
```
`local_only` makes no config or decisioning calls and does not send clickstream
events to RevTurbine ingest. A keyless, anonymous `sdk_init` adoption beacon is
the one network exception. Set `anonymousTelemetry: false` (or
`previewMode: true` for docs and examples) when the process must make zero
RevTurbine requests.
## Copy-Paste Setup
**Local-only (start here)**
```ts
import { initRevTurbine, RuntimeMode } from '@revturbine/sdk';
import playbook from './revturbine.playbook.json';
// No account, key, tenant or endpoint: the Playbook is the whole configuration.
const session = await initRevTurbine({
runtimeMode: RuntimeMode.LocalOnly,
localRuntime: {
playbook,
storageKey: 'my-app:revturbine-local-runtime',
},
});
```
**Tip:**
In `local_only` mode the SDK evaluates all targeting rules, entitlements, and placement decisions client-side using the loaded Playbook. Clickstream events are not sent to RevTurbine ingest; local consumers and runtime persistence still work. The one network exception is the anonymous adoption beacon described above.
**Hosted mode**
```ts
import { initRevTurbine, createServerRuntimeConfig } from '@revturbine/sdk';
const session = await initRevTurbine(
createServerRuntimeConfig({
tenantId: 'tenant_abc',
publicKey: 'rtk_…', // public key — safe in the browser
}),
);
```
**Custom endpoints**
```ts
import { initRevTurbine, createCustomEndpointRuntimeConfig } from '@revturbine/sdk';
const session = await initRevTurbine(
createCustomEndpointRuntimeConfig({
tenantId: 'tenant_abc',
publicKey: 'rtk_…', // public key — safe in the browser
endpointOverrides: {
clientContext: 'https://proxy.example.com/revturbine/client-context',
userContext: 'https://proxy.example.com/revturbine/user-context',
trialStatus: 'https://proxy.example.com/revturbine/trial-status',
ingestEvents: 'https://proxy.example.com/revturbine/track',
touchpointTransition: 'https://proxy.example.com/revturbine/interactions',
},
}),
);
```
### Add the context and UI your Playbook uses
Local mode needs no placeholder tenant, API key or endpoint. Add a user when your rules depend on identity or plan, resolve the CTA action types your Playbook authors, and optionally supply branding:
```ts
import { initRevTurbine, RuntimeMode } from '@revturbine/sdk';
import playbook from './revturbine.playbook.json';
const session = await initRevTurbine({
runtimeMode: RuntimeMode.LocalOnly,
localRuntime: { playbook },
user: { id: 'user_123', plan_handle: 'starter' },
uiPathResolvers: {
navigate_to_plans: () => { window.location.assign('/pricing'); },
},
branding: {
workspace_name: 'My app',
theme: { colors: { primary: '#6366f1' } },
},
});
// The async initializer returns a session; instance-only methods live on .sdk.
const branding = session.sdk.getBranding();
```
Use your actual plan handle and CTA action types. Branding is optional and merges over defaults. With React, pass the same options to [RevTurbineProvider](/revturbine-sdk/getting-started/react/) and let it initialize the SDK. See [User Context](/revturbine-sdk/user-context/), [CTA actions](/revturbine-sdk/guides/placements/#cta-actions) and [Theming](/revturbine-sdk/guides/theming/) for the individual options.
## Migrating from local to hosted mode
When you want dashboard-managed changes without deploying your app, switch the connection options. Local mode is also production-capable; your component code stays the same:
```diff
```
## Provider Fallback Strategy
Providers are for teams that supply their own facts — entitlement grants, user context — from more than one source. Decisions evaluate locally from the available Playbook. Loading or refreshing that snapshot can still fail; handle `config_unavailable` and choose an explicit freshness policy. Most apps configure no provider.
**Available options:**
- `provider`: primary provider.
- `providerFallbacks`: ordered fallback provider list.
- `providerFailureSlotBehavior`: `'invisible'` (default) or `'placeholder'`.
**Behavior:**
1. SDK calls primary provider first.
2. If primary fails, SDK logs a warning and executes fallbacks in order.
3. If all configured providers fail for a method, slots render hidden or placeholder output based on `providerFailureSlotBehavior`.
## Integration Checklist
1. Start with `local_only` to validate your integration and placement slots.
2. For dashboard-managed changes and analytics, switch to `RuntimeMode.Server` and provide real credentials.
3. Re-check billing-critical actions on your server with the same Playbook — see [Server-Side Integration](/revturbine-sdk/getting-started/server-side/).
4. If you supply your own facts through providers, choose `providerFailureSlotBehavior` per surface.
# Upgrade an SDK Integration
> Check identity, entitlement mirroring, gate behavior, and presentation contracts when upgrading to SDK 0.12.4.
Pin the SDK version in your package manifest and lockfile. This guide describes
the published TypeScript SDK **0.12.4**; review the
[changelog](https://github.com/revt-eng/revturbine-sdk/blob/main/CHANGELOG.md)
for your starting version. TypeScript, Python, and Rust do not expose identical
integration APIs even when their evaluator results agree.
## Entitlement results and mirrors
An unknown handle denies with `entitlement_not_in_playbook`. Check spelling and
the loaded Playbook before presenting that operational error as an upsell. Read
`allowed`, not `status`, because a `limited` outcome can permit or deny work.
`UserContext.entitlements` contains application-supplied mirrored grants. These
can determine a result before ordinary Playbook rules; `entitlement_mirrored`
identifies that path. Do not populate every handle with placeholder booleans and
then expect the rules to decide. Supply mirrors only when the application owns
those grants and select `entitlementMerge` deliberately. Test a conflicting
mirror and rule, an absent mirror, and both a free and paid user.
Parameter kinds such as `price_per_unit` and `rate_limit` are not access gates.
They deny `can()` with `not_access_entitlement_kind`; see
[parameter entitlements](/revturbine-sdk/guides/entitlements/#parameter-entitlements).
## Gates, slots, and presentation
A denied gate resolves Access Gate content. A generic promotional placement is
not interchangeable with a denied gate's upgrade surface. Keep a safe
`deniedFallback` for cases with no eligible Access Gate content.
Presentation caps may come from payloads and placement settings. Check the
authored category, eligibility, and presentation settings when content does not
appear. Message slots do not consume fixed-content placements. Test the actual
mounted component with its authored slot and template, not just a raw placement
lookup. The public components are `Gate` and `Slot`; this version does not export
`InlineSlot`, `OverlaySlot`, or a `moment()` API.
Mounted gates recheck when the SDK publishes relevant context or usage updates.
You do not need a React key change to make a new SDK balance take effect. Changes
in another device or a billing webhook still need to reach the local SDK first.
See [React updates](/revturbine-sdk/getting-started/react/#keeping-mounted-gates-current).
## Events and lifecycle
Do not emit the removed `growth_signal_observed` event. Use the current typed
event/trigger surface for supported signals, and `track()` for your application
events. [Billing event ownership](/revturbine-sdk/guides/events/#billing-facts-and-application-events)
explains how Stripe facts differ from app telemetry.
On a backend, create an SDK session for one verified user and operation and
dispose it in `finally`. Sharing immutable Playbooks is safe; sharing a mutable
user-bound session across callers is not. Browser sign-in, sign-out, and account
switches should follow [identity lifecycle](/revturbine-sdk/guides/identity-lifecycle/).
## Verify the migration
Run your application's typecheck and focused integration tests against the exact
package you install. Exercise a paid grant, a free denial, a missing Playbook,
a misspelled handle, a usage threshold crossed while a gate stays mounted, and
user A switching to user B. Recheck the backend operation as well as the UI.
The [Gate](/revturbine-sdk/tutorials/gate-premium-feature/) and
[usage meter](/revturbine-sdk/tutorials/usage-quota-meter/) tutorials use the current API shapes.
The [cloud examples](/revturbine-sdk/guides/cloud-entitlements/) exercise real SDK behavior with
local provider adapters; no cloud deployment is required to run them.
# Theming
> Customize the appearance of built-in slot components with themes.
Built-in slot components (banner, modal, toast, etc.) use a theme system for colors, typography, shapes, and shadows. You can use the default theme, apply a built-in variant, or define your own.
## Theme Structure
```ts
interface RevTurbineTheme {
colors: RevTurbineThemeColors;
typography: RevTurbineThemeTypography;
shape: RevTurbineThemeShape;
shadows: RevTurbineThemeShadows;
}
```
## Color Tokens
| Token | Default | Purpose |
|---|---|---|
| `primary` | `#1e40af` | Primary CTA buttons, links |
| `primaryText` | `#ffffff` | Text on primary background |
| `secondary` | `#f3f4f6` | Secondary buttons, backgrounds |
| `secondaryText` | `#1f2937` | Text on secondary background |
| `accent` | `#7c3aed` | Highlight elements |
| `accentText` | `#ffffff` | Text on accent background |
| `background` | `#ffffff` | Page background |
| `surface` | `#f8fafc` | Card/surface background |
| `surfaceBorder` | `#e2e8f0` | Card borders |
| `text` | `#111827` | Primary text |
| `textSecondary` | `#4b5563` | Secondary/description text |
| `textMuted` | `#6b7280` | Muted/hint text |
| `overlay` | `rgba(0,0,0,0.5)` | Modal backdrop |
| `success` | `#16a34a` | Success states |
| `warning` | `#f59e0b` | Warning states (quota 70–90%) |
| `danger` | `#dc2626` | Danger states (quota > 90%) |
| `info` | `#60a5fa` | Informational states |
| `track` | `#e5e7eb` | Meter/progress track background |
| `toastBackground` | `#1f2937` | Toast notification background |
| `toastText` | `#ffffff` | Toast notification text |
## Typography Tokens
| Token | Default | Purpose |
|---|---|---|
| `fontFamily` | `system-ui, -apple-system, sans-serif` | Body text |
| `fontFamilyMono` | `ui-monospace, SFMono-Regular, ...` | Code/CLI text |
| `fontSize` | `14px` | Base font size |
| `fontSizeSmall` | `13px` | Small text |
| `fontSizeHeader` | `20px` | Section headers |
| `fontSizeLargeHeader` | `28px` | Page/modal titles |
## Shape Tokens
| Token | Default | Purpose |
|---|---|---|
| `borderRadiusSmall` | `6px` | Buttons, inputs |
| `borderRadius` | `8px` | Cards, panels |
| `borderRadiusLarge` | `12px` | Modals, large surfaces |
## Shadow Tokens
| Token | Default | Purpose |
|---|---|---|
| `medium` | `0 10px 40px rgba(0,0,0,0.25)` | Toast, dropdown |
| `large` | `0 20px 60px rgba(0,0,0,0.3)` | Modal |
## Applying a Custom Theme
Pass an optional `branding` value in your SDK options. This local React example changes a few tokens and lets the rest use defaults:
```tsx
import { RevTurbineProvider } from '@revturbine/sdk';
import { useMemo, type ReactNode } from 'react';
import playbook from './revturbine.playbook.json';
const customTheme = {
colors: {
primary: '#6366f1', // Indigo
primaryText: '#ffffff',
accent: '#ec4899', // Pink
accentText: '#ffffff',
surface: '#fafafa',
surfaceBorder: '#e5e5e5',
text: '#171717',
textSecondary: '#525252',
// ... other tokens use defaults
},
typography: {
fontFamily: '"Inter", system-ui, sans-serif',
fontSizeHeader: '18px',
},
shape: {
borderRadius: '12px',
borderRadiusSmall: '8px',
},
shadows: {
large: '0 25px 50px rgba(0,0,0,0.15)',
},
};
export function App({ children }: { children: ReactNode }) {
const options = useMemo(() => ({
localRuntime: { playbook },
branding: { theme: customTheme },
}), []);
return (
{children}
);
}
```
Partial themes are merged with defaults — you only need to specify the tokens you want to change.
### Light and dark palettes
Set the provider's `colorScheme` prop to `'light'`, `'dark'` or `'system'` (the default). System follows the device's preference. Pass your app's current preference to that prop when the user changes it; placements repaint without reinitializing the SDK. Keep it outside `options`.
`useRevTurbine().colorScheme` reports the resolved `'light'` or `'dark'` value. The selected palette supplies the defaults; explicit branding tokens still override those defaults.
## Accessing the Theme
### In React Components
```tsx
import { useRevTurbineTheme } from '@revturbine/sdk';
function ThemedBadge({ label }) {
const theme = useRevTurbineTheme();
return (
{label}
);
}
```
### In Custom Slot Components
Custom slot components receive the theme as a prop:
```tsx
function MySlot({ content, theme }: PlacementSlotProps) {
return (
{content?.body}
);
}
```
## Theme Priority
The theme is resolved from multiple sources, in priority order:
1. **Explicit `branding` option** — supplied by your app at initialization
2. **Branding API** — supplied or fetched workspace branding
3. **Legacy config `theme`** — accepted for older artifacts, deprecated for new integrations
4. **Defaults** — the selected light or dark palette
The selected source's partial theme merges over defaults. For local mode, pass branding explicitly or use defaults. A legacy config theme still resolves for older artifacts; new Playbooks do not need one.
An app-mounted `RevTurbineThemeProvider` above `RevTurbineProvider` owns the rendered theme and takes precedence over this automatic resolution. Development builds warn about that override. Use this wrapper when your app deliberately manages the rendered theme; otherwise let `RevTurbineProvider` resolve it.
## Next Steps
- [Custom Slot Types](/revturbine-sdk/guides/custom-slots/) — build components that use the theme
- [Component Gallery](/revturbine-sdk/components/) — built-in slot demos with theme switching
# Troubleshooting
> Common SDK integration failures — symptoms, causes, and fixes.
## Troubleshooting Matrix
| Symptom | Likely Cause | Fix |
|---|---|---|
| `getPlacement` returns `null` unexpectedly | Slot/component mismatch or no eligible payload | Verify `slotId`, `componentType`, and payload targeting. Start with `createSlotPlacementRequest(...)`. |
| A slot renders nothing, with no error and no reason code | The placement targets a slot your code never mounts — it can **never** show | Run [`sdk.diagnoseSlotInventory()`](#nothing-renders-and-nothing-explains-why). Comparing slot ids by eye is what this replaces. |
| A slot renders its fallback forever | Your code mounts a slot no placement targets | Same probe — it reports this direction too. |
| Entitlement check denies with `config_unavailable` or `sdk_disabled_provider_failure` | Playbook fetch or configured provider failed | Verify endpoint availability, auth headers, and provider health. See [Client vs Server Enforcement](/revturbine-sdk/concepts/enforcement/) for the authoritative fallback contract. |
| CTA path not firing expected action | Payload action field mismatch (`cta_path` vs legacy shape) | Use canonical `cta_path` in payloads and parse via `PlacementRenderer`. |
| Decisions feel stale | Cache TTL too long | Lower `ttlMs` in decision requests or call refresh flows explicitly. |
| Interactions not visible in backend telemetry | Ingestion endpoint misconfigured | Validate `ingestEvents`/`touchpointTransition` endpoint wiring and auth. |
| Runtime mode behavior is incorrect | Wrong mode or incomplete mode config | Re-check mode selection in the [runtime modes guide](/revturbine-sdk/guides/runtime-modes/) and use mode helper builders. |
| Console warns primary provider failed | The provider chain is failing: entitlement checks deny with `sdk_disabled_provider_failure` and slots follow `providerFailureSlotBehavior` | Configure `providerFallbacks`, verify provider health, and choose `providerFailureSlotBehavior` intentionally. |
| Type is `unknown` from SDK route | Contract/schema title mismatch | Ensure the SDK version matches the schema version and reinstall. |
| Build fails after SDK API changes | Missing migration updates in caller code | Migrate to object-style request helpers and rerun typecheck. |
## Warnings that need an integration fix
| Warning or reason | What to inspect |
| --- | --- |
| Blank user ID | Wait for authentication or intentionally omit `user` for a visitor; never pass an empty ID as a loading placeholder. |
| Email-shaped identity | Use a stable internal user/account ID; keep email in its dedicated context field. |
| Dropped context keys | Use the flat canonical UserContext shape; free-form targeting belongs under `custom`, not an extra `context` wrapper. |
| Missing UI-path resolver | Register the authored action type or remove the unused action. A tracked click alone does not navigate or open checkout. |
| `entitlement_not_in_playbook` | Compare the exact handle with the loaded Playbook and confirm the correct tenant/version. |
| `not_access_entitlement_kind` | `price_per_unit` and `rate_limit` are parameter kinds; gate on an access entitlement. |
| `config_unavailable` | Diagnose delivery/authentication/version admission; do not turn an unavailable policy into a paid grant. |
| `VAL-PLN-07` | A numeric cap has no explicit enforcement choice. The default blocks at the cap. Choose `hard_block`, `block_with_upsell`, `degrade`, or `allow_overage` intentionally in the authored rule. |
| Playbook `tenant_id` differs from the SDK initialization target (for example, `local`) | Check the imported artifact and the explicit `tenantId` option. For local input, the init target wins and a mismatch warns; align them with the intended tenant rather than suppressing the warning. This is not an instruction to rewrite a signed hosted artifact. |
| The server's schema is newer than this CLI's bundled snapshot | Offline validation may lack newer rules. Update the repository's pinned CLI (`npm install -D @revturbine/cli@latest`, or your package manager's equivalent). The warning clears when that CLI's bundled schema version is at least the server stamp; reinstalling the same version does not resolve the lag. |
Read `initStatus` inside the actual provider and inspect `getPlaybookLoadFailure()`
on an initialized SDK when delivery fails. A once-per-session warning may not
repeat on every render; fix the cause and rerun the relevant transition. Do not
disable validation globally to hide a warning. See
[identity lifecycle](/revturbine-sdk/guides/identity-lifecycle/) and
[SDK upgrades](/revturbine-sdk/guides/sdk-upgrades/).
## Nothing renders, and nothing explains why
This is the hardest symptom to debug, because **there is no error**. Almost
nothing in the SDK throws — a mismatch degrades instead of erroring — so a
placement that can never show looks exactly like a user who is not eligible.
The usual cause is that a placement targets a slot id your code does not mount,
or your code mounts a slot id no placement targets. Neither is visible from the
Playbook alone: **a config-side audit cannot see your call sites.** Only the
running app knows what it mounted, so ask it:
```ts
const diagnosis = sdk.diagnoseSlotInventory();
if (!diagnosis.configAvailable) {
// No Playbook reached the SDK. `authored` is empty for a completely
// different reason than "nothing is authored" — check this FIRST, because
// the two look identical in the lists below.
console.warn('No Playbook available; fix initialization before reading the rest.');
}
// Placements that can never show: they target a slot nothing renders.
console.log(diagnosis.authoredButUnmounted);
// Slots that render their fallback forever: nothing targets them.
console.log(diagnosis.mountedButUnauthored);
```
Each finding names the placement id and category, so it points at the thing to
fix rather than telling you something is wrong somewhere.
If both lists are empty, `configAvailable` is `true`, targeting matches the
user, and the placement still does not render — stop. That is a correct
configuration producing silence, which is not a configuration problem.
[Open an issue](https://github.com/revt-eng/revturbine-sdk/issues/new) with the
diagnostics output above and the SDK version, rather than working around it.
Requires `@revturbine/sdk` 0.8.0 or newer.
## Provider Failure Behavior
When all configured providers fail for `getPlacement`, `checkEntitlement`, or `identify`, the SDK disables itself to avoid inconsistent behavior.
In this state:
1. The SDK logs warning messages to the console.
2. Placements render as hidden (`invisible`) or safe placeholders (`placeholder`) depending on `providerFailureSlotBehavior`.
3. Entitlement checks return a denied result with `sdk_disabled_provider_failure`; see [Client vs Server Enforcement](/revturbine-sdk/concepts/enforcement/) for why checks and placements have different fallback behavior.
## Quick Checks
1. **Run API type checks:**
```bash
pnpm typecheck:sdk-api
```
2. **Run web build:**
```bash
pnpm build
```
3. **Validate SDK docs generation:**
```bash
pnpm docs:sdk
```
# Usage Periods and Reset Ownership
> Keep authoritative counters, period boundaries, and SDK snapshots consistent.
Your application owns usage counting, storage, concurrency, and resets. The SDK
evaluates the balance supplied in UserContext against the Playbook's policy.
An authored `period_scope` describes the intended period; it does not schedule
a database reset or roll a counter over automatically.
## Define the period in your application
The authored `period_scope` values are `per_second`, `per_minute`, `per_hour`,
`per_6_hours`, `per_day`, `per_week`, `per_month`, `per_year`,
`per_billing_period`, `lifetime`, `concurrent`, and `per_instance`.
Choose the value that matches your application-owned accounting model.
`concurrent` and `per_instance` describe a population or instance boundary,
not a scheduled time reset; `lifetime` does not imply periodic replenishment.
Decide whether a monthly allowance means a calendar month or a customer's
billing-cycle month, which timezone defines the boundary, and what happens on
upgrade, downgrade, cancellation, and retry. Store that period identity with
the counter so delayed requests cannot consume or reset the wrong period.
At a boundary, atomically open the new period in your backend and deliver its
current balance to the SDK. A `reset_date` can describe when a reset is due;
changing that date alone does not reset the reported amount.
## Report absolute values
Initialize usage with the rich `UserUsageEntry` shape. For later changes, use
`update({ usage: { api_calls: total } })` with the latest **absolute** total,
not the delta for the last request. Treat delayed or out-of-order refreshes
according to your application's versioning policy.
```ts
// After the backend has accepted the operation and returned the current total:
rt.update({ usage: { api_calls: 951 } });
```
A missing balance can be interpreted as zero used. That is not evidence of an
unused account: ensure every metered handle has authoritative usage before
allowing a valuable operation. UI updates keep mounted gates current, but the
backend remains the enforcement boundary.
## Enforce concurrent operations
Two requests can both observe the same remaining allowance. A local SDK check
does not reserve it. Use an atomic database update, reservation, or your own
metering service to prevent overspending, and then report the resulting balance.
Telemetry delivery is not a durable accounting write.
Test the last permitted operation, the first denied operation, two concurrent
requests for the last unit, and a request across the reset boundary. The
[quota-meter tutorial](/revturbine-sdk/tutorials/usage-quota-meter/) covers the UI, while
[server-side integration](/revturbine-sdk/getting-started/server-side/) covers per-operation
SDK sessions.
# Debugging Decisions
> Use explainPlacementDecision() to see exactly why a placement was or wasn't shown — segment predicate pass/fail, entitlement-rule matching, and decision metadata.
"Why isn't my upgrade prompt showing?" is the most common integration question, and RevTurbine answers it directly. `explainPlacementDecision()` returns the same decision the engine made, plus the full reasoning behind it.
## explainPlacementDecision()
Call it with the same input you pass to `getPlacementDecision()`:
```ts
// A headless session exposes the SDK as `session.sdk`; in React it is `useRevTurbine().sdk`.
const explanation = await session.sdk.explainPlacementDecision(decisionInput);
```
The result is a structured diagnostic covering three things:
- **Segment membership** — each configured segment, whether the user matched, and **predicate-level pass/fail** (which trait comparison succeeded or failed). This is where a mistyped trait or a missing user-context field shows up.
- **Entitlement-rule matching** — which entitlement rules applied to the current targeting context and their outcomes.
- **Decision metadata** — the `rule_id` that won, reason codes, and any **suppression** (e.g. a frequency cap or cooldown that hid an otherwise-eligible placement).
## Reading it
When a placement doesn't show, walk the explanation top-down:
1. **Did the user match the segment?** If a predicate failed, check the trait you set in user context against the segment's `field` / `operator` / `value`.
2. **Did an entitlement rule apply?** A gate that grants access won't render an upsell — denial is what triggers the prompt.
3. **Was it suppressed?** Reason codes will name a cap, cooldown, or priority loss to a competing placement.
Because the explanation is plain data, wire it into your own QA tooling or a customer-facing "why am I seeing this?" panel — it's the same payload the engine used, so it never drifts from the real decision.
## Related
- [Placements](/revturbine-sdk/guides/placements/) — the decision input and how placements resolve
- [Run an Experiment with Segments](/revturbine-sdk/guides/experiments/) — segment predicates in practice
- [Production Readiness](/revturbine-sdk/operate/production-readiness/) — the rest of the pre-launch checklist
# Production Readiness
> The pre-launch checklist for a billing-adjacent SDK — keys, versioning, what a check returns when it can't justify a grant, local-only limits, caching, and testing plan changes before you deploy.
RevTurbine is safe to run in production from day one: we add no latency and our availability never affects your product. Decisions never have critical dependence on our servers. The RevTurbine SDK runs inside your app, and on your backend where you verify. It evaluates entitlements and placements locally, as a pure function of your Playbook and the current user context.
We push additional inputs to your local engine: aggregated frequency caps, prioritization scores as available. These are additive; nothing breaks or blocks if not present. This page is the checklist for the rest.
## Client vs server
The one decision that matters most: **the client check drives the UI; your server makes the money-safe decision.** Read [Client vs Server Enforcement](/revturbine-sdk/concepts/enforcement/) before shipping anything billing-critical.
## Keys
RevTurbine issues three kinds of key. Two you create in the dashboard; the third your backend mints at runtime. There is no live/test split on keys; use `environmentId` (below) and `test: true` to separate traffic.
| Key | Looks like | Lives in | What it does |
|---|---|---|---|
| **Ingest key** | `rtk_…` (type `public`) | Your browser bundle — safe to ship | The browser's key: Playbook delivery, event ingest, client-safe reads. Bound to an origin allowlist that fails closed: a request from an origin not on the list is refused, as is one with no browser origin at all. Cannot call management APIs. Create it at **Settings → API Token Management → Ingest Keys**, and list every origin that loads the SDK, your local dev origin included. |
| **Server key** | `rtk_…` (type `server`) | Your backend only | Programmatic calls to the RevTurbine API from your server — management APIs — and minting the session keys below with `RevTurbineServer`. The SDK's `initRevTurbine` does not read it. Refused on a browser-like request. Create it at **Settings → API Token Management → Server Token**. |
| **Session key** | `rt_client_…` | The browser, in memory, short-lived (~10 minutes) | Lets the browser poll one user's server-derived context — plan, trial, payment state — straight from RevTurbine (`GET /api/sdk/client-context`). Your backend mints it per user with the server key (`POST /api/sdk/client-sessions`); the SDK asks for it through your `clientSession` callback and re-mints on expiry. |
Which one goes where:
- **In the browser**, pass the ingest key as `publicKey`. The name says what it is: a scanner or an agent that finds `publicKey` in a bundle knows it belongs there. As of `0.11.0` it is the **only** name accepted there — `ingestPublicKey` was removed and `apiKey` is no longer read as a browser credential (BL-0113), so a browser init without `publicKey` fails instead of sending the wrong key. Never a server key: RevTurbine refuses one presented from a browser.
- **On your backend**, pass the server key as `apiKey` to `new RevTurbineServer({ … })`, which mints session keys (read it from `REVTURBINE_API_KEY`, as [Client sessions](/revturbine-sdk/guides/client-sessions/) does). It is not an option of `initRevTurbine`: your server's checks run from a Playbook snapshot, see [Server-Side Integration](/revturbine-sdk/getting-started/server-side/).
- **For server-derived context in the browser**, hand the SDK a `clientSession` callback that returns a session key from your backend. See [Configuration → Server-derived context](/revturbine-sdk/reference/configuration/#server-derived-context-clientsession).
- **Local mode needs no key at all.**
Two further credentials exist for tooling, not for SDK wiring: the CLI's own `rtk_…` (type `client`) from `revturbine login`, one per machine; and an `rtk_…` (type `mcp`) for agent tooling, from **Settings → API Token Management → MCP Tokens**.
Stamp `environmentId` (`'production'` / `'staging'`) so analytics stays separated by deployment, and `test: true` on instances that generate test traffic.
## Versioning
Pin `@revturbine/sdk` and the CLI per project; `revturbine --help` from the project-pinned CLI is authoritative for its command surface. A change to any `@public` signature requires a CHANGELOG entry — CI enforces it — and the Playbook schema version the SDK expects is listed on the [Compatibility Matrix](/revturbine-sdk/reference/compatibility/). Pin the SDK to a version whose schema matches the Playbook you deploy.
## No grant, no access
A check that can't justify a grant denies rather than leaking a paid feature — the [reason codes](/revturbine-sdk/guides/entitlements/#fail-closed-semantics) say why. The Playbook is cached locally, so real answers need no round trip, and billing-critical actions are re-checked on your server before value is granted. Tune what a slot renders on provider failure with `providerFailureSlotBehavior` (default: render nothing). See [Error Handling](/revturbine-sdk/guides/error-handling/).
## Local-only limits
In `local_only` mode the SDK evaluates entirely from the bundled Playbook — no network. That's great for tests and no-backend demos, but be aware:
- Usage is **client-reported and client-only** — not authoritative. Don't meter billing off it.
- There are **no real-time config updates** — you ship a Playbook snapshot.
- Events go to `localStorage`, not the ingest pipeline.
See [Runtime Modes](/revturbine-sdk/guides/runtime-modes/) to choose between local mode (`local_only`), hosted mode (`revturbine_server`), and `custom_endpoints`.
## Caching
The SDK caches placement decisions and interaction state per user and persists them, so repeat checks are instant and dismiss/snooze survive reloads. The cache clears on identity change (`identify` / `reset`) and when the config updates. If you see a stale decision after a config change, confirm the user context was re-identified.
## Config versioning
Every placement decision carries the `config_version` it was evaluated against, so you can trace a decision back to the exact Playbook that produced it. A Playbook moves from **draft → Release**, so each launched change is reviewed, versioned, and traceable.
## Test plan changes before you deploy
Because monetization changes ship as config, you can verify them without touching app code:
- **CLI:** validate and diff `./revturbine.playbook.json`, or upload it as a draft and run `preview` plus `evaluate --draft --user ` before launch. The [CLI reference](/revturbine-sdk/reference/cli/) lists the required selectors.
- **`local_only`:** load a candidate `revturbine.playbook.json` in a test build and exercise the gates and placements against fixture users.
- **[Debugging Decisions](/revturbine-sdk/operate/debugging/):** use `explainPlacementDecision()` to see exactly why a decision resolved the way it did.
## Related
- [Client vs Server Enforcement](/revturbine-sdk/concepts/enforcement/)
- [What Owns What](/revturbine-sdk/concepts/source-of-truth/)
- [Debugging Decisions](/revturbine-sdk/operate/debugging/)
- [Error Handling](/revturbine-sdk/guides/error-handling/)
# Interactive Playground
> Every live, editable SDK example in one place — each runs in local_only mode with a demo revturbine.playbook.json. The code is on the page; open any scenario in Sandpack to edit it.
Every scenario below is a live SDK demo running entirely in `local_only` mode — a bundled `revturbine.playbook.json` with plans, entitlements, placements, and targeting, and no decision-time network calls. The code is on the page; press **Open in Sandpack** on any scenario to edit it alongside a live preview, and switch demo users to see the engine respond.
## Fixed Slots
Always-visible inline placements.
### F-1: Upgrade Button
An always-visible upgrade CTA in a nav slot (`button`).
_Live example: /playground/#fixed-button_
### F-2: Plans & Pricing Card
An inline pricing/plans card (`in_page`).
_Live example: /playground/#fixed-in-page_
### F-3: Quota Meter
A usage meter with progress bar and counter (`in_page`).
_Live example: /playground/#fixed-usage-counter_
### F-4: Annual Nudge Banner
An annual-plan nudge banner (`banner`).
_Live example: /playground/#fixed-banner_
## Access Gates
Entitlement gates that render an upsell when access is denied.
### G-1: Data Export Gate
A blocking modal gate on a paid feature (`modal`).
_Live example: /playground/#gate-modal_
### G-2: Branding Gate
An inline gate on a branding feature (`in_page`).
_Live example: /playground/#gate-inline_
### G-3: Brand Kit Gate
A card gate on a brand-kit feature (`in_page`).
_Live example: /playground/#gate-card_
## Global Slots
Ephemeral, moment-driven messages.
### M-1: Usage Warning Banner
A "running low" usage banner (`banner`).
_Live example: /playground/#msg-banner_
### M-2: Usage Exhausted Modal
A limit-reached modal (`modal`).
_Live example: /playground/#msg-modal_
### M-3: Trial Welcome Toast
A trial-welcome toast (`toast`).
_Live example: /playground/#msg-toast_
## Headless API
The imperative SDK, no slot components.
### H-1: PlacementController
Driving a placement imperatively.
_Live example: /playground/#headless-placement_
### H-2: EntitlementGate
An entitlement check without React.
_Live example: /playground/#headless-gate_
### H-3: SdkSession One-Shot
A one-shot session evaluation.
_Live example: /playground/#headless-session_
## How it works
Each scenario runs the SDK in an isolated Sandpack environment:
1. **`revturbine.playbook.json`** provides plans, entitlements, placements, surface templates, and targeting rules.
2. **`RevTurbineProvider`** initializes the SDK in `local_only` mode — no network calls.
3. **Demo users** have different plans, usage, and roles: Alice and Bob are on **Professional**, Carol and Dan on **Starter**, and Eve and Frank on **Enterprise**.
4. **Entitlement rules** bind each entitlement to plans by handle — `data_export`, `branding`, and `brand_kit` are granted on Professional and Enterprise and disabled on Starter. So every Access Gate shows its content for Alice, Bob, Eve, and Frank, and its configured upgrade placement for Carol and Dan.
5. **Targeting rules** evaluate against the user context to decide which placements to show.
Switch demo users in a scenario's code to see how the SDK renders different placements for different segments.
# CLI Reference
> Install and pin @revturbine/cli, move a Playbook from draft to Release, select config versions, and generate typed handles.
The public `@revturbine/cli` package installs the `revturbine` command. It
validates Playbooks offline, manages drafts and Releases, evaluates decisions,
generates typed handles, and exposes hosted analytics and ingest-key workflows.
## Install and pin it
RevTurbine requires Node.js **22.13 or newer**. Pin the CLI in each repository
that owns a Playbook; its version-stamped schema snapshot then travels with the
lockfile into local development, CI, and coding-agent sandboxes.
```bash
npm install -D --save-exact @revturbine/cli
npx revturbine --version
```
Use `pnpm add -D --save-exact @revturbine/cli` or
`yarn add -D --exact @revturbine/cli` for those package managers. A global
install is optional for ad-hoc work. The pinned CLI warns when another
`revturbine` version is running inside the repository.
## The default Playbook loop
The canonical filename is `revturbine.playbook.json`. `launch ` performs
the guarded file-to-Release path in one operation: validate, stage, and launch.
```bash
revturbine download --live --save ./revturbine.playbook.json
# edit ./revturbine.playbook.json
revturbine validate ./revturbine.playbook.json
revturbine diff ./revturbine.playbook.json --live
revturbine launch ./revturbine.playbook.json
```
For a separately reviewable draft, use the two-step form:
```bash
revturbine upload ./revturbine.playbook.json
revturbine preview
revturbine validate --draft
revturbine launch --draft
```
`diff --live` uses live as the base. A `+` is created on launch; a `-`
is pruned on launch unless `--no-prune` is selected.
## First upload of a starter Playbook
A starter file may contain a placeholder or origin tenant. After login, select
the intended tenant explicitly for the first upload:
```bash
revturbine validate --offline ./revturbine.playbook.json
revturbine upload ./revturbine.playbook.json -t YOUR_TENANT_ID
revturbine validate --draft -t YOUR_TENANT_ID
```
Review the resulting draft before launching. `upload` stages a draft; it does
not launch it. `-t`/`--tenant-id` selects the destination but does not grant
permission to a tenant your credential cannot access. Do not confuse an SDK
public key with a CLI login credential.
## Evaluate without confusing it with the application
`evaluate` fetches the selected Playbook and runs the engine locally; RevTurbine
does not host a decision endpoint. Supply a user-context file and one version
selector, such as `--draft --user ./user.json --entitlement data_export`.
Use `--slot` and, when needed, `--component-type` for placement checks.
This is a clean evaluation with no live browser suppression or impression
history. It does not render React, execute a CTA, or reproduce a mounted Gate's
complete denial-and-placement lifecycle. There is no `--gate-placement` option:
verify the entitlement and placement inputs separately, then test the real Gate
in your application. Use the installed version's `evaluate --help` for its
supported flags.
## Version selectors and positional files
Commands never guess which config version you mean.
| Command | Accepted config input |
|---|---|
| `download` | exactly one of `--draft`, `--live`, `--release `; `--save ` writes it |
| `validate` | one or more positional files, or `--draft` |
| `diff` | exactly two from positional files, `--draft`, `--live`, `--release ` |
| `show ` | one positional file or one server selector |
| `upload ` | one required positional Playbook file |
| `launch [file]` | one positional file or `--draft` |
| `generate types [file...]` | one positional file or one server selector |
| `preview` | the open draft; it takes no config file |
| `evaluate` | `--draft`, `--live`, or `--release `; `--user ` is user context, not a Playbook |
Run `revturbine --help` for the complete option set from the version
your repository pins.
## Command inventory
| Command | Purpose |
|---|---|
| `init` (`create`) | Detect the app, install the SDK, pin the CLI, add a starter Playbook, and install agent skills |
| `signup`, `login`, `logout`, `whoami` | Create an account and manage device-flow credentials |
| `schema`, `docs` | Emit the bundled schema or print the canonical docs URL |
| `download`, `validate`, `diff` | Retrieve and inspect Playbook versions without launching |
| `show ` | Summarize plans, entitlements, segments, placements, or trials |
| `upload`, `preview`, `launch`, `discard` | Manage the open draft and produce a Release |
| `restore`, `status`, `history` | Restore or inspect Releases and draft state |
| `evaluate` | Evaluate entitlement or placement decisions locally against a selected server version |
| `generate types` | Generate typed Playbook handle objects and literal unions |
| `analytics catalog`, `templates`, `views`, `view` | Inspect the hosted Semantic Catalog and accessible analytics views |
| `analytics create`, `preview`, `query` | Save, validate, preview, or run canonical analytics-view documents |
| `ingest-keys create`, `list`, `revoke` | Manage public SDK telemetry keys; creation prints the secret once |
Read commands support `--json` where documented. Results go to stdout and
diagnostics go to stderr, so scripts can consume output safely.
## Generate typed handles
Generate after authoring a Playbook and again after a launch changes its
handles:
```bash
revturbine generate types ./revturbine.playbook.json \
--out src/revturbine-handles.ts
```
The generated module contains five families:
- `Entitlements`, grouped by entitlement type, plus the `EntitlementHandle`
union used by `can()`, `gate()`, and `checkEntitlement()`;
- `Plans` and `PlanHandle`;
- `Segments` and `SegmentHandle`;
- `SurfaceTemplates` and `SurfaceTemplateId`;
- `UiPathActionTypes` and `UiPathActionType`.
They are `as const` objects plus literal-union types, deliberately not enums.
The values are the runtime strings the SDK accepts, so no mapping layer is
needed:
```ts
import { Entitlements } from './revturbine-handles';
const handle = Entitlements.feature.data_export;
const result = await rt.can(handle);
```
The generated header records the exact regeneration command and source
selector. Use `--live`, `--draft`, or `--release ` instead of a file when
you want types from a hosted version.
## Related
- [Installation](/revturbine-sdk/getting-started/installation/) — SDK and CLI setup
- [Entitlements](/revturbine-sdk/guides/entitlements/) — use generated handles in checks
- [Runtime modes](/revturbine-sdk/guides/runtime-modes/) — local and hosted SDK behavior
# Compatibility Matrix
> SDK version compatibility, supported browsers, runtimes, and feature support by mode.
## SDK Versions
| SDK Version | Schema Version | API Version | Status |
|---|---|---|---|
| `0.8.x` | `0.1.x` | `v1` | Current |
## Browser Support
| Browser | Minimum Version | Notes |
|---|---|---|
| Chrome | 90+ | Full support |
| Firefox | 90+ | Full support |
| Safari | 15+ | Full support |
| Edge | 90+ | Full support (Chromium-based) |
| iOS Safari | 15+ | Full support |
| Chrome Android | 90+ | Full support |
### Required APIs
The SDK requires these browser APIs:
| API | Used For | Fallback |
|---|---|---|
| `fetch` | Playbook sync, server-derived context, event delivery (not used in `local_only`) | Required — no fallback |
| `localStorage` | Decision cache, interaction state | In-memory fallback |
| `sessionStorage` | Session state | In-memory fallback |
| `JSON.parse/stringify` | Data serialization | Required — no fallback |
| `Promise` | Async operations | Required — no fallback |
## React Requirements
| Dependency | Minimum Version | Recommended |
|---|---|---|
| React | 18.0 | 19.x |
| React DOM | 18.0 | 19.x |
The SDK uses React hooks (`useState`, `useEffect`, `useContext`, `useRef`). React 17 and below are not supported.
## Node.js Requirements
| Package | Runtime | Minimum Version |
|---|---|---|
| `@revturbine/sdk` (web) | Browser | — |
| Server SDK (Node.js) | Node.js | 22.13 |
## Build Tool Compatibility
| Tool | Support | Notes |
|---|---|---|
| Vite | ✅ | Recommended |
| Next.js | ✅ | App Router and Pages Router |
| webpack | ✅ | Version 5+ |
| Parcel | ✅ | Version 2+ |
| esbuild | ✅ | Used internally for bundling |
## Feature Support by Mode
| Feature | `react` | `snippet` | `iframe` |
|---|---|---|---|
| `RevTurbineProvider` | ✅ | — | — |
| React hooks | ✅ | — | — |
| Drop-in slot components | ✅ | — | — |
| Headless controllers | ✅ | ✅ | — |
| Custom slot types | ✅ | — | — |
| Theming | ✅ | ✅ | ✅ |
| Event tracking | ✅ | ✅ | ✅ |
| localStorage persistence | ✅ | ✅ | ✅ |
## Feature Support by Runtime Mode
Decisions are evaluated inside your app in every mode. The modes differ in where the Playbook comes from and where telemetry goes.
| | `revturbine_server` | `local_only` | `custom_endpoints` |
|---|---|---|---|
| Decision evaluation (entitlements, placements, caps) | In your app | In your app | In your app |
| Playbook source | RevTurbine control plane, cached in the SDK, re-checked about once a minute | Bundled with your app | Your endpoints |
| Server-derived context (plan, trial, payment state) | ✅ via client session | Supplied by your app | Your endpoints |
| Usage and events | Sent to RevTurbine ingest | Kept locally | Your endpoints |
| Playbook changes | Live, without a deploy | Redeploy | Your endpoints |
| Cap enforcement | ✅ Client | ✅ Client | ✅ Client |
## TypeScript
| TypeScript Version | Support |
|---|---|
| 5.0+ | ✅ Full support |
| 4.9 | ⚠️ May work, not tested |
| 4.8 and below | ❌ Not supported |
The SDK ships with `.d.ts` type declarations. All public APIs are fully typed.
## Bundle Size
Bundle size depends on the entry point, bundler, and imported components. The
SDK is tree-shakeable, so measure the exact pinned release in your production
build; this page does not publish estimates that can drift from the package.
## Related
- [Installation](/revturbine-sdk/getting-started/installation/) — installation instructions
- [Configuration Reference](/revturbine-sdk/reference/configuration/) — full options specification
# Configuration Reference
> Complete InitOptions specification — all configurable parameters with defaults and mode-specific requirements.
Complete reference for `RevTurbineInitOptions` and related configuration types.
## RevTurbineInitOptions
### Required Fields
In `revturbine_server` and `custom_endpoints` modes the browser needs `publicKey`; `tenantId` is optional because the key names its tenant. In `local_only` mode neither is needed — `initRevTurbine({ runtimeMode: 'local_only', localRuntime: { playbook } })` is a complete configuration. For copy-paste examples per runtime, see [Initialize the SDK](/revturbine-sdk/getting-started/initialization/).
| Field | Type | Description |
|---|---|---|
| `tenantId` | `string` | Your RevTurbine tenant identifier. Optional whenever `publicKey` is supplied: the key is bound to one tenant and the SDK reads the tenant back from the Playbook it fetches. A `tenantId` that disagrees with the key's tenant is ignored with a one-time warning; the key wins. Required by `RevTurbineServer`, where it must be the server key's own tenant. |
| `publicKey` | `string` | In the browser: your public key (`rtk_…`, type `public`, minted under **Settings → API Token Management → Ingest Keys**). Safe to ship in a bundle. Add your app's origins to the key's allowed origins, or Playbook delivery is refused with `403 origin_not_allowed`. See [Production Readiness → Keys](/revturbine-sdk/operate/production-readiness/#keys). |
| `apiKey` | `string` | Your server key (`rtk_…`, type `server`), minted under **Settings → API Token Management → Server Token**. Trusted backend `initRevTurbine` in `RuntimeMode.Server` uses it for Playbook delivery and event ingest; `RevTurbineServer` uses it for client sessions and server-only writes. Evaluation remains local. Never ship it to a browser. As of `0.11.0` it is not accepted as a browser credential — pass `publicKey` there; `ingestPublicKey` was removed (BL-0113). |
### Runtime Configuration
| Field | Type | Default | Description |
|---|---|---|---|
| `runtimeMode` | `'revturbine_server' \| 'custom_endpoints' \| 'local_only'` | `'revturbine_server'` | How the SDK loads its Playbook; evaluation remains local |
| `endpoint` | `string` | `'https://revturbine.com/app'` | RevTurbine control-plane URL; omit it for the hosted service |
| `mode` | `'react' \| 'snippet' \| 'iframe'` | `'snippet'` (`'react'` when `` initializes the SDK) | Integration label carried in telemetry; no decision depends on it |
| `endpointOverrides` | `Partial` | — | Route the SDK's non-decision calls through your own endpoints (`custom_endpoints` mode) |
| `configProvider` | `RevTurbineConfigProvider` | — | Custom provider for the Playbook; implement `getPlaybook()` |
| `localRuntime` | `RevTurbineLocalRuntimeOptions` | — | Local-only mode configuration |
### Provider Configuration
| Field | Type | Default | Description |
|---|---|---|---|
| `provider` | `RevTurbineSdkProvider \| RevTurbineProviderFactory` | — | Primary provider override |
| `providerFallbacks` | `Array<...>` | — | Fallback provider chain |
| `domainProviders` | `AnyDomainProvider[]` | — | Domain-specific providers |
| `providerFailureSlotBehavior` | `'placeholder' \| 'invisible'` | `'invisible'` | Slot behavior after provider failure |
### UI Path Handling
| Field | Type | Default | Description |
|---|---|---|---|
| `uiPathResolvers` | `RevTurbineUiPathResolverMap` | — | Map of CTA action types to resolver functions |
### Branding
| Field | Type | Default | Description |
|---|---|---|---|
| `branding` | `BrandingConfig` | Built-in defaults | Optional workspace name, logo and partial theme. An explicit value takes priority over API branding and legacy config themes. |
See [Theming](/revturbine-sdk/guides/theming/) for an example. Set `colorScheme: 'light'` or `'dark'` in initialization, use the React provider's `colorScheme` prop, or call `session.sdk.setColorScheme(scheme)` in a headless integration (SDK 0.12.4).
### User & Page Context
| Field | Type | Default | Description |
|---|---|---|---|
| `user` | `RevTurbineUserContext` | — | Initial user context. `plan_handle` is the plan's `unique_handle` — the value plan-scoped rules match on |
| `clientSession` | `() => string \| Promise` | — | Mints a client-session token so the SDK keeps server-derived context fresh on its own. See [Server-derived context](#server-derived-context-clientsession) |
| `page` | `RevTurbinePageContext` | — | Page context (URL, title, referrer, tags) |
| `contextPolicy` | `RevTurbineContextPolicy` | `{ inferUser: true, inferPage: true, routerAutoTrack: true }` | Auto-inference behavior |
### Server-derived context (`clientSession`)
Your app tells RevTurbine who the user is. Some of that state, though, is only
knowable on your server — the plan a Stripe webhook just changed, trial status,
a payment that failed. `clientSession` is how the SDK gets it without you
wiring a refresh loop.
Supply a function that returns a client-session token minted by your backend
(`POST /api/sdk/client-sessions`, with the server key; `RevTurbineServer` does
it for you). The SDK calls it when it first needs a
token, again after `identify()`, and again if the control plane reports one
expired — then fetches `GET /api/sdk/client-context` and folds the result into
its decisions. **A purchase updates what the user can do with no further app
code.**
```ts
import { initRevTurbine } from '@revturbine/sdk';
export async function startRevTurbine() {
return initRevTurbine({
publicKey: process.env.NEXT_PUBLIC_REVTURBINE_PUBLIC_KEY!, // public key — safe in the browser
user: { id: 'user_123', plan_handle: 'free' },
clientSession: async () => {
const response = await fetch('/api/revturbine-session', { method: 'POST' });
if (!response.ok) throw new Error(`RevTurbine client session failed (${response.status})`);
const body: { client_token: string } = await response.json();
return body.client_token;
},
});
}
```
The route behind `/api/revturbine-session` is yours: it authenticates your user,
mints with the server key and returns only `client_token`. The complete route
(Next.js and Express), the failure behavior and the refresh rules are on
[Client sessions](/revturbine-sdk/guides/client-sessions/).
It is a **function, not a token**, because these tokens are short-lived (~10
minutes) — a value captured at init would go stale mid-session, and refreshing
it would become your problem.
The token is a transport credential, never user context: held in memory only,
never persisted, never logged, never placed in a URL. If your minter throws or
the fetch fails, the SDK keeps using the context your app supplied — enrichment
is best-effort and never breaks your app.
Omit `clientSession` and none of this happens: no token is requested and no
client-context call is made. Server-derived context is opt-in.
### Behavioral Flags
| Field | Type | Default | Description |
|---|---|---|---|
| `placementBehavior` | `Partial` | derived from the Playbook | Overrides for flags the SDK derives from what the Playbook authors (see below) |
### Storage
| Field | Type | Default | Description |
|---|---|---|---|
| `persistentStorage` | `RevTurbineStorage` | `localStorage` | Persistent storage override |
| `sessionStorage` | `RevTurbineStorage` | `sessionStorage` | Session storage override |
---
## RevTurbineContextPolicy
| Field | Type | Default | Description |
|---|---|---|---|
| `inferUser` | `boolean` | `true` | Auto-detect user info from browser APIs |
| `inferPage` | `boolean` | `true` | Auto-capture URL, title, referrer |
| `routerAutoTrack` | `boolean` | `true` | Track SPA route changes |
---
## RevTurbineLocalRuntimeOptions
| Field | Type | Description |
|---|---|---|
| `playbook` | `Playbook` | Full Playbook snapshot for local execution |
| `placements` | `LocalPlacementDataset` | Optional static placements dataset |
| `initialData` | `object` | Static data for local decisions (see below) |
| `resolvers` | `object` | Optional resolver callbacks (see below) |
| `storageKey` | `string` | Optional localStorage key override |
| `getContext` | `() => Promise` | Reactive context callback |
### initialData
| Field | Type |
|---|---|
| `placementDecisionsByPlacementId` | `Record` |
| `userContextByUserId` | `Record` |
| `trialStatus` | `RevTurbineTrialContext` |
Entitlement grants are evaluated from `localRuntime.playbook`. For a custom
source, implement the documented `checkEntitlement` resolver; do not seed
grants through `initialData`.
Placements work the same way: `getPlacement` is a pure function of the user
context and `localRuntime.playbook`, evaluated through the same decision path
as `getPlacementDecision` and the server SDKs. There is no seeded-output lane:
`initialData.placementsByLookupKey` was removed in 0.12.0 (ignored since
0.11.21). Author the placement in the Playbook instead, or implement the
`getPlacement` resolver for a custom source.
### resolvers
| Field | Signature |
|---|---|
| `getPlacementDecision` | `(input, placement?, context?) => Promise` |
| `getPlacement` | `(config) => Promise` |
| `checkEntitlement` | `(handle, context?) => Promise` |
| `fetchUserContext` | `(userId) => Promise` |
| `getTrialStatus` | `() => Promise` |
| `resolvePlaybook` | `() => Promise` |
---
## RevTurbinePlacementBehaviorFlags
Each flag is derived from the loaded Playbook; an explicit value in `placementBehavior` overrides the derivation. You rarely need to set them.
| Flag | Type | Derived default | Description |
|---|---|---|---|
| `enableClientCapsEnforcement` | `boolean` | `true` when any placement payload authors a cap, cooldown, or remind-later | Client-side cap enforcement |
| `enableAutoGatedPlacement` | `boolean` | `true` when the Playbook has a `gated` placement | Auto-render gated placements |
| `enableTrialAutoTriggers` | `boolean` | `true` when the Playbook has a `trials` placement | Auto-derive trial lifecycle triggers |
---
## RevTurbineEndpointOverrides
The calls the SDK makes over the network, and the key that reroutes each one in `custom_endpoints` mode. None of them is a decision — entitlements and placements are evaluated inside your app in every mode. A relative value is appended to `endpoint`; an absolute URL replaces it.
| Key | What it carries | Default path |
|---|---|---|
| `clientContext` | Server-derived user context, read with an `rt_client_` session token | `/api/sdk/client-context` |
| `userContext` | User context read | `/api/sdk/user-context` |
| `trialStatus` | Trial status read | `/api/sdk/trial-status` |
| `ingestEvents` | Clickstream events (`track` / `capture`) | `/api/track` |
| `touchpointTransition` | Placement interactions — impression, dismiss, CTA | `/api/events/interactions` |
| `ingestSdkMeta` | The anonymous `sdk_init` beacon | `/api/sdk/meta` |
| `surfaceSlots` | Surface-slot inventory registration | `/api/placements` |
| `placementTypes` | Custom placement-type persistence — meaningful only when overridden | `/api/sdk/placement-types` |
Not overridable: **Playbook delivery**. The SDK fetches the launched Playbook from `endpoint` directly (`/api/sdk/bootstrap`, then the signed manifest and bundle), so `custom_endpoints` routes context and telemetry through your proxy but not the Playbook itself.
Keep `endpoint` pointing at RevTurbine and use absolute override URLs for a proxy on another origin. In local mode, import the Playbook checked into your app. In either mode, preserve the validated artifact: do not insert an app endpoint that reshapes or strips Playbook fields before the SDK reads them. See [Runtime Modes](/revturbine-sdk/guides/runtime-modes/#keep-the-playbook-intact).
The type also declares `decide`, `decideContext`, `bootstrapContext`, `getPlacement` and `checkEntitlement`. They are retired: the SDK never reads them, because there is no decision endpoint. They remain on the type so existing configurations still compile.
---
## RevTurbineStorage
Interface for custom storage providers:
```ts
interface RevTurbineStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
```
---
## Mode-Specific Required Fields
### `revturbine_server` (default)
```ts docs-check=false reason="required-field shape sketch, not a value"
{
tenantId?: string; // optional: the key names its tenant
publicKey: string; // ✅ Required (public key, browser). `apiKey` is not read here: a server uses a Playbook snapshot instead
endpoint?: string; // defaults to 'https://revturbine.com/app'
mode?: string; // defaults to 'snippet' ('react' via the provider)
}
```
### `local_only`
```ts docs-check=false reason="required-field shape sketch, not a value"
{
runtimeMode: 'local_only'; // ✅ Required
localRuntime: {
playbook: Playbook; // ✅ Required
};
// tenantId, publicKey, endpoint, mode — optional; no account or key is needed
}
```
### `custom_endpoints`
```ts docs-check=false reason="required-field shape sketch, not a value"
{
tenantId?: string; // optional: the key names its tenant
publicKey: string; // ✅ Required (public key, browser)
endpoint?: string; // defaults to 'https://revturbine.com/app'
mode?: string; // defaults to 'snippet' ('react' via the provider)
runtimeMode: 'custom_endpoints'; // ✅ Required
endpointOverrides: { // ✅ At least one override required
clientContext?: string;
ingestEvents?: string;
touchpointTransition?: string;
};
}
```
## Related
- [Runtime Modes](/revturbine-sdk/guides/runtime-modes/) — mode comparison and migration
- [Error Codes Reference](/revturbine-sdk/reference/errors/) — error handling options
# Error Codes
> Enumerated error and reason codes returned by the SDK, with causes and fixes.
This page lists all error and reason codes the SDK may return, organized by category.
## Placement Reason Codes
Returned in `decision.reasonCodes[]` to explain why a placement was or wasn't shown. This is the complete set the SDK emits — generated from the public reason contract (`tests/reason-contract.json` in revturbine-sdk-internal), the same file [`reason-contract.test.ts`](https://github.com/revt-eng/revturbine-sdk-internal/blob/main/web-sdk/reason-contract.test.ts) enforces against live SDK fixtures. Do not hand-edit the table below; run `pnpm gen:reason-codes` (from `pages-build/`) after the contract changes.
| Code | Meaning |
|---|---|
| `config_unavailable` | The Playbook could not be fetched, or a fetch is still in flight — the SDK has no config to decide against yet. |
| `entitlement_gate_unmet` | The candidate's entitlement-gate trigger did not match the user's current entitlement state. |
| `entitlement_not_denied` | The Access Gate placement's entitlement is not denied or limited for this user, so the gate does not fire. |
| `gate_outside_access_gate` | An Access Gate placement was requested from a slot that is not an Access Gate slot; gates render only in gate slots. |
| `no_eligible_candidate` | No candidate placement survived eligibility filtering for this slot. |
| `no_gate_for_entitlement` | The Access Gate slot asks for an entitlement that no gate placement is authored for, so the slot shows its access-denied placeholder. |
| `no_resolver_configured` | No placement resolver is configured, so the SDK has nothing to evaluate against. |
| `placement_not_found` | No candidate output matched the requested placement id or name. |
| `placement_not_registered` | The payload targets a placement handle the app never registered with the SDK. |
| `placement_retired` | The candidate's rule is hidden — for example, superseded by a conversion — per impression history. |
| `plan_target_mismatch` | The user's plan or billing period doesn't match the candidate's plan targeting. |
| `qualifier_trigger_unmet` | The candidate's qualifier trigger condition was not satisfied for the user's plan or category. |
| `sdk_disabled_provider_failure` | The SDK disabled itself after its configured providers failed, so it fails closed rather than risk showing something wrong. |
| `segment_target_mismatch` | The user doesn't belong to any of the candidate's target segments. |
| `suppressed_by_dismiss_cooldown` | The user dismissed this placement and its cooldown window has not elapsed. |
| `suppressed_by_payload_cap_day` | The payload's per-day impression cap has been reached. |
| `suppressed_by_payload_cap_lifetime` | The payload's lifetime impression cap has been reached. |
| `suppressed_by_payload_cap_month` | The payload's per-month impression cap has been reached. |
| `suppressed_by_payload_cap_session` | The payload's per-session impression cap has been reached. |
| `suppressed_by_payload_cap_week` | The payload's per-week impression cap has been reached. |
| `suppressed_by_payload_cooldown` | The payload is still inside its configured cooldown window since it was last shown. |
| `suppressed_by_presentation_cap` | Every eligible candidate is over an overall presentation cap rule from Placement Settings. |
| `suppressed_by_system_cooldown` | Every eligible candidate is a discretionary nudge inside the session cooldown since the last one shown. |
| `suppressed_until_remind_window` | The user chose "remind me later" and that reminder window has not elapsed yet. |
| `threshold_trigger_unmet` | The candidate's threshold trigger did not match the user's current entitlement usage. |
| `trial_trigger_unmet` | The candidate's trial trigger did not match the user's trial state. |
| `unknown_trigger_type` | The candidate uses a trigger type this SDK does not recognize, so it is not shown. |
:::note[Client-side cap enforcement]
When `enableClientCapsEnforcement` is on, the SDK also ticks `cap.v1`
presentation-cap policies locally and can add `cap_exceeded` to
`reasonCodes` on a cache-hit re-evaluation once the local budget is
consumed. This is a client-side fallback default distinct from the
`suppressed_by_payload_cap_*` family above (which come from the evaluated
Playbook itself) and from the protected reason contract, so it is called out
here rather than generated into the table.
:::
## Entitlement Reason Codes
Returned in `result.reason` to explain the entitlement check outcome.
Entitlement checks are [**fail-closed**](/revturbine-sdk/guides/entitlements/#fail-closed-semantics): when the SDK cannot produce an
affirmative grant it denies and names the cause. This is the complete emitted
set — see [Error handling](/revturbine-sdk/guides/error-handling/) for the enforcement-mode
suffixes on the two limit codes.
| Code | Meaning | Fix |
|---|---|---|
| `no_matching_entitlement_rule` | No rule grants this entitlement to the user's plan — check **denied** | Add an entitlement rule targeting that plan, or check the user's `plan_handle` is the plan's `unique_handle` |
| `feature_not_enabled_for_plan` | A matching `feature` rule has `enabled: false` — check **denied** | Enable the rule for that plan, or upgrade the user |
| `usage_limit_reached` | At or over a `usage_limit` rule's limit | Report accurate usage via `updateUsage()`; raise the limit or change `enforcement` |
| `credit_balance_exhausted` | At or over a `credits` rule's allowance | Grant more credits or change `enforcement` |
| `config_unavailable` | The launched Playbook could not be fetched (Server mode) — check **denied** | Check network connectivity; the reason distinguishes a fetch failure from a real denial |
| `entitlement_not_in_playbook` | Local mode with no Playbook and no cached result — check **denied** | Add the entitlement to the Playbook fixture |
| `sdk_disabled_provider_failure` | The SDK disabled itself after a provider failure — check **denied** | Check API keys, endpoints, and network |
| `granted_by_reverse_trial` | **Allowed** by an active reverse trial rather than by the plan | None — expected during a reverse trial |
:::note[Renamed in 0.3.0]
`local_runtime_default_allow` → `entitlement_not_in_playbook`, no deprecated
alias. The old name stated a verdict the result does not have (it denies).
:::
## Provider Errors
| Error | Source | Meaning |
|---|---|---|
| `provider_chain_exhausted` | All providers failed | Check API keys, endpoints, and network |
| `config_fetch_failed` | Playbook could not be loaded | Verify `configProvider` or API endpoint |
| `invalid_api_key` | API returned 401 | Check the `publicKey` (browser) or `apiKey` (server) value and key status |
| `tenant_not_found` | API returned 404 | Verify `tenantId` value |
## Interaction Errors
| Error | Context | Meaning |
|---|---|---|
| `interaction_tracking_failed` | `trackTreatmentInteraction()` | Event delivery failed — silently dropped |
| `event_delivery_failed` | `trackEvent()` | Custom event could not be sent — buffered for retry |
| `network_error` | Interaction-flush fetch | The touchpoint-transition request threw (offline, DNS, CORS) — the batch is re-queued and retried |
## Storage Errors
| Error | Context | Meaning |
|---|---|---|
| `storage_unavailable` | localStorage/sessionStorage | Browser storage not accessible — using in-memory fallback |
| `storage_quota_exceeded` | `setItem()` failed | Clear old entries or use custom storage provider |
## Debugging
Enable verbose logging to see all reason codes and errors:
```ts
localStorage.setItem('revturbine:debug', 'true');
```
Errors and reason codes are also available programmatically:
```tsx
const { decision, error } = usePlacement({ placement: { name: 'hero_banner' } });
// Hook-level error (string)
console.log(error);
// Decision-level reason codes
console.log(decision?.reasonCodes);
```
## Related
- [Error Handling Guide](/revturbine-sdk/guides/error-handling/) — patterns and strategies
# Playbook Field and Reference Map
> The 29 body fields, their roles, and the references connecting policy and presentation.
A Playbook is a versioned document, not a flat list of entitlements. Its envelope
identifies the artifact, tenant, environment, and schema version; its body holds
policy, targeting, and presentation. This map describes the 29 top-level body
keys in the schema pinned by SDK 0.12.4 documentation (`0.1.520`). Their key set
also matches the scaffold source reviewed on October 23, 2026.
## The main reference paths
Arrows point from a record that contains a reference to the record it names.
This overview groups related fields; the tables below provide their exact names.
## Body field inventory
“Array” means a collection of records, not a promise that each record is an
independent runtime entity. Optional fields may be absent. Compatibility and
authoring-only fields are not instructions to build new integrations around them.
| Field | Shape | Purpose |
| --- | --- | --- |
| `plans` | Array | Base plan definitions, identified by `unique_handle`. |
| `addons` | Array | Add-on definitions referenced by entitlement targets. |
| `entitlements` | Array | Named access or parameter capabilities and their kinds. |
| `entitlement_rules` | Array | Rules binding an entitlement to plan/add-on targets and optional segments. |
| `segments` | Array | Named targeting predicates over context. |
| `content_ui_paths` | Array | Authored CTA action definitions and routing parameters. |
| `slot_configs` | Array | Deprecated compatibility field; activation/trigger state belongs to the SDK. |
| `content_overrides` | Object | Deprecated compatibility map; author Message Block/Payload content instead. |
| `theme` | Object | Legacy branding compatibility data; branding is independently owned. |
| `placement_slots` | Array | Authored locations into which matching placements may render. |
| `message_blocks` | Array | Reusable content shaped for a surface template. |
| `placement_payloads` | Array | Content selections, targeting, actions, and presentation for placements. |
| `placements` | Array | Placement category, triggers, targeting, and content associations. |
| `content_promotions` | Array | Authored promotions that content and actions can reference. |
| `personalization_tokens` | Array | Named substitutions used in content. |
| `surface_templates` | Array | Presentation templates referenced by message blocks and slots. |
| `free_trial_rules` | Array | Authoring definitions for opt-in/free trial behavior. |
| `reverse_trial_rules` | Array | Authoring definitions for reverse trials and their fallback/conversion plans. |
| `objectives` | Array | Authoring-only business labels; not runtime decision inputs. |
| `plan_variations` | Array | Plan-owned pricing variations, with optional segment eligibility. |
| `addon_variations` | Array | Add-on-owned pricing variations, with optional segment eligibility. |
| `extension_rules` | Array | Tagged extension payloads; unknown mandatory kinds must be rejected by readers. |
| `seat_types` | Array | Seat-type policy definitions for account-scoped access. |
| `enforcement_defaults` | Array | Configured enforcement defaults used by applicable policies. |
| `placement_settings` | Array | Placement presentation and selection settings. |
| `segment_dimensions` | Array | Segment-dimension declarations; field presence does not imply a normalized bundle table. |
| `meter_bindings` | Array | Bindings connecting policy to meter definitions. |
| `experiments` | Array | Reserved compatibility placeholder; the current schema requires an empty array. |
| `signal_catalog` | Object | Reserved compatibility placeholder; the current schema requires an empty strict object. Signal definitions remain on the control plane and are not shipped to the browser. |
## References use the target's declared identity
Do not assume a field ending in `_id` is a database UUID. Portable references
often use handles. These examples follow the schema's reference graph:
| Referencing field | Target identity | Meaning |
| --- | --- | --- |
| Rule `entitlement_id` | Entitlement `unique_handle` | The capability this rule configures. |
| Rule `targets[].id`, kind `plan` | Plan `unique_handle` | Applicable plan. |
| Rule `targets[].id`, kind `addon` | Add-on `unique_handle` | Applicable add-on. |
| Rule segment references | Segment `handle` | Eligibility restriction. |
| Variation `plan_handle` / `addon_handle` | Parent `unique_handle` | Owning plan or add-on. |
| Variation `segment_handle` | Segment `handle` | Optional price eligibility. |
| Trial plan, fallback, or conversion references | Plan handle | Trial entry/exit plan policy. |
| Placement trigger entitlement / slot references | Entitlement handle / slot ID | Which event or location can activate the placement. |
| Payload segment-content entries | Segment handle | Conditional content selection. |
| Payload content/action references | Message block, UI path, promotion identities | Content and action to resolve. |
| Message block template reference | Surface template ID | Rendering shape. |
| UI-path promotion / placement references | Referenced promotion / placement identity | Destination or offer for the action. |
Variation-level rule target enum members exist for compatibility but are not a
recommendation to author new variation-level entitlement targeting. Use the
supported plan/add-on targeting model. Removing a segment constraint can widen
access; do not silently drop a missing reference. Validate the whole document
with the CLI before upload and review the diff before launch.
The compiler's 13 normalized bundle tables are a projection of this document,
not the complete list of Playbook body fields. A body field's presence does not
prove it is lowered into an independent table or read by every language port.
See [runtime and Playbook](/revturbine-sdk/concepts/runtime-and-playbook/) for the distinction
between authored policy and the per-user facts evaluated with it.
# Tutorial: Show a Banner Placement
> End-to-end tutorial — render a targeted, dismissible banner (an upsell or announcement) with a CTA, controlled from your config.
In this tutorial, you'll render a **banner** at the top of your app — a targeted upsell or announcement with a call-to-action — controlled entirely from your config. The banner shows only for the users your config targets, and renders nothing for everyone else. Takes about 10 minutes.
## What You'll Build
A header banner that:
- Renders a placement in a fixed slot via ``
- Shows only to a targeted audience (here, Pro users), and nothing otherwise
- Has a dismissible CTA that opens your checkout flow
## Prerequisites
- A React 18+ application with `@revturbine/sdk` installed
- A `RevTurbineProvider` set up (see [Quickstart](/revturbine-sdk/))
## Steps
1. **Add a banner placement to your config**
This is a minimal but complete `Playbook` — author the same shape in the [dashboard](https://revturbine.com/app) or with the CLI. The banner is a `fixed` placement bound to a named slot, targeted at the Pro plan.
```json title="revturbine.playbook.json"
{
"artifact_type": "playbook",
"format_version": "1.0.0",
"plans": [
{ "unique_handle": "pro", "name": "Pro", "tier_position": 1, "sort_order": 1 }
],
"entitlements": [],
"entitlement_rules": [],
"segments": [],
"content_ui_paths": [],
"surface_templates": [
{ "id": "banner_placement", "surface_type": "banner", "fields": [] }
],
"placements": [
{
"id": "pl_annual_upsell",
"name": "Annual billing upsell",
"category": "fixed",
"trigger": { "type": "surface_render", "slot_id": "app_header_banner" },
"payloads": [
{
"id": "pl_annual_upsell_p0",
"target": { "plan_ids": ["pro"], "segment_chips": [] },
"surfaces": [
{
"template_id": "banner_placement",
"fields": {
"header": "Save 20% with annual billing",
"body": "Switch your Pro plan to annual and get two months free.",
"dismissible": "Yes"
},
"ctas": [
{ "label": "Switch to annual", "path": "open_checkout", "config": { "purchase": "pro_annual" } }
]
}
],
"caps": {},
"status": "active"
}
],
"order": 0
}
]
}
```
2. **Drop the slot into your header**
`` renders the matching placement for the current user — or nothing. The `id` matches the placement trigger's `slot_id`.
```tsx title="src/components/AppHeader.tsx"
import { Slot } from '@revturbine/sdk';
export function AppHeader() {
return (
{/* Renders the targeted banner, or nothing */}
);
}
```
3. **Provide the user context that targeting reads**
The placement targets `plan_ids: ["pro"]`, so the banner only renders for Pro users. Pass the user's plan in the provider; the banner's CTA dispatches through the UI path resolvers you registered at init.
```tsx title="src/App.tsx"
```
## How It Works
1. `` asks the decision engine which placement wins for `app_header_banner`.
2. The engine evaluates the payload's `target` against the current user (plan, segments). If nothing matches, the slot renders nothing — your header is unchanged.
3. The slot renders the surface's `fields` (header, body) and CTA; clicking the CTA dispatches through your resolvers (see [Quickstart](/revturbine-sdk/)).
4. Dismissing the banner records the interaction, so any `caps` (cooldowns, max-per-period) apply on the next render.
**Tip:**
A banner is a **fixed** placement — it renders wherever you place the slot, filtered by targeting. For a banner that fires on a *condition* (credits running low, a failed payment), use a trigger instead — see [Warn Users About Low Credits](/revturbine-sdk/tutorials/low-credits-warning/) and [Recover a Failed Payment](/revturbine-sdk/tutorials/payment-recovery/).
## Try It Live
> [Interactive Playground → Banner Slot](/revturbine-sdk/playground/#fixed-banner)
## Next Steps
- [Placements Guide](/revturbine-sdk/guides/placements/) — targeting, triggers, and surface types
- [Tutorial: Add an Upgrade Button](/revturbine-sdk/tutorials/upgrade-button/) — a persistent CTA button instead of a banner
- [Component Gallery](/revturbine-sdk/components/) — every built-in slot component
# Tutorial: Gate a Premium Feature
> End-to-end tutorial — check an entitlement, block access for free users, and show an upgrade modal.
In this tutorial, you'll gate a premium feature (video export) behind an entitlement check and show an upgrade modal when access is denied. Takes about 10 minutes.
## What You'll Build
A video export button that:
- Checks the `data_export` entitlement before allowing access
- Shows an upgrade modal when the user doesn't have the entitlement
- Works instantly in local mode, no API calls needed
## Prerequisites
- A React 18+ application with `@revturbine/sdk` installed
- A `RevTurbineProvider` set up (see [Quickstart](/revturbine-sdk/))
## Steps
1. **Add entitlement and gate placement to your config**
Add to your `revturbine.playbook.json`:
```json title="revturbine.playbook.json"
{
"artifact_type": "playbook",
"format_version": "1.0.0",
"plans": [
{ "unique_handle": "free", "name": "Free", "tier_position": 0, "sort_order": 0 },
{ "unique_handle": "professional", "name": "Professional", "tier_position": 1, "sort_order": 1 }
],
"entitlements": [
{ "unique_handle": "data_export", "name": "Data Export", "type": "feature" }
],
"entitlement_rules": [
{
"id": "er_data_export_pro",
"entitlement_id": "data_export",
"targets": [{ "kind": "plan", "id": "professional" }],
"segment_ids": [],
"kind": "feature",
"enabled": true
}
],
"segments": [],
"content_ui_paths": [],
"surface_templates": [
{ "id": "modal_overlay", "surface_type": "modal", "fields": [] }
],
"placements": [
{
"id": "pl_export_gate",
"name": "Export gate",
"category": "gated",
"trigger": { "type": "entitlement_gate", "entitlement_handle": "data_export" },
"payloads": [
{
"id": "pl_export_gate_p0",
"target": { "plan_ids": ["free"], "segment_chips": [] },
"surfaces": [
{
"template_id": "modal_overlay",
"fields": {
"modal_type": "Blocking",
"header": "Unlock Data Export",
"body": "Video export is available on the Professional plan. Upgrade to export your projects in MP4, WebM, and GIF formats."
},
"ctas": [
{ "label": "View Plans", "path": "open_checkout", "config": { "purchase": "professional" } },
{ "label": "Maybe later", "path": "dismiss" }
]
}
],
"caps": {},
"status": "active"
}
],
"order": 0
}
]
}
```
2. **Create the gated component**
```tsx
// src/components/ExportPanel.tsx
import { Gate } from '@revturbine/sdk';
function ExportControls() {
return (
Export Settings
);
}
export function ExportPanel() {
return (
{/* This only renders when the entitlement is granted */}
);
}
```
3. **Use it in your page**
```tsx
// src/pages/Editor.tsx
import { ExportPanel } from '../components/ExportPanel';
export default function Editor() {
return (
Video Editor
{/* ... editor UI ... */}
Export
);
}
```
4. **Test with different users**
```tsx
// Free user → sees upgrade modal
;
// Professional user → sees export controls
;
```
## How It Works
The `` component:
1. Checks the `data_export` entitlement against the user's plan
2. If **granted** → renders the children (`ExportControls`)
3. If **denied** → resolves the gate placement and renders the upgrade modal
The gate check happens instantly in `local_only` mode using the Playbook.
## Alternative: Hook-Based Approach
For more control, use the `useEntitlement` hook directly:
```tsx
import { useEntitlement, usePlacement } from '@revturbine/sdk';
function ExportPanel() {
const { allowed, denied } = useEntitlement({
handle: 'data_export',
});
const { visible, content, dismiss, ctaClick } = usePlacement({
surfaceSlot: {
id: 'editor_export_action',
surfaceTemplateIds: ['modal_overlay'],
},
autoLoad: denied, // Only load placement if denied
});
if (allowed) return ;
if (visible) {
return (
{content?.header}
{content?.body}
);
}
return ;
}
```
## Try It Live
> [Interactive Playground → Data Export Gate](/revturbine-sdk/guides/entitlements/#gate-modal)
## Next Steps
- [Tutorial: Track Usage and Show a Quota Meter](/revturbine-sdk/tutorials/usage-quota-meter/) — usage-based entitlements
- [Entitlements Guide](/revturbine-sdk/guides/entitlements/) — all entitlement types and patterns
# Tutorial: Warn Users About Low Credits
> End-to-end tutorial — show a running credit balance and surface a warning when a user's credits run low, with a top-up CTA.
In this tutorial, you'll display a user's remaining **credits** and surface a warning placement when the balance runs low — at 80% consumed — so users top up before they're blocked. Takes about 15 minutes.
## What You'll Build
A credits flow that:
- Reports credit consumption to the SDK with `update()`
- Reads the remaining balance with `useEntitlement()`
- Fires a low-credits warning placement when consumption crosses 80%
## Prerequisites
- A React 18+ application with `@revturbine/sdk` installed
- A `RevTurbineProvider` set up (see [Quickstart](/revturbine-sdk/))
## Steps
1. **Add a credits entitlement and a low-credits placement**
A `credits` entitlement with a per-plan allowance, plus a `usage_credit_seat` placement triggered by `credit_threshold` at 80%. This is a complete, valid `Playbook`.
```json title="revturbine.playbook.json"
{
"artifact_type": "playbook",
"format_version": "1.0.0",
"plans": [
{ "unique_handle": "free", "name": "Free", "tier_position": 0, "sort_order": 0 },
{ "unique_handle": "pro", "name": "Pro", "tier_position": 1, "sort_order": 1 }
],
"entitlements": [
{ "unique_handle": "credits", "name": "Render Credits", "type": "credits", "unit": "credits" }
],
"entitlement_rules": [
{
"id": "er_credits_free",
"entitlement_id": "credits",
"targets": [{ "kind": "plan", "id": "free" }],
"segment_ids": [],
"kind": "credits",
"allowance_value": 20,
"rollover_enabled": false,
"max_balance": null
}
],
"segments": [],
"content_ui_paths": [],
"surface_templates": [
{ "id": "banner_placement", "surface_type": "banner", "fields": [] }
],
"placements": [
{
"id": "pl_credits_low",
"name": "Low credits warning",
"category": "usage_credit_seat",
"trigger": { "type": "credit_threshold", "entitlement_handle": "credits", "threshold_percent": 80 },
"payloads": [
{
"id": "pl_credits_low_p0",
"target": { "plan_ids": ["free"], "segment_chips": [] },
"surfaces": [
{
"template_id": "banner_placement",
"fields": {
"header": "{{usage_remaining}} credits left",
"body": "Top up to keep rendering.",
"dismissible": "Yes"
},
"ctas": [
{ "label": "Buy credits", "path": "open_checkout", "config": { "purchase": "credit_pack" } }
]
}
],
"caps": { "max_per_period": { "count": 1, "period": "day" } },
"status": "active"
}
],
"order": 0
}
]
}
```
2. **Report credit usage as it's consumed**
Each time a user spends credits, report the new running total. This re-evaluates the warning's threshold.
```tsx title="src/hooks/useRender.ts"
import { useRevTurbine } from '@revturbine/sdk';
import { useCallback, useRef } from 'react';
export function useRender() {
const { sdk } = useRevTurbine();
const spent = useRef(0);
return useCallback((cost: number) => {
spent.current += cost;
sdk?.update({ usage: { credits: spent.current } });
}, [sdk]);
}
```
3. **Show the remaining balance**
Read the live balance with `useUsageSnapshot` — the `credits` entry reflects every `update()` call.
```tsx title="src/components/CreditBadge.tsx"
import { useUsageSnapshot } from '@revturbine/sdk';
export function CreditBadge() {
const { usage } = useUsageSnapshot();
const credits = usage['credits']; // { current, limit? }
if (!credits) return null;
const remaining =
credits.limit != null ? Math.max(0, credits.limit - credits.current) : undefined;
const low = credits.limit != null && credits.current / credits.limit >= 0.8;
return (
{remaining} / {credits.limit} credits
);
}
```
`limited` flips to `true` when the balance is **at or past** the allowance — not as it approaches one. There is no "approaching" state: to warn before the cap, compare `current` against `limit` yourself (the placement below triggers at 80%). And read `allowed` before granting access — under the default enforcement a `limited` result is `allowed: false`.
4. **Render the warning slot**
Place the slot where the banner should appear. It stays hidden until consumption crosses 80%, then renders the warning with the balance interpolated into the copy via `{{usage_remaining}}`.
```tsx title="src/components/AppHeader.tsx"
import { Slot } from '@revturbine/sdk';
export function AppHeader() {
return (
);
}
```
## How It Works
1. `update()` updates the SDK's credit balance and re-evaluates placements.
2. The `credit_threshold` trigger keeps the placement hidden until 80% of the allowance is consumed.
3. Token expansion fills `{{usage_remaining}}` from the live balance.
4. The CTA opens your top-up flow through the resolvers from [Quickstart](/revturbine-sdk/).
**Caution:**
Credits are revenue-critical. In `local_only` mode the balance is client-side and advisory. Before charging for a top-up or letting a user spend credits they may not have, confirm the balance server-side with `can` — see the [Entitlements guide](/revturbine-sdk/guides/entitlements/).
## Try It Live
> [Interactive Playground → Message Banner](/revturbine-sdk/playground/#msg-banner)
## Next Steps
- [Entitlements Guide](/revturbine-sdk/guides/entitlements/) — credits, usage limits, and the `limited` state
- [Tutorial: Track Usage and Show a Quota Meter](/revturbine-sdk/tutorials/usage-quota-meter/) — the usage-limit equivalent
- [Component Gallery](/revturbine-sdk/components/) — every built-in slot component
# Tutorial: Recover a Failed Payment
> End-to-end tutorial — show a persistent, non-dismissible banner that prompts users to fix a failed payment before they lose access.
In this tutorial, you'll surface a **payment recovery** banner to users whose last charge failed — a persistent, non-dismissible prompt to update their payment method before their subscription lapses. Takes about 10 minutes.
## What You'll Build
A dunning banner that:
- Targets only users in a failed-payment state via a segment
- Stays visible (non-dismissible) until the issue is resolved
- Links to your billing portal to update the card on file
## Prerequisites
- A React 18+ application with `@revturbine/sdk` installed
- A `RevTurbineProvider` set up (see [Quickstart](/revturbine-sdk/))
- A way to know a user's billing status (e.g. a Stripe webhook updating your backend)
## Steps
1. **Add a billing-failed segment and a recovery placement**
A segment matches users whose `billing_status` is `payment_failed`; the `retention` placement targets that segment and is non-dismissible. This is a complete, valid `Playbook`.
```json title="revturbine.playbook.json"
{
"artifact_type": "playbook",
"format_version": "1.0.0",
"plans": [],
"entitlements": [],
"entitlement_rules": [],
"segments": [
{
"id": "seg_billing_failed",
"name": "Payment failed",
"handle": "seg_billing_failed",
"predicates": [
{ "field": "billing_status", "operator": "eq", "value": "payment_failed" }
]
}
],
"content_ui_paths": [],
"surface_templates": [
{ "id": "banner_placement", "surface_type": "banner", "fields": [] }
],
"placements": [
{
"id": "pl_payment_recovery",
"name": "Payment recovery",
"category": "retention",
"trigger": { "type": "qualifier", "qualifier": "payment_failed" },
"payloads": [
{
"id": "pl_payment_recovery_p0",
"target": { "plan_ids": [], "segment_chips": ["seg_billing_failed"] },
"surfaces": [
{
"template_id": "banner_placement",
"fields": {
"header": "Your last payment failed",
"body": "Update your payment method to keep your subscription active.",
"dismissible": "No"
},
"ctas": [
{ "label": "Update payment", "path": "update_payment_method" }
]
}
],
"caps": {},
"status": "active"
}
],
"order": 0
}
]
}
```
2. **Pass the user's billing status into the SDK**
The placement's `payment_failed` trigger reads the user context's `payment_failed` flag, and its segment reads `billing_status`. Feed both from your backend, and update them whenever your payment provider's webhook fires.
```tsx title="src/App.tsx"
```
3. **Render the banner slot**
```tsx title="src/components/AppHeader.tsx"
import { Slot } from '@revturbine/sdk';
export function AppHeader() {
return (
{/* Visible only while billing_status is payment_failed; non-dismissible */}
);
}
```
4. **Clear it on recovery**
When the payment succeeds, clear the flag and set `billing_status` back to `active`; the banner stops rendering automatically — no manual teardown.
```ts
sdk.update({ payment_failed: false, custom: { billing_status: 'active' } });
```
## How It Works
1. The `payment_failed` qualifier trigger fires while the user context's `payment_failed` flag is `true`.
2. The `seg_billing_failed` segment matches users whose `billing_status` context equals `payment_failed`, and the placement targets that segment via `segment_chips`, so only affected users see the banner.
3. `"dismissible": "No"` keeps it visible — dunning prompts shouldn't be dismissible.
4. Clearing the flag and setting `billing_status` to `active` takes the user out of both, and the banner disappears on the next evaluation.
**Caution:**
The SDK targets and renders the banner; it doesn't process payments. Drive `billing_status` from your billing provider's source of truth (e.g. a Stripe `invoice.payment_failed` / `invoice.paid` webhook) so the banner reflects reality.
## Try It Live
> [Interactive Playground → Banner Slot](/revturbine-sdk/playground/#fixed-banner)
## Next Steps
- [Placements Guide](/revturbine-sdk/guides/placements/) — segments, targeting, and triggers
- [Tutorial: Show a Banner Placement](/revturbine-sdk/tutorials/banner-placement/) — targeted banners
- [Tutorial: Show a Trial-Ending Countdown](/revturbine-sdk/tutorials/trial-ending-nudge/) — another lifecycle nudge
# Tutorial: Show a Trial-Ending Countdown
> End-to-end tutorial — surface a countdown banner in the final days of a trial to convert users before it expires.
In this tutorial, you'll show a **trial-ending** banner that counts down the final days of a user's trial and prompts them to upgrade before it expires. Takes about 10 minutes.
## What You'll Build
A trial countdown that:
- Fires only in the final window of a trial (the last 3 days)
- Renders the days remaining via the `{{days_remaining}}` token
- Links to checkout with an upgrade CTA
## Prerequisites
- A React 18+ application with `@revturbine/sdk` installed
- A `RevTurbineProvider` set up (see [Quickstart](/revturbine-sdk/))
## Steps
1. **Add a trial-ending placement to your config**
The `trial_ending` trigger fires when a trial enters its closing window. This is a complete, valid `Playbook`.
```json title="revturbine.playbook.json"
{
"artifact_type": "playbook",
"format_version": "1.0.0",
"plans": [
{ "unique_handle": "pro", "name": "Pro", "tier_position": 1, "sort_order": 1 }
],
"entitlements": [],
"entitlement_rules": [],
"segments": [],
"content_ui_paths": [],
"surface_templates": [
{ "id": "banner_placement", "surface_type": "banner", "fields": [] }
],
"placements": [
{
"id": "pl_trial_ending",
"name": "Trial ending countdown",
"category": "trials",
"trigger": { "type": "trial_ending", "days_before_end": 3 },
"payloads": [
{
"id": "pl_trial_ending_p0",
"target": { "plan_ids": [], "segment_chips": [] },
"surfaces": [
{
"template_id": "banner_placement",
"fields": {
"header": "Your trial ends in {{days_remaining}} days",
"body": "Upgrade now to keep your projects and Pro features.",
"dismissible": "Yes"
},
"ctas": [
{ "label": "Upgrade to Pro", "path": "open_checkout", "config": { "purchase": "pro" } }
]
}
],
"caps": { "max_per_period": { "count": 1, "period": "day" } },
"status": "active"
}
],
"order": 0
}
]
}
```
2. **Provide trial status to the SDK**
In local mode the SDK reads trial state from `localRuntime.initialData` (in hosted mode it arrives with the user context). Supply the trial so the trigger and `{{days_remaining}}` token can compute the window.
```tsx title="src/App.tsx"
```
3. **Render the banner slot**
```tsx title="src/components/AppHeader.tsx"
import { Slot } from '@revturbine/sdk';
export function AppHeader() {
return (
{/* Hidden until the trial enters its final 3 days */}
);
}
```
4. **(Optional) Read trial status directly**
To drive your own UI — a sidebar countdown, a settings notice — read trial status imperatively:
```tsx
import { useEffect, useState } from 'react';
import { useRevTurbine } from '@revturbine/sdk';
function TrialNotice() {
const { sdk } = useRevTurbine();
const [trial, setTrial] = useState(null);
useEffect(() => {
if (sdk) void Promise.resolve(sdk.getTrialStatus()).then(setTrial);
}, [sdk]);
if (!trial?.in_trial) return null;
return
{trial.days_remaining} days left in your trial
;
}
```
## How It Works
1. The `trial_ending` trigger keeps the placement hidden until the trial is within `days_before_end` of expiring.
2. `{{days_remaining}}` is filled from the trial status the SDK holds.
3. As the trial advances day by day, the banner copy updates automatically.
4. The CTA opens checkout through the resolvers from [Quickstart](/revturbine-sdk/).
**Tip:**
RevTurbine supports both **forward trials** (paid features for a limited time) and **reverse trials** (full access that downgrades to free). The same trigger pattern applies — only the trial status the SDK reports differs.
## Try It Live
> [Interactive Playground → Banner Slot](/revturbine-sdk/playground/#fixed-banner)
## Next Steps
- [Entitlements Guide](/revturbine-sdk/guides/entitlements/) — trials, usage limits, and credits
- [Placements Guide](/revturbine-sdk/guides/placements/) — triggers and targeting
- [Tutorial: Show a Banner Placement](/revturbine-sdk/tutorials/banner-placement/) — targeted banners without a trigger
# Tutorial: Add an Upgrade Button
> End-to-end tutorial — install the SDK, configure local mode, and place an upgrade button that renders based on targeting rules.
In this tutorial, you'll install the SDK, configure it in local mode, and render an upgrade button that only appears for users on the Starter plan. Takes about 10 minutes.
## What You'll Build
An upgrade button in your navigation bar that:
- Shows "Upgrade to Pro" for Starter plan users
- Hides automatically for Professional/Enterprise users
- Tracks impressions and CTA clicks
- Navigates to your pricing page on click
## Prerequisites
- A React 18+ application (Next.js, Vite, or CRA)
- Node.js 22.13+
## Steps
1. **Install the SDK**
```bash
pnpm add @revturbine/sdk
```
2. **Create an Playbook fixture**
For local development, you need a Playbook with plans, entitlements, and placements. Create `src/fixtures/revturbine.playbook.json`:
```json title="revturbine.playbook.json"
{
"artifact_type": "playbook",
"format_version": "1.0.0",
"plans": [
{ "unique_handle": "starter", "name": "Starter", "tier_position": 0, "sort_order": 0 },
{ "unique_handle": "pro", "name": "Pro", "tier_position": 1, "sort_order": 1 }
],
"entitlements": [],
"entitlement_rules": [],
"segments": [],
"content_ui_paths": [],
"surface_templates": [
{ "id": "button", "surface_type": "button", "fields": [] }
],
"placements": [
{
"id": "pl_nav_upgrade",
"name": "Upgrade button",
"category": "fixed",
"trigger": { "type": "surface_render", "slot_id": "nav_upgrade" },
"payloads": [
{
"id": "pl_nav_upgrade_p0",
"target": { "plan_ids": ["starter"], "segment_chips": [] },
"surfaces": [
{
"template_id": "button",
"fields": {},
"ctas": [
{ "label": "Upgrade to Pro", "path": "open_checkout", "config": { "purchase": "pro" } }
]
}
],
"caps": {},
"status": "active"
}
],
"order": 0
}
]
}
```
3. **Wrap your app with the provider**
```tsx
// src/App.tsx
import { RevTurbineProvider, RuntimeMode } from '@revturbine/sdk';
import playbook from './fixtures/revturbine.playbook.json';
export default function App() {
return (
);
}
```
4. **Add the upgrade button slot**
```tsx
// src/components/NavBar.tsx
import { Slot } from '@revturbine/sdk';
export function NavBar() {
return (
);
}
```
5. **Verify it works**
Start your dev server and you should see:
- **Starter user** → "Upgrade to Pro" button appears
- **Professional user** → nothing renders (slot is invisible)
To test different users, change the user context in the provider:
```tsx
```
## What Just Happened
1. The `RevTurbineProvider` initialized the SDK in `local_only` mode with your Playbook
2. The `` asked the decision engine for a placement at slot `nav_upgrade`
3. The engine evaluated the payload target (`plan_ids: ['starter']`) against the user context
4. For Starter users, it resolved the `button` template with the "Upgrade to Pro" CTA
5. For Pro users, no payload matched — the slot rendered nothing
## Local mode is production-ready
`local_only` is not a demo mode you graduate out of. It evaluates the full Playbook client-side and is a valid production runtime on its own — ship it as-is when a bundled Playbook, client-side decisioning, and `localStorage` events are enough for you.
Switch to the RevTurbine **server** runtime when you want what it *adds*, not because local mode isn't "real": live Playbook updates without a redeploy, server-authoritative usage and metering, A/B experiments, and centralized analytics through the ingest pipeline. See [Runtime Modes](/revturbine-sdk/guides/runtime-modes/) for the full comparison.
To switch, point the provider at your tenant and drop the local runtime:
```diff
```
## Try It Live
> [Interactive Playground → Upgrade Button](/revturbine-sdk/guides/placements/#fixed-button)
## Next Steps
- [Tutorial: Gate a Premium Feature](/revturbine-sdk/tutorials/gate-premium-feature/) — add an entitlement gate
- [Component Gallery](/revturbine-sdk/components/) — interactive demos of all built-in slot components
# Tutorial: Track Usage and Show a Quota Meter
> End-to-end tutorial — report usage, check limits, and render a quota meter that updates in real time.
In this tutorial, you'll report API usage to the SDK, display a quota meter that updates in real time, and trigger a warning placement when usage exceeds 80%. Takes about 15 minutes.
## What You'll Build
A usage tracking flow that:
- Reports API call counts to the SDK via `update()`
- Shows a quota meter with current/limit display
- Triggers a warning banner when usage exceeds 80%
- Triggers an upgrade modal when usage exceeds 100%
## Prerequisites
- A React 18+ application with `@revturbine/sdk` installed
- A `RevTurbineProvider` set up (see [Quickstart](/revturbine-sdk/))
## Steps
1. **Add usage entitlements and placements to your config**
```json title="revturbine.playbook.json"
{
"artifact_type": "playbook",
"format_version": "1.0.0",
"plans": [
{ "unique_handle": "starter", "name": "Starter", "tier_position": 0, "sort_order": 0 },
{ "unique_handle": "professional", "name": "Professional", "tier_position": 1, "sort_order": 1 }
],
"entitlements": [
{ "unique_handle": "api_calls", "name": "API Calls", "type": "usage_limit", "unit": "calls" }
],
"entitlement_rules": [
{
"id": "er_api_calls_starter",
"entitlement_id": "api_calls",
"targets": [{ "kind": "plan", "id": "starter" }],
"segment_ids": [],
"kind": "usage_limit",
"limit_value": 1000,
"unit": "calls",
"period_scope": "per_month",
"enforcement": "hard_block"
}
],
"segments": [],
"content_ui_paths": [],
"surface_templates": [
{ "id": "usage_counter", "surface_type": "in_page", "fields": [] },
{ "id": "banner_warning", "surface_type": "banner", "fields": [] }
],
"placements": [
{
"id": "pl_api_quota_meter",
"name": "API quota meter",
"category": "fixed",
"trigger": { "type": "surface_render", "slot_id": "sidebar_usage" },
"payloads": [
{
"id": "pl_api_quota_meter_p0",
"target": { "plan_ids": [], "segment_chips": [] },
"surfaces": [
{
"template_id": "usage_counter",
"fields": { "header": "API Calls", "body": "{{usage_current}} / {{usage_limit}} calls used" },
"ctas": []
}
],
"caps": {},
"status": "active"
}
],
"order": 0
},
{
"id": "pl_usage_warning",
"name": "Usage limit approaching",
"category": "usage_credit_seat",
"trigger": { "type": "usage_threshold", "entitlement_handle": "api_calls", "threshold_percent": 80 },
"payloads": [
{
"id": "pl_usage_warning_p0",
"target": { "plan_ids": ["starter"], "segment_chips": [] },
"surfaces": [
{
"template_id": "banner_warning",
"fields": {
"header": "Usage limit approaching",
"body": "You've used {{usage_percent}}% of your API calls this month.",
"dismissible": "Yes"
},
"ctas": [
{ "label": "View Plans", "path": "open_checkout", "config": { "purchase": "professional" } }
]
}
],
"caps": {},
"status": "active"
}
],
"order": 1
}
]
}
```
2. **Add the quota meter to your sidebar**
```tsx
// src/components/Sidebar.tsx
import { Slot } from '@revturbine/sdk';
export function Sidebar() {
return (
);
}
```
3. **Add the warning banner**
```tsx
// src/components/DashboardLayout.tsx
import { Slot } from '@revturbine/sdk';
export function DashboardLayout({ children }) {
return (
{/* Warning banner appears when usage > 80% */}
{children}
);
}
```
4. **Report usage from your API layer**
```tsx
// src/hooks/useApiCall.ts
import { useRevTurbine } from '@revturbine/sdk';
import { useCallback, useRef } from 'react';
export function useApiCall() {
const { sdk } = useRevTurbine();
const callCount = useRef(0);
const apiCall = useCallback(async (endpoint: string, options?: RequestInit) => {
const response = await fetch(endpoint, options);
// Report usage to the SDK
callCount.current += 1;
sdk?.update({ usage: { api_calls: callCount.current } });
return response;
}, [sdk]);
return { apiCall };
}
```
5. **Test the flow**
Simulate increasing usage to see the meter and banner update:
```tsx
function DemoControls() {
const { sdk } = useRevTurbine();
const [calls, setCalls] = useState(0);
const simulateUsage = (count: number) => {
setCalls(count);
sdk?.update({ usage: { api_calls: count } });
};
return (
Demo: Simulate API Usage
Current: {calls} calls
);
}
```
**Expected behavior:**
| Usage | Quota Meter | Warning Banner |
|---|---|---|
| 500 / 1000 (50%) | Green bar, "500 / 1000" | Hidden |
| 800 / 1000 (80%) | Yellow bar, "800 / 1000" | Shows "Usage limit approaching" |
| 950 / 1000 (95%) | Red bar, "950 / 1000" | Shows "Usage limit approaching" |
| 1000 / 1000 (100%) | Full red bar | Still shows; `can('api_calls')` now denies (`hard_block`) |
## How It Works
1. `update()` updates the SDK's internal usage balances
2. The decision engine re-evaluates all active placements
3. The quota meter refreshes its display (current/limit)
4. When usage exceeds the targeting threshold (80%), the warning banner becomes visible
5. Token expansion fills in `{{usage_current}}`, `{{usage_limit}}`, `{{usage_percent}}`
## Triggering Programmatic Alerts
For more control, emit trigger events:
```ts
// When usage approaches limit
if (usagePercent >= 80) {
await sdk.emitTrigger('usage_limit_approaching', {
entitlement_handle: 'api_calls',
current_usage: currentCalls,
usage_limit: 1000,
usage_percent: usagePercent,
});
}
// When usage reaches the limit
if (usagePercent >= 100) {
await sdk.emitTrigger('usage_limit_reached', {
entitlement_handle: 'api_calls',
current_usage: currentCalls,
usage_limit: 1000,
});
}
```
## Try It Live
> [Interactive Playground → Quota Meter](/revturbine-sdk/playground/#fixed-usage-counter)
## Next Steps
- [Entitlements Guide](/revturbine-sdk/guides/entitlements/) — all entitlement types
- [Events & Analytics](/revturbine-sdk/guides/events/) — trigger events and tracking
- [Component Gallery](/revturbine-sdk/components/) — interactive demos of all built-in slot components
# Protect a Cloud Export
> Authenticate UserContext, load a Playbook, and enforce a paid action with a fresh SDK session.
This tutorial starts with a Vercel standalone Edge Function. The same application
handler also has Node, Firebase, AWS, Google Cloud, and Azure adapters in the
[cloud examples](/revturbine-sdk/guides/cloud-entitlements/).
## 1. Run the example locally
Clone the [SDK repository](https://github.com/revt-eng/revturbine-sdk) and open
`examples/cloud-entitlements`. Install with `npm ci`, check types with
`npm run typecheck`, and run `npm test`. Tests provide their own keys and delivery
fixtures. You do not need to deploy or supply production credentials.
The fixture grants the boolean `data_export` entitlement to `pro` and denies it
to `free`. Tests verify both decisions, rejected authentication, unavailable
configuration, cache refresh, and user isolation against the published SDK.
## 2. Establish authoritative context
In your trusted application backend, authenticate the caller and resolve their
account membership and plan from your application's records. Pass that context
to `issuer/issue-context-token.ts`. Keep its signing private key on the issuer;
publish only public keys at your HTTPS JWKS URL.
Set `CONTEXT_JWKS_URL`, `CONTEXT_ISSUER`, and `CONTEXT_AUDIENCE` to your application's
values. The token's subject must match `user.id`, and its tenant must match the
deployment. The helper is not an endpoint for signing arbitrary browser JSON.
This application JWT is separate from an `rt_client_` RevTurbine client-session
token. Do not send a client-session token to this verifier or treat the two
credential types as interchangeable.
## 3. Configure trusted Playbook delivery
Use the variable names in `.env.example`. Set `REVTURBINE_TENANT_ID`, the secret
server `REVTURBINE_API_KEY`, `REVTURBINE_ENDPOINT`, and an explicit allowlist in
`REVTURBINE_CDN_ORIGINS`. Use the actual delivery origins supplied by your
deployment; do not accept an origin from the incoming request.
`src/playbook-cache.ts` shares only immutable snapshots. It authenticates
bootstrap, checks scope/integrity/version/expiry, and refreshes within 60 seconds.
The cache deliberately rejects a failed refresh instead of serving stale grants.
The native connected alternative is in `src/native-connected-example.ts`.
The four-second delivery timeout is per fetch, not for the complete operation.
JWKS resolution separately allows three seconds. Account for sequential requests
when setting your overall application deadline and provider timeout.
## 4. Follow the enforcement boundary
`api/check-export.ts` selects the Edge runtime and delegates to the configured
handler. `src/check-export.ts` verifies authentication before loading policy,
initializes a fresh SDK session, invokes `session.can('data_export')`, and disposes
the session in `finally`. There is no shared mutable session for multiple users.
The sample returns 200 for allowed, 403 for denied, 401 for rejected authentication,
405 for an unsupported method, and 503 for unavailable configuration. Personalized
responses carry `private, no-store`.
Replace the allowed response with your export operation, after verifying that
the caller owns or may access the requested resource. Do not call this endpoint
from the browser and treat a successful response as authorization for a separate,
unguarded download. Keep the check and protected operation in the trusted handler.
## 5. Choose your runtime
Use `api/check-export-node.ts` when you need Node APIs or your framework does not
support standalone Edge execution. Keep Node-only provider imports outside the
Edge graph. Other providers use the same handler through the adapters listed in
the [cloud guide](/revturbine-sdk/guides/cloud-entitlements/).
Before optional deployment, follow the example README's platform-specific setup.
Disable CDN caching for the route, forward Authorization, protect direct origin
access, and store server secrets in the provider's secret facility. Firebase,
Google Cloud, and Azure CDN front doors still execute this handler regionally.
## 6. Decide freshness and accounting requirements
Short-lived context tokens can remain valid until their expiry, and policy
snapshots can remain current for the cache interval. Document those bounds for
your application. Add revocation checks when changes must take effect sooner.
The example trusts authenticated HTTPS bootstrap and checks the supplied digest;
it does not independently verify optional manifest signatures.
For a metered export, use atomic application-side quota accounting as well as the
SDK policy check. Telemetry delivery and an entitlement decision are not a durable
usage transaction. See [server-side integration](/revturbine-sdk/getting-started/server-side/)
for session lifecycle and event-delivery details.
# RevTurbineUserContext
> Identify users and keep plan, usage, traits, experiments, and server-derived signals current.
`RevTurbineUserContext` is the user snapshot RevTurbine evaluates against the
active Playbook. Entitlement checks, Segment targeting, placements,
personalization, and experiment assignment all depend on this context.
For most integrations, start with a stable user id and the current plan handle.
Add only the fields your Playbook reads.
```ts
import type { RevTurbineUserContext } from '@revturbine/sdk';
export const user: RevTurbineUserContext = {
id: 'usr_01J8M4X9Q2',
plan_handle: 'starter',
custom: {
role: 'editor',
company_size: 42,
},
};
```
**Caution — User context is flat:**
There is no nested `context` wrapper. `plan_handle` is a direct property of
the user object.
```diff
- user: { id: 'user_123', context: { plan_handle: 'pro' } }
+ user: { id: 'user_123', plan_handle: 'pro' }
```
The TypeScript context types reject unknown top-level keys. `identify()` and
`update()` also warn and drop unknown keys from plain JavaScript callers. Put
customer-defined fields under `custom`.
## Choose the right entry point
| Situation | API | Behavior |
|---|---|---|
| Initialize React or the browser SDK | `options.user` | Sets the initial `RevTurbineUserContext` |
| A user logs in or you switch accounts | `identify(userId, context?)` | Establishes the identity, invalidates cached decisions, and applies identity-safe context fields |
| Data changes for the same user | `update(patch)` | Merges plan, usage, custom traits, and other supported session fields without changing identity |
| Merge a low-level context snapshot | `setUserContext(context)` | Merges a `RevTurbineUserContext`; React's `setContext` calls this method |
| A user logs out | `reset()` | Clears the known identity and returns to inferred anonymous state |
| A demo or test needs a blank slate | `resetUserContext()` | Clears all user values, usage, decisions, and interaction history without anonymous inference |
| Inspect held SDK state | `getUserContext()` | Returns the current context with `tenant_id`, `user_id`, and persistence-envelope fields |
| Inspect what decisions actually read | `getEffectiveUserContext()` | Returns the immutable context after customer and experiment providers resolve |
Use `identify()` whenever the identity changes. Use `update()` for later changes
to the same user:
```ts
import type { RevTurbineCustomerSdk } from '@revturbine/sdk';
type SignedInUser = {
id: string;
accountId: string;
planHandle: string;
role: string;
};
export function syncSignedInUser(rt: RevTurbineCustomerSdk, user: SignedInUser) {
rt.identify(user.id, {
account_id: user.accountId,
plan_handle: user.planHandle,
custom: { role: user.role },
});
}
export function applyPlanChange(rt: RevTurbineCustomerSdk, planHandle: string) {
rt.update({ plan_handle: planHandle });
}
export function signOut(rt: RevTurbineCustomerSdk) {
rt.reset();
}
```
Context updates notify mounted hooks and components, so active entitlement
checks and placements re-evaluate for the new snapshot.
The optional context passed to `identify()` accepts `account_id`, `email`,
`plan_handle`, `plan`, `usage`, `entitlements`, `custom`, and
`personalization`. Set trial, tier, billing-recovery, and instance changes at
initialization or with `update()`. Supply direct `experiments` assignments at
initialization, through an experiment provider, or through the lower-level
`setUserContext()` merge.
## Field reference
### Identity and account
| Field | Type | Guidance |
|---|---|---|
| `id` | `string` | Your stable, opaque user identifier. Supply it in `options.user`; pass it as the first argument to `identify()`. Avoid emails and other guessable identifiers |
| `account_id` | `string \| null` | The organization, workspace, or account the user is acting in. Optional — leave it unset and every event the SDK emits carries `user-fallback:` instead. That prefix is the point: `account_id` is the join key RevTurbine's account-grain analytics match on, so a *bare* user id stamped there is indistinguishable from a real account, while a prefixed one is countable and obviously fabricated (RevTurbine's own account-grain readouts exclude it). Use `isFallbackAccountId()` to classify a value. Set `account_id` whenever your product has a notion of an account |
| `email` | `string \| null` | Optional contact identity. Put email here, never in `id` |
| `email_type` | `string` | Optional classification such as `business` or `personal` |
`id` is optional at the type boundary because the browser SDK can operate with
anonymous state. Once your app knows the user, identify them with the same
stable internal id on every device and session.
### Plan
| Field | Type | Guidance |
|---|---|---|
| `plan_handle` | `string` | Preferred plan identity. It must equal the plan's `unique_handle` in the Playbook, such as `starter` or `pro` |
| `plan` | `{ handle, name, price?, billing_period? }` | Optional display metadata. `plan.handle` is a fallback identity when `plan_handle` is absent; the flat field wins when both are present |
Plan ids from your database or RevTurbine's internal records do not belong in
user context. `plan.id` is a retired shape and is rejected. Use the plan's
stable handle:
```ts
import type { RevTurbineUserContext } from '@revturbine/sdk';
export const professionalUser: RevTurbineUserContext = {
id: 'usr_01J8M4X9Q2',
plan_handle: 'pro',
plan: {
handle: 'pro',
name: 'Professional',
price: '$49',
billing_period: 'monthly',
},
};
```
### Decision and presentation inputs
| Field | Type | What reads it |
|---|---|---|
| `custom` | `Record` | Customer-owned attributes for Segment targeting, such as role, region, or company size. Use string, number, or boolean values for predicates |
| `usage` | `Record` | Usage-limit and credit entitlements. Keys are entitlement handles; see [Report usage](#report-usage) |
| `trial` | `RevTurbineTrialContext` | Reverse-trial entitlement grants, trial-lifecycle placements (`trial_ending`, `trial_progress`, `trial_ended`, `trial_converted`) and the `{{trial_days_remaining}}` / `{{trial_days_total}}` tokens. When a server-delivered or hydrated trial status is also present, the most recent change wins |
| `tiers` | `Record` | Current tier handle per capability-tier entitlement handle |
| `entitlements` | `Record` | Precomputed grants. Most apps should let the Playbook derive these rather than asserting them in the browser |
| `experiments` | `Record` | Experiment handle to variant handle assignments; see [Use Experiment Assignments](/revturbine-sdk/guides/experiments/) |
| `payment_failed` | `boolean` | Hard-failure recovery placements |
| `payment_at_risk` | `boolean` | Soft billing-attention placements |
| `instances` | `UserInstanceContext[]` | Per-product-instance plans, usage, trials, and entitlements for multi-instance accounts |
| `personalization` | `Record` | Transient tokens used in placement copy. It is rebuilt for each SDK session and is not an access-control input |
Browser values can be changed by the person running the browser. Use them for
UI decisions, but re-run value-bearing entitlement checks in a server SDK over
the same Playbook before spending money, provisioning seats, or granting
sensitive access. See [Client vs Server Enforcement](/revturbine-sdk/concepts/enforcement/).
## Server SDK context shapes
The TypeScript headless entry point accepts this same canonical
`RevTurbineUserContext`. The Python and Rust local evaluators instead expose
smaller, request-scoped `UserContext` facade types. Map your application user
into the shape for the SDK you are calling:
| Concept | TypeScript headless | Python and Rust server facade |
|---|---|---|
| Identity | `user.id`; tenant is the top-level `tenantId` initialization option | `tenant_id` and `user_id` inside the facade object |
| Plan | `plan_handle`, with optional `plan: { handle, name, ... }` metadata | `plan_handle`, with optional `plan_name`; no `plan` object |
| Usage at construction | `{handle: {entitlement_handle, unit, amount, limit?}}` | `{handle: {used, limit}}` |
| Trial | `trial` | `trial_status`, containing already-derived trial state |
| Targeting | `custom`, `experiments`, and other canonical context fields | No raw-trait or experiment derivation; Python also accepts pre-resolved `segment_ids` for eligible plan and add-on helpers |
| Shared signals | `payment_failed`, `payment_at_risk`, and `tiers` | The same three fields |
**Caution — Use the facade type, not the persisted model:**
For Python, import `UserContext` from `revturbine`. For Rust, import it from
`revturbine::sdk`. Do not pass the generated persisted models
`revturbine.types.UserContext` or `revturbine::types::UserContext` to the local
evaluator constructor. Those models include database envelope fields and use
the broader canonical schema; they are not constructor inputs.
See the detailed [TypeScript server guide](/revturbine-sdk/getting-started/server-side/),
[Python guide](/revturbine-sdk/getting-started/python/), and [Rust guide](/revturbine-sdk/getting-started/rust/).
## Report usage
Initial user context uses rich `UserUsageEntry` objects. The record key and
`entitlement_handle` both refer to the entitlement's `unique_handle`:
```ts
import type { RevTurbineUserContext } from '@revturbine/sdk';
export const userWithUsage: RevTurbineUserContext = {
id: 'usr_01J8M4X9Q2',
plan_handle: 'pro',
usage: {
api_calls: {
entitlement_handle: 'api_calls',
unit: 'calls',
amount: 42,
limit: 1_000,
},
},
};
```
Runtime updates use the simpler `Record` shape:
```ts
import type { RevTurbineCustomerSdk } from '@revturbine/sdk';
export function reportApiCallTotal(rt: RevTurbineCustomerSdk, total: number) {
rt.update({ usage: { api_calls: total } });
}
```
Usage numbers are **absolute balances, not deltas**. If the user has made 43
API calls, report `43`, not `1`. A usage key that does not match an entitlement
handle is stored but cannot affect a rule; once a Playbook is loaded, the SDK
warns about unmatched keys.
## Merge behavior
Context changes use upsert semantics:
- Omitted fields and fields set to `undefined` keep their current values.
- `setUserContext()` merges `custom`, `entitlements`, `usage`,
`personalization`, and `experiments` by key; supplied keys win.
- `update()` applies the same merge behavior to its supported non-usage fields
and routes its simple numeric `usage` map through the usage update path.
- `update()` cannot change `id`. Call `identify(newId, context)` to switch users.
- An empty object does not erase a previously populated nested map. Use
`reset()` for logout or `resetUserContext()` when you need a completely blank
context.
Treat the object you pass to RevTurbine as immutable. Create a new object for
each meaningful change instead of mutating an earlier value in place.
## Add server-derived context
Some signals should come from a trusted backend: the plan last updated by a
Stripe webhook, trial status, or coarse payment health. Supply a
`clientSession` callback and the SDK will fetch the signed-in user's client-safe
context automatically:
```ts
import { initRevTurbine } from '@revturbine/sdk';
import { fetchClientSession } from './revturbine-client-session';
export const rt = await initRevTurbine({
publicKey: process.env.NEXT_PUBLIC_REVTURBINE_PUBLIC_KEY!, // public key — safe in the browser
user: { id: 'user_123', plan_handle: 'free' },
clientSession: fetchClientSession, // POSTs to your /api/revturbine-session route
});
```
Your backend—not the browser—mints the short-lived `rt_client_…` token. The route
and the `fetchClientSession` function are on [Client sessions](/revturbine-sdk/guides/client-sessions/). The SDK
requests one when needed, requests a fresh token after `identify()`, and retries
with a new token after expiry. Server-derived plan and trial state then join the
effective user context used for local evaluation. If enrichment is unavailable,
the SDK keeps using the context your app supplied.
See [Configuration Reference: server-derived context](/revturbine-sdk/reference/configuration/#server-derived-context-clientsession)
for the complete token flow.
## SDK-managed TypeScript fields
The broader generated TypeScript user schema also contains persistence and
cache metadata. Application code using `RevTurbineUserContext` should not
author these fields:
- `tenant_id`, `user_id`, `created_at`, and `updated_at` are added by
`getUserContext()` and the persistence layer.
- `derived_config_version`, `context_hash`, and `derived_computed_at` describe
the server-computed entitlement cache.
- `activity_score` and `activity_score_computed_at` are server-computed activity
signals.
Keep application-specific values under `custom`; do not repurpose SDK-managed
fields.
## Privacy and validation
- Use an opaque internal id. If `identify()` receives an email-shaped id, the
SDK warns and tells you to use the `email` field instead.
- Do not put secrets, access tokens, payment details, or raw provider records in
user context.
- Keep Segment-targeted `custom` values scalar. Nested JSON may be accepted at
the SDK boundary, but it is not a scalar Segment predicate.
- `RevTurbineProvider` options, `identify()`, and `update()` exact-check context
objects even when they come through an intermediate variable. Plain
JavaScript calls to `identify()` and `update()` receive visible warnings for
unknown keys, which are dropped rather than silently becoming traits.
## Related
- [Core Concepts](/revturbine-sdk/concepts/core-concepts/) — where user context fits in the decision loop
- [React Integration](/revturbine-sdk/getting-started/react/) — provider and hook setup
- [Configuration Reference](/revturbine-sdk/reference/configuration/) — initialization options
- [Entitlements](/revturbine-sdk/guides/entitlements/) — how plan and usage become access decisions
- [Run an Experiment](/revturbine-sdk/guides/experiments/) — direct and provider-owned assignments
- [`RevTurbineUserContext` API reference](/revturbine-sdk/api/interfaces/revturbineusercontext/) — generated type details