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](/docs/guides/headless-api/) and [Server-Side Integration](/docs/getting-started/server-side/). Python and Rust SDKs evaluate the same Playbook with byte-identical results. ## 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](/docs/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](/docs/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](/docs/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'; async function handleExport(req, res) { const rt = await initRevTurbine({ tenantId: process.env.REVTURBINE_TENANT_ID!, apiKey: process.env.REVTURBINE_API_KEY!, endpoint: process.env.REVTURBINE_ENDPOINT!, runtimeMode: RuntimeMode.Server, user: { id: req.userId, plan_handle: req.user.planHandle }, }); const result = await rt.can('batch_export'); if (!result.allowed) return res.status(402).json({ error: 'upgrade_required' }); await runExport(req); } ``` Why the re-check, and which actions need it: [Client vs Server Enforcement](/docs/concepts/enforcement/). ## 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. See [Billing & Stripe](/docs/guides/billing/). 3. **Switch the provider to hosted mode.** Mint a publishable ingest key under **Settings → API tokens**, then: ```tsx const options = useMemo(() => ({ tenantId: 'tenant_abc', publicKey: 'rtk_…', // public key — safe in the browser user: { id: currentUser.id }, // plan_handle now comes from Stripe clientSession: () => fetch('/api/revturbine-session', { method: 'POST' }).then((r) => r.json()).then((j) => j.client_token), }), [currentUser.id]); ``` 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](/docs/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](/docs/getting-started/react/#userevturbine) | | Run checks without React | [TypeScript / Node](/docs/guides/headless-api/), [Python](/docs/getting-started/python/), [Rust](/docs/getting-started/rust/) | | Choose Playbook delivery and optional wiring | [Runtime Modes](/docs/guides/runtime-modes/), [Configuration](/docs/reference/configuration/) | | Match your app's colors | [Theming](/docs/guides/theming/) | | Route blocked actions to a paywall | [Paywall Host Pattern](/docs/guides/paywall-host/) | | Explain a missing placement or denied check | [Troubleshooting](/docs/guides/troubleshooting/), [Debugging Decisions](/docs/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](/docs/llms-small.txt) is an abridged documentation bundle, including the language guides; [llms-full.txt](/docs/llms-full.txt) contains the complete documentation. ## Next steps - [Recommended API Path](/docs/getting-started/api-overview/) — `can()` vs `gate()` vs ``, and the vocabulary - [Entitlements](/docs/guides/entitlements/) — feature gates, usage limits, credits, seats, tiers - [Placements](/docs/guides/placements/) — targeting, caps, cooldowns, priority, and UI paths - [Runtime Modes](/docs/guides/runtime-modes/) — local mode, hosted mode, and what is cached - [Interactive Playground](/docs/playground/) — every scenario, live and editable - [API reference](/docs/api/) — full TypeDoc reference for all exports # 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](/docs/components/button-slot/) | `button` | Standalone CTA button for nav bars and inline prompts | | [BannerSlot](/docs/components/banner-slot/) | `banner` | Full-width banner for announcements and usage warnings | | [ModalSlot](/docs/components/modal-slot/) | `modal` | Overlay modal for feature gates and trial expirations | | [ToastSlot](/docs/components/toast-slot/) | `toast` | Ephemeral toast notification for usage alerts | | [QuotaMeterSlot](/docs/components/quota-meter-slot/) | `in_page` | Usage meter with progress bar, counter, and gauge styles | | [InlineEmbedSlot](/docs/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](/docs/playground/#fixed-banner) · [API Reference](/docs/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](/docs/guides/placements/#fixed-button) · [API Reference](/docs/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](/docs/playground/#gate-inline) · [API Reference](/docs/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](/docs/guides/entitlements/#gate-modal) · [API Reference](/docs/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](/docs/playground/#fixed-usage-counter) · [API Reference](/docs/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](/docs/playground/#msg-toast) · [API Reference](/docs/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](/docs/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](/docs/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](/docs/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()`](/docs/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 the UI the control plane decides to render at the right moment: an upgrade prompt when a gate denies, a "running low" banner near a limit, a trial-ending toast. You render one with a [``](/docs/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 `` `user` prop. Entitlement checks and [segment targeting](/docs/guides/experiments/) both read from it. The dedicated [`RevTurbineUserContext` guide](/docs/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](/docs/guides/runtime-modes/). ## Where decisions run **Tip — No latency, no dependence on our availability:** 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. 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](/docs/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 (``) | For the component/config name aliases (`SurfaceSlotComponent`, `Playbook`, …), see the [Recommended API Path](/docs/getting-started/api-overview/#the-vocabulary-slot--gate--playbook). ## Related - [Recommended API Path](/docs/getting-started/api-overview/) — which call to reach for - [Client vs Server Enforcement](/docs/concepts/enforcement/) — policy vs enforcement - [Entitlements](/docs/guides/entitlements/) · [Placements](/docs/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](/docs/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](/docs/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'; // Server: the trusted decision. Nothing on this path calls RevTurbine — // the headless SDK evaluates the Playbook it holds. async function handleExport(req, res) { const rt = await initRevTurbine({ tenantId: process.env.REVTURBINE_TENANT_ID!, apiKey: process.env.REVTURBINE_API_KEY!, endpoint: process.env.REVTURBINE_ENDPOINT!, runtimeMode: RuntimeMode.Server, user: { id: req.userId, plan_handle: req.user.planHandle }, }); const result = await rt.can('data_export'); if (!result.allowed) return res.status(402).json({ error: 'upgrade_required' }); await runExport(req); } ``` 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 on a request hot path, don't pay the Playbook fetch per request — see [Instance lifecycle](/docs/getting-started/server-side/#instance-lifecycle). 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](/docs/getting-started/api-overview/) — when to reach for `can()` vs `gate()` vs `` - [Server-Side Integration](/docs/getting-started/server-side/) — running entitlement checks on your backend - [Entitlements](/docs/guides/entitlements/) — the entitlement model behind every check - [Error Handling](/docs/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](/docs/guides/runtime-modes/) — choosing the right runtime mode - [Component Gallery](/docs/components/) — interactive demos of every built-in slot - [API Reference](/docs/api/) — full TypeDoc reference for all exports # 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](/docs/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](/docs/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](/docs/concepts/enforcement/) — the policy-vs-enforcement split in detail - [Placements](/docs/guides/placements/) — slots, CTA actions, and the empty-state rule - [Entitlements](/docs/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 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](/docs/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](/docs/) — gate one action end to end - [Entitlements](/docs/guides/entitlements/) — the full entitlement model behind `can()` / `gate()` - [Placements](/docs/guides/placements/) — what `` renders and how decisions resolve # 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](/docs/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](/docs/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](/docs/) — render your first placement - [Runtime modes](/docs/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](/docs/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. Construct a **new** `RevTurbineCustomerSdk` with the new snapshot. Because the instance is stateless, "refresh" is just "rebuild" — atomically swap the reference your handlers read. ```python # e.g. behind a lock / atomic reference, refreshed every N minutes _sdk = RevTurbineCustomerSdk(user_context=ctx, exported_config=load_config()) ``` 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](/docs/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](/docs/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](/docs/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](/docs/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 | ### 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](/docs/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](/docs/guides/entitlements/) — feature gates, usage limits, and credits - [Placements Guide](/docs/guides/placements/) — slot-based and entitlement-based patterns - [Theming Guide](/docs/guides/theming/) — customize slot component appearance - [Interactive Playground](/docs/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](/docs/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](/docs/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. In `Server` mode it fetches the launched Playbook and evaluates each entitlement and placement in-process; in `LocalOnly` mode you bundle the Playbook yourself and make no decisioning network calls. **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](/docs/user-context/#server-sdk-context-shapes). ## When to Use Server-Side | Use case | Client SDK | Server SDK | |---|---|---| | Render placements in React | ✅ | — | | Gate API endpoints | — | ✅ | | Enforce usage limits before processing | — | ✅ | | SSR placement decisions | ✅ (local mode) | ✅ | | Track server-side events | — | ✅ | ## 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: ```ts import { initRevTurbine, RuntimeMode, type RevTurbineUserContext, } from '@revturbine/sdk/headless'; async function handleExport(req) { const user: RevTurbineUserContext = { id: req.user.id, plan_handle: req.user.planHandle, usage: { api_calls: { entitlement_handle: 'api_calls', unit: 'calls', amount: req.user.apiCallCount, limit: req.user.apiCallLimit, }, }, }; const rt = await initRevTurbine({ tenantId: process.env.REVTURBINE_TENANT_ID!, apiKey: process.env.REVTURBINE_API_KEY!, endpoint: process.env.REVTURBINE_ENDPOINT!, runtimeMode: RuntimeMode.Server, user, }); const result = await rt.can('data_export'); if (!result.allowed) { return new Response('Upgrade required', { status: 402 }); } await rt.track('data_export_started'); await rt.flushEvents(); return runExport(req); } ``` `Server` mode fetches the launched Playbook from the control plane, then evaluates locally. Before the first Playbook has arrived, a check denies rather than grants; once it has, a failed refresh keeps the last-known-good Playbook. ### Instance lifecycle Each `initRevTurbine` call in `Server` mode fetches the launched Playbook and then caches it for that instance, re-checking for a new release at most once a minute. A request-scoped instance therefore pays that fetch on every request. Two patterns avoid it: - **One long-lived instance per process** where a single user context per instance fits — workers, cron jobs, a per-tenant service. - **A shared Playbook snapshot with a request-scoped `LocalOnly` instance** — the model the Python and Rust SDKs use. Load the Playbook once per process (`revturbine download --live` on deploy, or a timer), then construct `initRevTurbine({ runtimeMode: RuntimeMode.LocalOnly, localRuntime: { playbook }, user })` per request, as in [Bundled Local Runtime](#bundled-local-runtime) below. Note that a `LocalOnly` instance does not send events to RevTurbine ingest. 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. ## 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 import { initRevTurbine, RuntimeMode } from '@revturbine/sdk/headless'; export async function POST(request: Request) { const user = await requireUser(request); const rt = await initRevTurbine({ tenantId: process.env.REVTURBINE_TENANT_ID!, apiKey: process.env.REVTURBINE_API_KEY!, endpoint: process.env.REVTURBINE_ENDPOINT!, runtimeMode: RuntimeMode.Server, user: { id: user.id, plan_handle: user.planHandle }, }); const access = await rt.can('trial_extension'); if (!access.allowed) return new Response('Upgrade required', { status: 402 }); const trial = await extendTrial(user.id); return Response.json({ plan_handle: user.planHandle, trial: { in_trial: true, days_remaining: trial.daysRemaining }, }); } ``` 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: ```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`, mints short-lived, browser-safe client sessions from a secret server key. It is available today, but it is not the decision evaluator; use the headless session above for checks. ## 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](/docs/guides/headless-api/) | | Python | `revturbine` (PyPI) | [Python SDK](/docs/getting-started/python/) | | Rust | `revturbine` (crates.io) | [Rust SDK](/docs/getting-started/rust/) | TypeScript supports local and hosted modes. 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](/docs/getting-started/python/) and [Rust SDK guide](/docs/getting-started/rust/) for setup and usage. ## Next Steps - [User Context](/docs/user-context/) — canonical fields and cross-language shapes - [Entitlements Guide](/docs/guides/entitlements/) — entitlement types and checking patterns - [Runtime Modes](/docs/guides/runtime-modes/) — choosing the right runtime mode - [Headless API](/docs/guides/headless-api/) — imperative SDK usage without React # 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](/docs/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 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()`](/docs/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](/docs/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](/docs/guides/coding-agent/). ## Related - [Placements](/docs/guides/placements/) — decision inputs, caps, and priority in depth - [Run an Experiment with Segments](/docs/guides/experiments/) - [Debugging Decisions](/docs/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. ### Linking a Stripe customer to a RevTurbine user RevTurbine identifies a user by the `user.id` your app passes to the SDK. To tie that user to their Stripe customer, put the same id on the Stripe Customer object as `metadata.revturbine_user_id` when you create it (for example, when your checkout creates the customer). RevTurbine links the two on `customer.created`, and uses the same key to attribute conversions to the prompts that drove them. ```ts // Your checkout, when it creates the Stripe customer const customer = await stripe.customers.create({ email: user.email, metadata: { revturbine_user_id: user.id }, // the id you pass to the SDK }); ``` If a Stripe customer was created without the metadata, RevTurbine can still match it once it has seen the customer's Stripe id — but the metadata is the explicit, authoritative join, and the one to rely on. ### 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 // Browser: `output` is the decision the slot rendered (`decision.output`). 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. # 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](/docs/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](/docs/guides/runtime-modes/) and [Client vs Server Enforcement](/docs/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](/docs/reference/cli/). Treat `revturbine --help` from the project-pinned CLI as authoritative. ## Related - [Replace Hard-Coded Plan Checks](/docs/guides/migrate-plan-checks/) — the before/after the agent produces - [Beyond Gating](/docs/guides/beyond-gating/) — what the config can express - [Client vs Server Enforcement](/docs/concepts/enforcement/) — the paths the agent must not touch # 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](/docs/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](/docs/guides/theming/) — customize colors, typography, and shapes - [Component Gallery](/docs/components/) — built-in slot demos - [API Reference](/docs/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](/docs/playground/#access-gates): [Branding gate](/docs/playground/#gate-inline) and [Brand Kit gate](/docs/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](/docs/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](/docs/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 two entitlement types always evaluate to allowed — the one explicit carve-out from fail-closed. They are **non-gating metadata** that exists to populate text tokens (an overage price in upgrade copy, a documented rate ceiling), not to control access. Gating a feature on one of them grants it to everyone. ::: ## Next Steps - [Placements Guide](/docs/guides/placements/) — render upgrade prompts for denied entitlements - [Events & Analytics](/docs/guides/events/) — track entitlement check outcomes - [Error Handling](/docs/guides/error-handling/) — fail-closed checks and recovery patterns - [Try it live → Data Export Gate](/docs/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](/docs/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](/docs/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. | ### 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` entitlements always evaluate to allowed. This is by design and is the one explicit carve-out from the fail-closed rule: they are **non-gating metadata**, present to populate text tokens (an overage price in upgrade copy, a documented rate ceiling), not to control access. Do not gate a feature on one of them — a `price_per_unit` check answering "allowed" tells you the price exists, not that the user may proceed. ::: ## 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](/docs/reference/configuration/) — error-related configuration options - [Error Codes Reference](/docs/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. ## 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_started` · `trial_expiring` · `trial_expired` · `usage_limit_approaching` · `usage_limit_exceeded` · `feature_gated` · `payment_retry_required` · `subscription_renewing`. :::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 const rt = initRevTurbine({ tenantId: 'your-tenant', publicKey: 'rtk_…', // 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) is a *server-side decisioning* control — it governs **who is eligible to see** a placement, and stamps its own marker on decision responses. 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](/docs/guides/placements/) — placement lifecycle and interactions - [Runtime Modes](/docs/guides/runtime-modes/) — local vs hosted mode - [Error Handling](/docs/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 const selection = await rt.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 rt.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 const rt = initRevTurbine({ tenantId: 'tenant_abc', publicKey: 'rtk_…', // 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](/docs/guides/placements/) — how placement payloads, targeting, and priority resolve - [Entitlements](/docs/guides/entitlements/) — entitlement rules and precedence - [Events & Analytics](/docs/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 const session = await initRevTurbine({ tenantId: 'tenant_abc', publicKey: 'rtk_…', // 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](/docs/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](/docs/playground/#headless-api): [PlacementController](/docs/playground/#headless-placement) and [SdkSession one-shot](/docs/playground/#headless-session). **EntitlementGate** — an entitlement check without React. _Live example: /playground/#headless-gate_ # 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'; async function handleExport(req, res) { const rt = await initRevTurbine({ tenantId: process.env.REVTURBINE_TENANT_ID!, apiKey: process.env.REVTURBINE_API_KEY!, endpoint: process.env.REVTURBINE_ENDPOINT!, runtimeMode: RuntimeMode.Server, user: { id: req.userId, plan_handle: req.user.planHandle }, }); const result = await rt.can('data_export'); if (!result.allowed) return res.status(402).json({ error: 'upgrade_required' }); await runExport(req); } ``` ```tsx // 2. The upgrade navigation, wired to the placement the renders when denied. router.push(path.url ?? '/pricing')}> ``` See [Client vs Server Enforcement](/docs/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](/docs/getting-started/api-overview/) — `can()` vs `gate()` vs `` - [Entitlements](/docs/guides/entitlements/) — feature / usage-limit / credits / seats rules - [Run an Experiment with Segments](/docs/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 [``](/docs/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](/docs/playground/#msg-banner) ## Modal ```tsx ``` > [Try it live → Data Export Gate](/docs/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](/docs/guides/placements/#fixed-button) ## Full-Page ```tsx ``` ## CLI ```tsx ``` ## Quota Meter (In-Page) ```tsx ``` > [Try it live → Quota Meter](/docs/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](/docs/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](/docs/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](/docs/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](/docs/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](/docs/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](/docs/playground/). **Upgrade Button** — an always-visible CTA in a nav slot. _Live example: /playground/#fixed-button_ More on the Playground: [Plans & Pricing Card](/docs/playground/#fixed-in-page) · [Quota Meter](/docs/playground/#fixed-usage-counter) · [Annual Nudge Banner](/docs/playground/#fixed-banner) · [Usage Warning Banner](/docs/playground/#msg-banner) · [Usage Exhausted Modal](/docs/playground/#msg-modal) · [Trial Welcome Toast](/docs/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](/docs/guides/placement-cookbook/#route-surface) in the Placement Cookbook for the `` boundary, the Next.js recipe, and the pathname fallback. ## Next Steps - [Component Gallery](/docs/components/) — interactive demos of every built-in slot component - [Custom Slot Types](/docs/guides/custom-slots/) — register your own slot components - [Events & Analytics](/docs/guides/events/) — track impressions and interactions - [Interactive Playground](/docs/playground/) — try all slot types live # 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`). ## 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 | `tenantId`, `publicKey`, `endpoint` | | **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](/docs/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](/docs/getting-started/react/) and let it initialize the SDK. See [User Context](/docs/user-context/), [CTA actions](/docs/guides/placements/#cta-actions) and [Theming](/docs/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. They are not a resilience mechanism for `revturbine_server` mode: decisions are evaluated locally from the cached Playbook, so RevTurbine's availability never affects them. 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](/docs/getting-started/server-side/). 4. If you supply your own facts through providers, choose `providerFailureSlotBehavior` per surface. # 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](/docs/guides/custom-slots/) — build components that use the theme - [Component Gallery](/docs/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](/docs/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](/docs/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. | ## 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](/docs/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 ``` # 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 const explanation = await rt.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](/docs/guides/placements/) — the decision input and how placements resolve - [Run an Experiment with Segments](/docs/guides/experiments/) — segment predicates in practice - [Production Readiness](/docs/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](/docs/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; cannot call management APIs. Create it at **Settings → API tokens → Ingest keys**. | | **Server key** | `rtk_…` (type `server`) | Your backend only | Programmatic calls to the RevTurbine API from your server — Playbook delivery, management APIs — and minting the session keys below. Refused on a browser-like request. Create it at **Settings → API tokens → 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` — on the server SDK and on the headless SDK alike; the sample on [Server-Side Integration](/docs/getting-started/server-side/) reads it from `REVTURBINE_API_KEY` — and use it to mint session keys. - **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](/docs/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 tokens → MCP**. 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](/docs/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](/docs/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](/docs/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](/docs/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](/docs/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](/docs/operate/debugging/):** use `explainPlacementDecision()` to see exactly why a decision resolved the way it did. ## Related - [Client vs Server Enforcement](/docs/concepts/enforcement/) - [What Owns What](/docs/concepts/source-of-truth/) - [Debugging Decisions](/docs/operate/debugging/) - [Error Handling](/docs/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. ## 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](/docs/getting-started/installation/) — SDK and CLI setup - [Entitlements](/docs/guides/entitlements/) — use generated handles in checks - [Runtime modes](/docs/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](/docs/getting-started/installation/) — installation instructions - [Configuration Reference](/docs/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 Required in `revturbine_server` and `custom_endpoints` modes. In `local_only` mode both are optional — `initRevTurbine({ runtimeMode: 'local_only', localRuntime: { playbook } })` is a complete configuration. | Field | Type | Description | |---|---|---| | `tenantId` | `string` | Your RevTurbine tenant identifier | | `publicKey` | `string` | In the browser: your public key (`rtk_…`, type `public`, minted under **Settings → API tokens → Ingest keys**). Safe to ship in a bundle. See [Production Readiness → Keys](/docs/operate/production-readiness/#keys). | | `apiKey` | `string` | On a server only: your server key (`rtk_…`, type `server`), the same option the server SDK takes. 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 resolves decisions | | `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](/docs/guides/theming/) for an example. Light/dark selection is the React provider's `colorScheme` prop, not an initialization option. ### 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`). 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 initRevTurbine({ tenantId: 'tenant_abc', publicKey: 'rtk_…', // public key — safe in the browser user: { id: 'user_123', plan_handle: 'free' }, clientSession: () => fetch('/api/revturbine-session', { method: 'POST' }) .then((r) => r.json()) .then((j) => j.client_token), }); ``` 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` | | `placementsByLookupKey` | `Record` — **deprecated, ignored since 0.11.21**; removed in 0.12.0 | | `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. Pre-decided outputs in `placementsByLookupKey` are ignored (with a warning); 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](/docs/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; // ✅ Required publicKey: string; // ✅ Required in the browser (public key); on a server pass the server key as `apiKey` 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; // ✅ Required publicKey: string; // ✅ Required (public key in the browser; `apiKey` = server key on a server) 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](/docs/guides/runtime-modes/) — mode comparison and migration - [Error Codes Reference](/docs/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. | :::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**](/docs/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](/docs/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](/docs/guides/error-handling/) — patterns and strategies # 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](/docs/)) ## 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](/docs/)). 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](/docs/tutorials/low-credits-warning/) and [Recover a Failed Payment](/docs/tutorials/payment-recovery/). ## Try It Live > [Interactive Playground → Banner Slot](/docs/playground/#fixed-banner) ## Next Steps - [Placements Guide](/docs/guides/placements/) — targeting, triggers, and surface types - [Tutorial: Add an Upgrade Button](/docs/tutorials/upgrade-button/) — a persistent CTA button instead of a banner - [Component Gallery](/docs/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](/docs/)) ## 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](/docs/guides/entitlements/#gate-modal) ## Next Steps - [Tutorial: Track Usage and Show a Quota Meter](/docs/tutorials/usage-quota-meter/) — usage-based entitlements - [Entitlements Guide](/docs/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](/docs/)) ## 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](/docs/). **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](/docs/guides/entitlements/). ## Try It Live > [Interactive Playground → Message Banner](/docs/playground/#msg-banner) ## Next Steps - [Entitlements Guide](/docs/guides/entitlements/) — credits, usage limits, and the `limited` state - [Tutorial: Track Usage and Show a Quota Meter](/docs/tutorials/usage-quota-meter/) — the usage-limit equivalent - [Component Gallery](/docs/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](/docs/)) - 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](/docs/playground/#fixed-banner) ## Next Steps - [Placements Guide](/docs/guides/placements/) — segments, targeting, and triggers - [Tutorial: Show a Banner Placement](/docs/tutorials/banner-placement/) — targeted banners - [Tutorial: Show a Trial-Ending Countdown](/docs/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](/docs/)) ## 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](/docs/). **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](/docs/playground/#fixed-banner) ## Next Steps - [Entitlements Guide](/docs/guides/entitlements/) — trials, usage limits, and credits - [Placements Guide](/docs/guides/placements/) — triggers and targeting - [Tutorial: Show a Banner Placement](/docs/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](/docs/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](/docs/guides/placements/#fixed-button) ## Next Steps - [Tutorial: Gate a Premium Feature](/docs/tutorials/gate-premium-feature/) — add an entitlement gate - [Component Gallery](/docs/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](/docs/)) ## 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 exceeds limit if (usagePercent >= 100) { await sdk.emitTrigger('usage_limit_exceeded', { entitlement_handle: 'api_calls', current_usage: currentCalls, usage_limit: 1000, }); } ``` ## Try It Live > [Interactive Playground → Quota Meter](/docs/playground/#fixed-usage-counter) ## Next Steps - [Entitlements Guide](/docs/guides/entitlements/) — all entitlement types - [Events & Analytics](/docs/guides/events/) — trigger events and tracking - [Component Gallery](/docs/components/) — interactive demos of all built-in slot components # 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](/docs/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](/docs/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](/docs/getting-started/server-side/), [Python guide](/docs/getting-started/python/), and [Rust guide](/docs/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'; export const rt = initRevTurbine({ tenantId: 'tenant_abc', publicKey: 'rtk_…', // public key — safe in the browser user: { id: 'user_123', plan_handle: 'free' }, clientSession: () => fetch('/api/revturbine/client-session', { method: 'POST' }) .then((response) => { if (!response.ok) throw new Error('Could not create RevTurbine client session'); return response.json(); }) .then((body) => body.client_token), }); ``` Your backend—not the browser—mints the short-lived `rt_client_…` token. 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](/docs/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](/docs/concepts/core-concepts/) — where user context fits in the decision loop - [React Integration](/docs/getting-started/react/) — provider and hook setup - [Configuration Reference](/docs/reference/configuration/) — initialization options - [Entitlements](/docs/guides/entitlements/) — how plan and usage become access decisions - [Run an Experiment](/docs/guides/experiments/) — direct and provider-owned assignments - [`RevTurbineUserContext` API reference](/docs/api/interfaces/revturbineusercontext/) — generated type details