Skip to content

Headless API

The headless API provides three controllers and a factory function:

ExportPurpose
initRevTurbine()Async factory — creates an initialized SdkSession
SdkSessionSession wrapper with user context, placement, and entitlement APIs
PlacementControllerRegister → decide → impression-track → interact lifecycle
EntitlementGateCheck → auto-gate pipeline for feature access
TypeScript
import {
initRevTurbine,
PlacementController,
EntitlementGate,
} from '@revturbine/sdk/headless';

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 (<Gate>) is a React affordance — in headless code, denied-state upsell surfaces resolve through the slot config you declare.
TypeScript
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);
}

Creates a fully-initialized session. Handles SDK initialization, user identification, theme resolution, and optional placement bootstrapping.

TypeScript
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' } },
],
});

Extends RevTurbineInitInputOptions with:

OptionTypeDescription
bootstrapPlacementsArray<{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.

The session returned by initRevTurbine(). Provides the full imperative API.

TypeScript
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();
TypeScript
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();

Manages the full placement lifecycle: register → decide → impression-track → interact.

TypeScript
const ctrl = session.placement({
surfaceSlot: { id: 'pricing_banner' },
});
await ctrl.load();
if (ctrl.visible) {
renderBanner(ctrl.content);
}
OptionTypeDefaultDescription
surfaceSlot{id, name?, surfaceTemplateIds}—Canonical surface slot (preferred). Headless resolution needs the slot id AND the surface template ids it accepts
placementRevTurbinePlacementConfig—Alternative: placement config
userIdstringSDK userOverride target user
contextModeRevTurbineContextMode'auto'Context resolution mode
overridesRevTurbinePlacementDecisionOverrides—Override segment/plan/usage for testing
traitsRecord<string, string | number | boolean>—Custom traits for decision request
ttlMsnumber—Decision cache TTL
autoTrackImpressionbooleantrueAuto-record impression on visible decision
routestring | (() => 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
TypeScript
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
TypeScript
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
TypeScript
const unsub = ctrl.onChange(() => {
console.log('State changed:', ctrl.state);
updateUI(ctrl.state);
});
unsub(); // stop listening

Checks feature access and optionally resolves a gated placement for denied users.

TypeScript
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);
}
OptionTypeDefaultDescription
handlestring—Entitlement handle (e.g. 'brand_kit')
contextRevTurbineEntitlementContext—Optional usage/tier context
autoGatebooleanfalseAuto-resolve gated placement when denied
gatePlacementRequestOmit<...>—Custom placement request for auto-gate
TypeScript
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
TypeScript
const unsub = gate.onChange(() => {
updateAccessUI(gate.state);
});
await gate.recheck(); // re-run the check
TypeScript
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();
});
ConcernReact hooksHeadless controllers
State managementReact useStateManual via onChange()
LifecycleuseEffectExplicit load() / check()
CleanupHook unmountCall unsub() / reset()
FrameworkReact onlyAny JS runtime
BundleIncludes React depZero framework deps

The React hooks (usePlacement, useEntitlement) delegate to the headless controllers internally — they share the same business logic.

Runs in local_only mode with no backend. Two more headless scenarios are on the Interactive Playground: PlacementController and SdkSession one-shot.

EntitlementGate — an entitlement check without React.

Example.tsx
import React, { useEffect, useState } from "react";
import { initRevTurbine, RuntimeMode } from "@revturbine/sdk/headless";
import playbook from "./playbook.json";
import { demoUsers } from "./demoUsers";
import { selectedUserId } from "./demoUser";
const activeUser = demoUsers[selectedUserId] ?? demoUsers.user_alice;
export function Example() {
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
(async () => {
const session = await initRevTurbine({
// local_only runs against the bundled playbook — no network for
// decisions. previewMode keeps this docs demo out of adoption
// telemetry; omit it in your own app.
runtimeMode: RuntimeMode.LocalOnly,
previewMode: true,
localRuntime: { playbook },
user: activeUser.context,
uiPathResolvers: {},
});
// The check: bound to this session, evaluated against the local playbook.
const gate = session.entitlement({ handle: "brand_kit" });
const entitlement = await gate.check();
gate.dispose();
// On denial, resolve the upgrade placement configured for this gate —
// the same lookup <Gate> makes before rendering it.
let upsell = null;
if (gate.denied) {
const ctrl = session.placement({
surfaceSlot: {
id: "settings_brand_section",
name: "settings_brand_section",
surfaceTemplateIds: ["in_page_card"],
metadata: { surface_slot_category: "gated", entitlement_handle: "brand_kit" },
},
});
const decision = await ctrl.load();
ctrl.dispose();
upsell = decision?.visible ? decision.content : null;
}
setResult({ entitlement, upsell });
})().catch((e) => setError(e.message));
}, []);
return (
<div style={{ padding: 16, fontFamily: "system-ui, sans-serif" }}>
<h3>Gate: EntitlementGate</h3>
<p>Entitlement: <code>brand_kit</code></p>
{error && <pre style={{ color: "red" }}>{error}</pre>}
{result && <pre style={{ background: "#f5f5f5", padding: 12, borderRadius: 8, fontSize: 12, overflow: "auto" }}>{JSON.stringify(result, null, 2)}</pre>}
{!result && !error && <p>Checking…</p>}
</div>
);
}