Crossdeck Docs
Dashboard

Entitlements & gating — verified access control across every surface

Reference Current version: 1.6.3 · ~18 min read · Updated May 28, 2026

An entitlement in Crossdeck is the access right a customer has earned — pro, cloud_sync, ai_addon — independent of which payment rail granted it or which SKU they bought. This document is the deep-dive: how entitlements resolve through the Rail SKU → CD Product → CD Entitlement spine, how the local cache makes isEntitled() a microsecond synchronous read, how server-side resolution gives you provenance and expiry, when to issue a manual grant, and the bank-grade reason a paid one-off purchase does not auto-grant an entitlement.

Zero to first gate

A working isEntitled("pro") gate from scratch, no bouncing between docs. If you've already connected a payment rail, skip step 1.

  1. Connect a payment rail. Dashboard → Settings → Payment rails → Connect Stripe (or Apple / Google). Crossdeck mirrors your live catalog the moment OAuth completes. Full walkthrough: Connect Stripe.
  2. Create the entitlement key. Dashboard → EntitlementsNew entitlement. Give it the key you'll check in code (pro, team_seat, …). The key is permanent and case-sensitive.
  3. Map a rail SKU to a CD Product that grants the entitlement. Dashboard → Products → pick the discovered product (it's already there from the rail mirror) → Grants entitlement → select the key you just created. Rationale for the SKU → Product → Entitlement spine: ADR-0001.
  4. Wire the four SDK lines in your app.
    await Crossdeck.init({ appId, publicKey, environment });
    await Crossdeck.identify("user_847");   // your user's stable ID
    await Crossdeck.getEntitlements();      // warms the cache (identify alone does NOT)
    if (Crossdeck.isEntitled("pro")) showPro();
  5. Verify with a paid + a free account. See § Verify your integration immediately below — it's the one check the contract self-tests can't run for you.

Migrating off your existing entitlement flag

Do not delete your existing entitlement code first.

The safe order is reads-before-writes, verify in the middle. Removing your old gate before Crossdeck's is verified locks out every paying customer behind an unproven integration. Tidy-up is the last step, not the first.

If you're adopting Crossdeck on a live app that already has a homegrown entitlement check — user.is_pro, a Stripe webhook flipping a database column, a custom hasFeature() helper — the migration is straightforward but the order is non-negotiable. The natural instinct of a careful engineer is to clean up first: "I'm adding Crossdeck, let me rip out my old is_pro code as I go." That's the tidy person's mistake. The old gate is your only working source of truth until you've verified Crossdeck's gate; deleting it first means every paying customer hits an unproven path. The reckless engineer who leaves the old code in place is actually safer during cutover — backwards, but exactly why this needs spelling out.

Three doors — pick the right one before you start

Before running the playbook below, decide which path applies to each cohort of existing payers. The wrong door is silent — Crossdeck accepts the write either way — but the downstream behaviour diverges hard, and one of the three doors silently locks the customer's access against future cancellations.

If your existing payer is … Use … Source written When they later cancel / refund
An active Stripe subscriber (rail access available) Do nothing. Connect Stripe and let the discovery backfill import them automatically as tracked subscriptions. source.rail: "stripe" with the real subscriptionId Rail webhook → resolver re-projects → entitlement auto-revokes. Self-correcting.
An existing payer you can't reach via a rail right now — no API key yet, mid-cutover, another rail you haven't connected, lost subscription ID POST /v1/migration/users with the entitlement claim, attribution "migration" source.rail: "developer_assertion" — a tentative claim Rail data lands later → resolver yields, rail wins. Self-correcting as soon as truth arrives.
A capability with no rail source ever — lifetime deal, founder comp, gift code, support goodwill Crossdeck.grantEntitlement() (or the dashboard's Customer detail → Grant entitlement button) source.rail: "manual" — operator-locked Resolver short-circuits to the manual grant — survives every re-projection. Refunds and cancels cannot take it away. Revoke is explicit.
Never use grantEntitlement() to seed active subscribers.

It lands as source.rail: "manual", which the resolver locks in. When that customer later cancels or refunds, the rail event arrives, the resolver runs — and the manual-grant short-circuit blocks the revoke. Every existing payer you "helpfully" granted becomes a permanent freeloader, silently. For active Stripe subscribers, Door 1 is the right answer — connecting Stripe imports them as tracked subscriptions and revokes them automatically when they leave. Door 2 (developer_assertion) is the right answer when rail truth isn't accessible at migration time but you still need them entitled today. Door 3 (grantEntitlement) is the right answer only when there will never be a rail-side cancellation event you want to honour.

The six-step playbook below assumes Door 1 — the common case where you're moving from a homegrown gate to Crossdeck on an app that already takes Stripe. If you're on Door 2, steps (2)–(4) still apply verbatim; step (1) doesn't need anything because there's no Stripe to connect; you swap step (2)'s "connect Stripe" for the bulk POST /v1/migration/users upload. Door 3 doesn't migrate anything — it's a per-customer operator action you take individually as the need arises.

Run the six steps in order. Each step has only the previous one as a precondition:

  1. Keep your existing is_pro code running and reading as-is. Don't touch it yet. Every gate that today reads user.is_pro keeps reading user.is_pro. The webhook that flips the flag on a Stripe event keeps flipping it. Your live revenue stays on the path that works.
  2. Add Crossdeck alongsideinitidentifygetEntitlements. The Crossdeck cache populates in the background; nothing in your app reads from it yet. You can observe what Crossdeck thinks via the dashboard's Reconciliation page while your live gates still ride the old flag — useful confidence build before step (3).
  3. Switch your gate reads to isEntitled("pro"). Replace the gate-site reads only. Leave the writes (the webhook, the column update, the manual admin tools) alone. Your reads now come from Crossdeck; your writes still feed both Crossdeck (via its own rail integration) and the old column (via your existing webhook). Roll out per-feature if the surface is large — one gate site at a time means a regression caught at one site, not all of them.
  4. Verify with the two-account login test — see § Verify your integration. Paid account unlocks; free account stays locked. If either side fails, your reads are still safely backed by the old flag because step (1) preserved it; revert step (3) at one gate site to confirm Crossdeck is the cause, fix, re-verify. The verification gate is what unlocks the irreversible step that follows.
  5. Decommission your old write path. Now — and only now — disable the webhook that sets is_pro. Crossdeck is the canonical source. Anything writing to is_pro from this point is fighting reality and will surface in Reconciliation as drift.
  6. Retire the old column later, once confident. Don't delete data early. Keep is_pro on the user record for a week or two as a read-only audit trail; let the Reconciliation page sit at zero drift across that window; then drop the column in a separate, isolated schema migration. Schema deletion is the last thing.

What you've removed from the failure surface by following the order: any chance of a Crossdeck bug locking out a paying customer during cutover. Steps (1)–(4) build Crossdeck up alongside the working gate; step (5) only fires once Crossdeck is proven; step (6) cleans schema only after observation. Every step is independently revertible until the one before it has held for long enough to trust.

Verify your integration

Crossdeck SDKs ship with structural contract self-tests that fire on every release. They prove the SDK works — caches are isolated per-user, idempotency keys are deterministic, the wire envelope hasn't drifted. They do NOT prove you wired it correctly. Only you know which of your users should be entitled to which capability; the SDK has no way to assert that against your business.

The end-to-end check is one minute:

  1. Log in as an account you know is paid → confirm the gated feature unlocks.
  2. Log in as an account you know is free → confirm it stays locked.

If both hold, the rail-SKU mapping, the identity wiring, and the gating code are all correct end-to-end. If only the paid case fails, the mapping or the refresh-after-purchase path is off — see Refresh after purchase. If only the free case fails (a free account reads true), the entitlement is over-granted somewhere — start with Reconciliation on the dashboard to see which subscription or manual grant is responsible.

TL;DR

The entitlement model

Crossdeck's access model has three layers, authored in different places and serving different roles:

LayerAuthored whereExampleRole
Rail SKU Stripe / Apple / Google console Stripe prod_HX…+price_1NY…, Apple com.app.pro, Google base plan The thing the customer actually pays for. Mirrored into Crossdeck at projects/{p}/catalog/skus/{rail}_{railSkuId} but never authored there.
CD Product Crossdeck dashboard pro_plus with description and marketing copy, grouping a Stripe price and an Apple product The rail-agnostic SKU. Groups N rail SKUs across rails into one developer-facing concept. Carries the metadata (name, description) and explicitly grants one or more Entitlements.
CD Entitlement Crossdeck dashboard pro, pro_plus, cloud_sync, ai_addon The atomic capability key the SDK answers about. What isEntitled("pro") resolves against.

Why three layers instead of one. A customer on iOS, Android, and web buys through three different rails with three different SKU identifiers. RevenueCat solves this with a 5-hop chain (rail SKU → rail-product mirror → mapping → entitlement → SDK) and two mirror layers that drift. Crossdeck collapses it to three hops with one mirror layer: rail SKU → CD Product (with entitlements attached) → SDK. Each mapping is journaled with operator UID and rationale, so the auditor's question — "why does cdcust_xxx have pro right now?" — answers from the ledger in four replayable hops.

The derivation flow (per ADR-0001):

Active rail subscription
   → rail SKU         (sub.items[].productId, optionally + priceId)
   → CD Product       (the Product whose groupedSkus[] contains that SKU)
   → CD Entitlements  (Product.grantsEntitlements[])
   → materialized at customers/{cdcust}/entitlements/{entitlementKey}
   → SDK reads here

The customer's effective entitlement set is the union of two streams:

  1. Rail-derived — for each active subscription, walk SKU → Product → Entitlements. While any active source touches an entitlement, it stays granted.
  2. Manual grants — explicit operator entries on a specific entitlement key. Manual grants always win and survive every re-projection until explicitly revoked.

Tiers are entitlement keys. A free / pro / team ladder is not a special construct — each tier is its own entitlement key, with the CD Products on that tier mapped to it via grantsEntitlements[]. isEntitled("pro") checks one key; a customer holds the set of keys their active subscriptions and manual grants grant. Time-boxed trials are first-class the same way: every entitlement carries its own validUntil (the resolver sets it from the subscription's current-period-end, or a manual grant sets it explicitly), and the entitlement stops granting access the moment that timestamp passes.

Monthly vs yearly — how many entitlements?

One entitlement. Always. If "Pro Monthly" and "Pro Yearly" unlock the same set of features, that's one pro entitlement, and you map it once.

The reason is the three-layer model above. An entitlement is a capability, not a SKU. Billing period is a property of the Stripe Price, which sits a layer below — Crossdeck mirrors one row per Stripe Product (not per Price), so a customer paying $5/mo and a customer paying $50/yr on the same Stripe product both resolve through the same CD Product → same pro entitlement key.

You're not stuck with a boolean — read the full record when you need to branch on plan length.

isEntitled("pro") is the microsecond yes/no for gating a feature, and it returns the same true for monthly and yearly subscribers (correctly — they both have pro). But if your code needs to differentiate them — show "upgrade to yearly" copy to monthly customers, format renewal dates, segment in analytics — read the full PublicEntitlement record via Crossdeck.listEntitlements() (sync, in-memory) or Crossdeck.getEntitlements() (async, network refresh). Each record carries:

{
  key: "pro",
  isActive: true,
  validUntil: 1764412800000,         // ms; next renewal or grant expiry
  source: {
    rail: "stripe",
    productId: "prod_TyXyz…",        // the real Stripe product the sub paid on
    subscriptionId: "sub_1NY…",
  },
  updatedAt: 1762412800000,
}

For the "is this customer pro?" gate, keep using isEntitled("pro") — it's free, synchronous, render-safe. For "is this a yearly customer?" or "when does their plan renew?", inspect source.productId + validUntil on the matching PublicEntitlement. One entitlement, two ways to read it.

The exception: when monthly and yearly unlock different capabilities.

Some products gate a perk on yearly — "Pro" gets the core features, "Pro Annual" also unlocks early access. That's two entitlements: pro and beta_access. You also need two Stripe Products (not just two Prices on one Product) so the catalog can map them separately. Yearly Product → grants both. Monthly Product → grants only pro. Your code does isEntitled("beta_access") in the perk-gated path; both subscribers still pass isEntitled("pro") for the core gate.

If you've modelled monthly and yearly as two Prices on a single Stripe Product, you can't differentiate them by entitlement gate — the catalog collapses Prices into one row. That's the right shape for "same features, different cadence." If you need different feature gates, split them into two Stripe Products on the rail side first; the catalog then gives you two rows to map separately.

Rule of thumb: create one entitlement per capability your code branches on with isEntitled(). If the yes/no answer is the same for monthly and yearly subscribers, it's one entitlement — and you read the plan length from the record's source when you actually need it.

Two overlapping subscriptions on the same key — which validUntil wins? The furthest-out one. If a customer holds two active subs that both grant pro (e.g. a renewing monthly that hasn't yet been cancelled, plus a brand-new annual just purchased), the resolver picks the sub with the longest currentPeriodEnd as the source and stamps that timestamp onto the EntitlementRecord.validUntil. The shorter sub is in the record's history but doesn't shorten the access window. Cancelling either one alone has no immediate effect; access falls back to whichever still-active sub has the next-furthest currentPeriodEnd. Access only ends when every active sub for that key is gone and the longest one's period has expired.

Entitlements are per-customer; per-seat / quantity licensing is not supported yet.

An entitlement is granted or revoked for the whole customer record — there is no seat count or quantity on an EntitlementRecord, a ResolvedEntitlement, or a SubscriptionRecord. A 5-seat team plan grants the team entitlement to the customer who holds the subscription; it does not model five independently-assignable seats. If your product sells seats, model each seat-holder as their own Crossdeck customer for now. Per-seat licensing is a known gap, not a configuration you've missed.

Crossdeck is canonical, not a mirror.

Post-migration, Crossdeck owns customers, subscriptions, products, entitlements, and their projections. The rail is an event source — Stripe webhooks, Apple App Store Server Notifications, Google Real-time Developer Notifications all feed in, but the source of truth for "what does this customer have access to right now" lives in Crossdeck. The SDK reads from Crossdeck; never from the rail directly.

The unmapped-SKU sneeze function. New rail SKUs never auto-create CD Products. They land in catalog/skus unmapped. Until the developer creates a CD Product, attaches the SKU, and assigns entitlements, customers paying for that SKU get zero entitlements. The dashboard's "Reconciliation" page surfaces unmapped-but-actively-paid SKUs as a critical-severity alert. This is intentional — the wrong shape is structurally impossible to ship past.

Resolving entitlements client-side

The client-side surface is three methods on Crossdeck:

MethodReturnNetworkWhen to call
getEntitlements() Promise<PublicEntitlement[]> Yes — one GET Once after init() (and after identify() resolves) to warm the cache.
isEntitled(key) boolean No — cache read Every gate site. Safe inside render paths.
listEntitlements() PublicEntitlement[] No — cache snapshot When you need source / validUntil per entitlement.

Warming the cache

Crossdeck.getEntitlements() POSTs to /v1/entitlements with the highest-priority identity hint available (crossdeckCustomerId > developerUserId > anonymousId) and writes the result into a local EntitlementCache. The backend already filters to active + env-matching entitlements before the response leaves the wire — the SDK trusts the payload and never re-filters.

identify() switches identity but does not fetch entitlements. You must call getEntitlements() after it resolves — otherwise the cache still holds the previous (or anonymous) identity's state, and a paying user can read false. The identify() call does rotate the per-user cache slot and rehydrate it from device storage, so a returning paying user on their own device still reads correctly from last-known-good; but a returning user on a fresh device (or any user whose entitlements changed since the last successful fetch) needs the explicit getEntitlements() to see the current truth.

await Crossdeck.init({ appId, publicKey, environment });
await Crossdeck.identify("user_847");
await Crossdeck.getEntitlements();  // warms the cache

if (Crossdeck.isEntitled("pro")) {
  showPro();  // microsecond cache hit
}

What isEntitled() actually does

It iterates the cached entitlements and returns true iff one matches the key, is isActive, and is not past its own validUntil — so a trial that has timed out reads false even though the cache still holds the record. There is no I/O. There is no await. The return type is boolean, not Promise<boolean>. This is what makes it safe to call inside a hot path:

Throws not_initialized if called before Crossdeck.init(). The React useEntitlement hook and the Vue composable both swallow this and return false so a component mounted during boot doesn't crash; bare callers (vanilla JS, server-rendered HTML, app-shell scripts) must guard for it — call after init() resolves, or wrap in a try/catch and treat the throw as "not entitled yet." The React Native SDK does not throw — it returns false at the call site instead, matching the framework-binding behaviour.

The key is a literal, case-sensitive string with no runtime validation.

isEntitled("Pro") when your dashboard key is "pro" returns false indistinguishably from a genuine non-grant — no "unknown key" warning, no console log, nothing. Same story for "pro_plan", "PRO", or any fat-fingered variant. The SDK has no list of "valid" keys to check against; your dashboard owns the canonical set and the SDK trusts whatever string you pass.

Pin it once: export your keys from a single module and import the constant everywhere you gate. A typo becomes a compile-time error in TypeScript instead of a silent locked-out customer.

// entitlements.ts — the one place keys live
export const ENT = {
  PRO: "pro",
  TEAM: "team_seat",
  AI_ADDON: "ai_addon",
} as const;

// every gate site:
import { ENT } from "@/entitlements";
if (Crossdeck.isEntitled(ENT.PRO)) showPro();
// React — fine. No re-render hookup needed at the call site;
// the wrapper hook handles that (see "Reactive hooks" below).
function ProBadge() {
  if (Crossdeck.isEntitled("pro")) return <Badge />;
  return null;
}

// SwiftUI — fine. The body re-evaluates when @Observable fires.
var body: some View {
  if Crossdeck.isEntitled("pro") { ProView() }
  else { PaywallView() }
}

Cache mechanics — durable last-known-good

The EntitlementCache (sdks/web/src/entitlement-cache.ts) is not a plain in-memory store with a TTL. It is a durable last-known-good cache — the RevenueCat model — and the durability is the point:

Staleness is observable, never silent. A per-entitlement validUntil handles time-based expiry (a trial ending mid-outage still ends). What it cannot catch is an event-based revoke — a chargeback or refund has no validUntil — so during an outage the cache would keep serving a revoked customer. Serving them is the correct trade (don't lock real payers out over a blip), but an invisible stale window is the bug. So the cache flags itself stale once a refresh attempt fails, or once last-known-good ages past 24 hours. The flag is surfaced as diagnostics().entitlements.stale. It changes nothing about what isEntitled() returns — it only makes serving-through-an-outage something you can see and alert on.

The server SDK (@cross-deck/node) follows the same durable last-known-good contract — see Server-side resolution for its per-customer cache, the EntitlementStore option for serverless cold-start durability, and the staleness fields in diagnostics().

isEntitled() is correct from boot for a returning customer.

Because the web cache hydrates from device storage synchronously, the first isEntitled("pro") after init() already reflects the customer's last-known entitlements — even before getEntitlements() resolves. The pending fetch only matters for a brand-new install with nothing stored yet, or to pull in a change made since the last successful fetch. Wiring a reactive binding (useEntitlement, the Vue composable, onEntitlementsChange) is still the right call so the gate re-renders when a fresh fetch lands.

false doesn't mean "not entitled" — it collapses three states into one signal.

The shield callout above covers the happy path: returning customer, hydrated cache, false means free. The trap: for a paying customer on a NEW device, or any first-ever install, the cache is empty between identify() and getEntitlements() resolving. isEntitled("pro") reads false in the same shape a free user does — confirmed-not-entitled and not-loaded-yet collapse to the same bare boolean. Gate immediately and your paying customer sees the paywall flash on first paint before the truth lands. Worse, the API can't tell you "I don't know yet" so you can't show a spinner unless you track resolution yourself.

The pattern: gate on false only after your first getEntitlements() has resolved. Show a neutral or loading state until then — never the paywall. The reactive hooks re-render when fresh state lands, but the FIRST paint is still wrong unless you fence on resolution:

function PaywallGate({ children }) {
  const [isReady, setReady] = useState(false);
  useEffect(() => {
    Crossdeck.getEntitlements().finally(() => setReady(true));
  }, []);
  const isPro = useEntitlement("pro");
  if (!isReady) return <Spinner />;        // "don't know yet" — NOT the paywall
  return isPro ? children : <Paywall />;
}

Returning customers still get the synchronous-from-storage benefit because the cache is already populated before useEffect fires — the loading branch flashes for one render at most. New-device customers stay on the spinner until truth lands. Both safe.

The cache miss path

When getEntitlements() is called before identify(), the SDK falls back to the device's anonymousId as the identity hint. The backend either:

  1. Resolves an existing crossdeckCustomerId from the identity-graph (the anonymous device was previously aliased to a user), and returns their entitlements;
  2. Returns an empty list with a fresh crossdeckCustomerId minted for this device.

The response always carries a canonical crossdeckCustomerId which the SDK persists. Subsequent getEntitlements() calls use that ID directly — no identity-graph walk needed.

Client isEntitled() is a UX gate, not a security boundary

Everything on this page so far — the cache, the synchronous read, the reactive hooks — is designed around making the right UI appear on the right device for the right user. That's UX correctness. It is NOT a security boundary. The web cache lives in localStorage; the validUntil check uses Date.now(). A motivated user can edit the cache in DevTools or roll their device clock back to keep a trial alive indefinitely. For "should I render the Pro badge?" gating that's the right trade — locking out a real paying customer because their clock drifted is worse than letting one determined cheater see a button they shouldn't.

It is the wrong trade the moment the gate sits in front of something that actually costs you money to serve. A server-rendered premium API response. A paid export that hits your compute. A fileserver hit for a license-gated asset. A backend route that issues a third-party API key. For those, the client check is a hint that lets you skip a round-trip on the happy path; the real boundary is the server's own resolution at the request handler. Anything that depends on the answer for billing or capacity has to ask Crossdeck again, server-side, with the actual customer's identity hint — see § Server-side resolution.

A useful heuristic: if the user gaining unauthorised access would cost you money or expose customer data, gate it server-side. If it would just embarrass the UI, the client gate is enough.

Reactive entitlement hooks

The bare Crossdeck.isEntitled() call is a synchronous cache read. React (and Vue, Svelte, SwiftUI, Compose) have no way to know the cache mutated asynchronously — a component that calls isEntitled() directly in a render path shows the empty-cache result forever until something else triggers a re-render.

The fix is onEntitlementsChange(listener): a subscribe API that fires every listener after every cache mutation. Every framework binding is a thin wrapper around it.

React — useEntitlement

From @cross-deck/web/react:

import { useEntitlement } from "@cross-deck/web/react";

function ProBadge() {
  const isPro = useEntitlement("pro");
  return isPro ? <Badge /> : null;
}

Behaviour, exact from sdks/web/src/react.ts:

Sister hook useEntitlements() returns the full active-key array, kept in sync the same way — useful for rendering a settings page that lists unlocked features.

Vue — useEntitlement

From @cross-deck/web/vue:

import { useEntitlement } from "@cross-deck/web/vue";

const isPro = useEntitlement("pro");  // Ref<boolean>
// Template: <span v-if="isPro">Pro</span>

Returns a Vue Ref<boolean> that updates automatically whenever the cache mutates. Uses onMounted + onScopeDispose internally to subscribe and clean up — SSR-safe by the same mechanism React's hook uses.

The listener contract

For any framework or surface — Svelte stores, Solid signals, vanilla JS — the contract is the same:

The identify()-triggered fire is NOT authoritative network state.

identify() doesn't fetch entitlements — it rotates the per-user cache slot and rehydrates from device storage. The fire that follows carries the rehydrated set, which may be empty (brand-new install) or stale (last-known-good from days ago). A listener that gates a paywall on the first fire after an identity switch will read false for a paying customer on a fresh device and let them past the gate as free. Gate on the entitlements present after the next getEntitlements() fire, or have your gating code tolerate the empty-then-populated transition (the framework hooks do this by default — they re-render on every fire).

// Vanilla JS / Svelte store pattern
const unsubscribe = Crossdeck.onEntitlementsChange((entitlements) => {
  // fired after every getEntitlements(), syncPurchases(), reset(), AND identify()
  // (the identify() fire carries the rehydrated local cache — empty/stale until
  // the following getEntitlements() resolves; don't treat it as network truth)
  store.set(entitlements.filter((e) => e.isActive).map((e) => e.key));
});

// Later — on unmount / cleanup
unsubscribe();

Server-side resolution

SSR, scheduled jobs, webhook handlers, and any server-side feature flag need to resolve entitlements without a browser cache. The Node SDK (@cross-deck/node) exposes the same model with a per-customer durable last-known-good cache appropriate for server hot paths — same durability contract as the web SDK, adapted for a multi-tenant process.

crossdeck.getCustomerEntitlements(customerId)

The canonical server-side read. Takes a crossdeckCustomerId and returns the full EntitlementsListResponse:

import { CrossdeckServer } from "@cross-deck/node";

const crossdeck = new CrossdeckServer({ secretKey: process.env.CD_SECRET_KEY! });

const { data: entitlements } = await crossdeck.getCustomerEntitlements("cdcust_xyz");

for (const e of entitlements) {
  console.log(e.key, e.isActive, e.validUntil, e.source);
  // e.g. "pro", true, 1738368000000, { rail: "stripe", subscriptionId: "sub_…", productId: "prod_…" }
}

Each PublicEntitlement carries provenance: which subscription granted it, which rail, when it was last projected, when the current period ends. This is what makes server-side gating auditable — you can answer "why does this user have pro?" from the response payload alone.

crossdeck.getEntitlements({ userId })

When the canonical crossdeckCustomerId isn't to hand, pass a hint:

const response = await crossdeck.getEntitlements({ userId: "user_847" });
// or:
const response = await crossdeck.getEntitlements({ anonymousId: "anon_abc" });

The backend's resolveCrossdeckCustomerId precedence applies (customerId > userId > anonymousId). The Node SDK records the resolved crossdeckCustomerId as an alias for the hint, so a subsequent isEntitled({ userId }, "pro") resolves to the same cache entry without a second HTTP round-trip.

Hot-path gating

For a request-per-second server gating on isEntitled, the call shape is:

// Once per request (or once per request boundary, depending on layout):
await crossdeck.getEntitlements({ userId });  // warm + refresh

// Hot path — synchronous, no HTTP:
if (crossdeck.isEntitled({ userId }, "pro")) {
  // serve the pro path
}

isEntitled() is a synchronous in-memory Map read with zero I/O, and it honours each entitlement's own validUntil. The entitlementCacheTtlMs option (default 60s) is a refresh hint, not an expiry: past the TTL, isEntitled() keeps serving last-known-good — it does not return false because the entry aged. The TTL only tells getEntitlements() when a re-fetch is due. A paying customer is never locked out 60 seconds after a warm just because Crossdeck was briefly unreachable.

Multi-tenant servers: the cache is per-customer, not per-process.

The Node SDK indexes the cache by crossdeckCustomerId with a bounded LRU (default 10,000 customers). High-fanout multi-tenant servers can tune both the cache size and TTL via the constructor options. Listener semantics match the web SDK.

Serverless cold starts — wire an EntitlementStore

The web SDK hydrates last-known-good from localStorage on boot. A long-lived Node server gets the same durability for free: the process stays warm, so the in-memory cache survives between requests. A serverless host does not. On Cloud Run or Lambda a cold start begins with an empty cache — and if a Crossdeck outage overlaps that window, isEntitled() has nothing to serve and returns false. There is no last-known-good to fall back on yet.

This is unavoidable for an in-memory cache on a stateless host, and the SDK does not paper over it: when it detects a serverless runtime with no durable store it emits a sdk.no_durable_store debug warning and stamps a durability fact on its boot telemetry event, so the gap is observable rather than a surprise in production.

The remedy is the entitlementStore option — a pluggable async durable store (the EntitlementStore interface, a load / save pair). Back it with Redis, your own database, or a KV. Once wired, every successful getEntitlements() persists the result to it, and on a network failure the SDK loads last-known-good back from it — so even a cold container rides out a Crossdeck outage. isEntitled() stays synchronous: the store is only ever touched from the already-async getEntitlements(), never from the hot-path read.

import { CrossdeckServer } from "@cross-deck/node";

const crossdeck = new CrossdeckServer({
  secretKey: process.env.CD_SECRET_KEY!,
  entitlementStore: {
    load: (key) => redis.get(key).then(JSON.parse),
    save: (key, snapshot) => redis.set(key, JSON.stringify(snapshot)),
  },
});
On serverless, wire entitlementStore — do not rely on the in-memory cache alone.

Without it, a cold start during a Crossdeck outage reads a paying customer as un-entitled. The full Node SDK reference covers the store contract, diagnostics() staleness fields (staleCustomers, durableStore, coldStartDurable), and a worked Redis adapter.

Use cases for server-side resolution

Manual entitlement grants

A manual grant is an entitlement record created without a rail subscription as its source. Manual grants are first-class in the resolver — they survive every re-projection until explicitly revoked.

await crossdeck.grantEntitlement({
  customerId: "cdcust_xyz",
  entitlementKey: "pro",
  duration: { days: 30 },           // or { lifetime: true }
  reason: "Design partner — Q2 2026 program",
});

Use cases

ScenarioDurationReason format
Comp account (employee, friend, family){ lifetime: true }"Employee comp — Vista Apps"
Gift code / promo redemption{ months: 3 }"Gift code GIFT-2026-Q2 redeemed by user_847"
Refund-of-goodwill (keep them happy){ days: 30 }"Goodwill ext after refund req-9281"
Design partner / beta program{ months: 6 }"Design partner — Q2 2026 program, ref DP-013"
Support escalation (broken feature recompense){ days: 14 }"Support escalation #4451 — extended free trial"
One-off purchase fulfillment (lifetime unlock){ lifetime: true }"One-off lifetime unlock for charge_…"
Migration import from prior platform{ lifetime: true } or per-record"Migrated from Gumroad — order #2284"

Idempotency

Grant calls are idempotent on (customerId, entitlementKey) at the spine level: granting pro to the same customer twice with the same parameters produces one record, not two. Bulk operations (bulkGrantEntitlement) fan out across an input array with bounded concurrency (default 5); each grant fires its own request and the result returns as a settled-promise array so partial failures don't drop the rest of the batch.

const results = await crossdeck.bulkGrantEntitlement([
  { customerId: "cdcust_a", entitlementKey: "pro_q1_bonus", duration: { days: 90 }, reason: "Q1 ops sweep" },
  { customerId: "cdcust_b", entitlementKey: "pro_q1_bonus", duration: { days: 90 }, reason: "Q1 ops sweep" },
  // … hundreds more
]);

for (const r of results) {
  if (!r.ok) console.error("Failed:", r.input.customerId, r.error.message);
}

The audit trail

Every grant lands in the catalog journal as an append-only entry with:

The materialised entitlement carries source.rail: "manual" on the customer record. The resolver's first check, on every re-projection, is "is there an existing manual grant for this key?" — if yes, the manual source wins and rail-derived candidates are short-circuited. See resolveEntitlements() in backend/src/lib/entitlement-resolver.ts.

Always pass a meaningful reason.

It surfaces in the audit dashboard, support tooling, and the HSBC audit-trace answer. "Migration import" tells you nothing in three months. "Migrated from Gumroad order #2284 (batch 2026-05-14)" tells your future self everything.

Manual revokes

The mirror operation. Use to immediately revoke an entitlement regardless of subscription state.

await crossdeck.revokeEntitlement({
  customerId: "cdcust_xyz",
  entitlementKey: "pro",
  reason: "Chargeback — Stripe dispute du_1xyz, account suspended",
});

Use cases

ScenarioReason format
Chargeback / dispute opened"Chargeback — Stripe dispute du_… — access suspended pending resolution"
ToS violation / fraud"ToS violation case #… — fraud detected on 2026-05-12"
Refund issued (no goodwill extension)"Refund issued for charge_… on user request"
Manual grant correction"Reverting accidental manual grant — was meant for cdcust_abc, not cdcust_xyz"
End-of-program access removal"Design partner program ended 2026-Q2 — converting to standard plan"

What revoke actually does

Revoke writes a new entitlement record with state: "revoked" and source.rail: "manual". The resolver's manual-grant precedence applies: even if the customer has an active rail subscription that would otherwise grant the entitlement, the manual revoke wins. The customer's isEntitled("pro") returns false on the next cache warm.

To "un-revoke" — i.e. restore access after a manual revoke — issue a new grantEntitlement() with the appropriate duration. There is no "un-revoke" operation; revoke + grant is the symmetric pair, both journaled.

Bulk revoke (bulkRevokeEntitlement) follows the same settled-array contract as bulk grant.

Refresh after purchase

The customer just bought Pro. Stripe Checkout returned success, the webhook fired, the backend projected the new entitlement. But the client-side cache is stale — the last getEntitlements() call was before the purchase. isEntitled("pro") still returns false and the paywall keeps showing.

Three patterns, in order of preference:

Pattern A — call getEntitlements() after the purchase confirmation (recommended)

The clean default. The moment your purchase-confirmation handler fires (Stripe Checkout return URL, in-app purchase callback, etc.), refresh the cache:

// Stripe Checkout success page
useEffect(() => {
  async function onPurchaseComplete() {
    await Crossdeck.getEntitlements();  // fresh fetch
    Crossdeck.track("purchase_completed", { plan: "pro" });
  }
  onPurchaseComplete();
}, []);

useEntitlement("pro") hooks elsewhere in the tree re-fire automatically once the cache lands — the paywall component re-renders and reveals the gated UI.

Pattern B — wire onEntitlementsChange and re-fetch on signal

For more complex flows (deferred verification, async webhook delivery, Apple StoreKit 2 paths) where the cache update may arrive through syncPurchases() instead of an explicit fetch. The listener fires once syncPurchases() writes the response into the cache:

Crossdeck.onEntitlementsChange((entitlements) => {
  if (entitlements.some((e) => e.key === "pro" && e.isActive)) {
    hidePaywall();
    showOnboarding();
  }
});

// Trigger via syncPurchases for Apple StoreKit transactions:
await Crossdeck.syncPurchases({ signedTransactionInfo });

Pattern C — wait for the next natural refresh

The lazy fallback. The cache catches up the next time the app calls getEntitlements() — the boot fetch on the next page load, or any later app-lifecycle refresh. There is no automatic web-side timer doing this, so "eventually" means "the next time your code calls it." Acceptable only for non-paywall code paths (a settings page that lists unlocked features — the user can re-open it tomorrow). Never acceptable for a paywall flow — the customer just paid and is staring at a locked screen. Use Pattern A.

Pattern A is the right paywall pattern.

Wire getEntitlements() into the purchase-confirmation success handler. Pattern B is for complex multi-rail flows. Pattern C is for code paths where staleness is acceptable. Never leave a paying customer staring at a paywall.

Server-side: re-warming after a webhook

Same pattern, server-side. After processing a Stripe customer.subscription.created webhook, re-fetch so the cache entry for that customer reflects the new grant:

// Inside your webhook handler, post-projection:
await crossdeck.getEntitlements({ customerId: cdcust });
// The successful fetch replaces last-known-good; every subsequent
// isEntitled() returns the fresh state.

Multi-entitlement gating

Compose entitlements as you'd compose any other boolean expression. The SDK has no opinions — it answers about one key per call, fast.

// AND — both must be active
if (Crossdeck.isEntitled("pro") && Crossdeck.isEntitled("ai_addon")) {
  showAiCopilot();
}

// OR — either grants access
if (Crossdeck.isEntitled("pro") || Crossdeck.isEntitled("enterprise")) {
  showCloudSync();
}

// React — composed hooks
const isPro = useEntitlement("pro");
const hasAi = useEntitlement("ai_addon");
return isPro && hasAi ? <AiCopilot /> : null;

One entitlement vs many — the design rule

Use one entitlement when the capability is one logical feature with no add-on shape. pro grants the entire Pro feature set; you don't split it into pro_sync and pro_export and pro_themes unless those are independently sellable.

Use many when capabilities can be sold or granted independently:

Don't model entitlements as a hierarchy.

"Pro implicitly includes Free" is an inheritance layer that obscures the audit trace. ADR-0001 rejects implicit stacking explicitly. Make every entitlement the Product grants an explicit member of grantsEntitlements[]. The mapping is the contract; one less concept to debug.

One-offs do NOT grant entitlements

The bank-grade rule, decided 2026-05-15 and codified in ADR-0001:

A paid one-off charge (lifetime unlock, consumable, IAP non-renewing) records a Purchase. It does not auto-grant an entitlement.

The entitlement resolver only walks active subscriptions. One-off purchases are revenue events, not capability grants. If the developer wants lifetime access from a one-off charge, their server fires an explicit grantEntitlement() with { lifetime: true } when their checkout webhook confirms — that lands as a manual grant on the customer record with full operator-UID + rationale attribution.

Why this separation matters

  1. Refunds. A subscription cancellation revokes through the same projection path that granted it — clean, journaled, auditable. A one-off refund leaves a Purchase record marked refunded but the manual grant it generated stays put until you explicitly revoke it. That's a feature: it forces the operator to make a conscious decision about access removal when the customer's money goes back. No silent revokes via accidental projection bugs.
  2. Chargebacks. Stripe disputes can drag on for weeks. The Purchase record reflects the chargeback state; the manual grant continues to provide service while the dispute is open (or your ops team revokes immediately — your call, journaled). Decoupling means the access decision is policy, not an automatic consequence of payment state.
  3. Audit clarity. Two independent ledgers — Revenue (Purchases) and Access (Entitlements) — can be reconciled against each other. Conflating them collapses the audit surface to one ledger that has to answer two questions.
  4. Gift cards and codes. A gift-code redemption fires grantEntitlement() with no associated Purchase. The customer has access; there's no revenue record. Decoupling makes this natural.

The integration pattern

// Server — your Stripe webhook handler for a one-off charge
if (event.type === "checkout.session.completed" && session.mode === "payment") {
  const customerId = await resolveCrossdeckCustomerId(session.customer);

  // The Purchase record lands automatically via the webhook ingest.
  // YOU explicitly decide whether to grant access:
  await crossdeck.grantEntitlement({
    customerId,
    entitlementKey: "pro_lifetime",
    duration: { lifetime: true },
    reason: `One-off lifetime unlock for charge ${session.payment_intent}`,
  });
}

The grant is explicit, journaled, and reversible. If a refund or chargeback arrives later, your ops surface decides whether to issue the corresponding revoke.

Reconciliation — paying customer ≠ active entitlement

The dashboard's Reconciliation page is a three-way diff: Rail truth | CD projection | SDK-served. It runs daily on schedule and on demand, and surfaces drift across two dimensions:

Drift typeSeverityMeaning
Unmapped SKUWarningA rail SKU exists in catalog/skus but no CD Product groups it. Customers on this SKU get zero entitlements.
Unmapped and actively paid SKUCriticalA rail SKU has paying customers but no CD Product groups it. Revenue without entitlement.
Active rail subscription, no entitlement projectedCriticalCustomer has an active sub on a SKU that should grant pro, but no pro entitlement record exists. Projection failure.
Entitlement projected, no active sourceWarningCustomer has pro but no rail sub and no manual grant. Likely a stale projection. Investigates whether the manual grant was supposed to expire.
SDK serves different state than CD projectionCriticalCache/wire issue. Almost always client-side staleness.

The manual fix for "paying customer, no entitlement" is almost always:

  1. Identify the unmapped SKU on the Rail SKUs page.
  2. Create the missing CD Product, or attach the SKU to an existing Product.
  3. The next projection pass picks up active subscriptions on that SKU and materialises entitlements.

When projection lag is unacceptable (the customer is on the phone with support), bridge with a manual grant:

await crossdeck.grantEntitlement({
  customerId,
  entitlementKey: "pro",
  duration: { months: 1 },
  reason: "Support escalation — paying for prod_xxx but SKU unmapped (ticket #4451). Map SKU + revoke this grant once projection catches up.",
});

Feature flag integration

Crossdeck entitlements are the feature flag for the entitled feature. Don't wire LaunchDarkly to gate pro features; wire isEntitled("pro"). Reasons:

When you need both

A real example: pro entitlement gates the feature; a feature flag gates the rollout.

// Customer is a Pro user → eligible for the AI feature.
// Rollout flag is at 30% → only some Pro users see it yet.
const isPro = useEntitlement("pro");
const aiRolloutOn = useFlag("ai_copilot_v2");

if (isPro && aiRolloutOn) {
  return <AiCopilot />;
}

The entitlement answers "is the customer allowed to have this?". The flag answers "have we shipped this to them yet?". Two different questions, two different surfaces. The mistake is using the flag tooling to answer the first question.

Paywall integration

The canonical paywall pattern:

function ProFeature() {
  const isPro = useEntitlement("pro");

  if (!isPro) {
    Crossdeck.track("paywall_viewed", { feature: "export", source: "export-button" });
    return <Paywall />;
  }

  return <ExportDialog />;
}

Track the paywall lifecycle

Three events tell you everything you need to know about paywall performance:

The funnel viewable + dismissed → purchase_completed is the conversion measurement. See the events doc for naming conventions and standard property bags.

Post-purchase refresh

Repeated from the Refresh after purchase section because it's the most common bug: call getEntitlements() after the purchase confirmation handler fires. Without it, the user pays, the webhook lands, the backend projects, but the client cache still says isEntitled("pro") === false and the paywall stays open.

Family / group entitlements Roadmap

Family / group entitlements are roadmap, not shipped.

The Web SDK and Node SDK answer isEntitled(key) against the resolved individual customer record. Group-shared entitlements (Apple Family Sharing, Stripe team subscriptions, organisation-level access) are planned but not yet implemented. Build today against per-customer entitlements; the group surface will extend the model, not replace it.

The planned shape:

Tracking item: P8 in DEVELOPER_PLAYGROUND.md (the internal product playground for sequencing post-launch work). When this ships, the existing per-customer API stays unchanged — isEntitled("pro") just starts returning true when the customer is in a group that has pro.

Troubleshooting

Before working through individual symptoms below, run the one-minute end-to-end check at § Verify your integration — paid account unlocks, free account stays locked. Whichever side fails tells you which class of bug you're chasing, and most readers stop reading here.

isEntitled("pro") returns false but the customer paid

Three causes, in order of frequency:

  1. Stale cache. Last getEntitlements() was before the purchase. Fix: re-call getEntitlements() after the purchase confirmation handler — see Refresh after purchase.
  2. The fetch failed silently. Network blip, CORS error, key mismatch. Check Crossdeck.diagnostics().events.lastError and the browser DevTools network tab for /v1/entitlements calls.
  3. Subscription not yet ingested. Stripe webhook lag (typically < 2 seconds, occasionally longer). Verify in the dashboard's Activity stream that the subscription.created webhook landed. If it didn't, your Stripe webhook endpoint is misconfigured.

getEntitlements() throws on first call

Either:

The onEntitlementsChange listener fires twice

The listener was registered twice. Common cause: a React component effect that runs in StrictMode (development double-invokes), or the same component mounts twice without the unsubscribe running between mounts. Fix:

Test publishable key + live-seeded customer (or the reverse)

If you seeded a customer's entitlement on the live environment but initialised the SDK with the test publishable key — or any other env mismatch between the SDK key and the data — getEntitlements() resolves to an empty list with no error, and isEntitled("pro") reads false indistinguishably from a free user. The backend filters records strictly by env (records whose env field doesn't match the caller's resolved env are dropped before the response leaves the wire), and the caller's env is derived from the publishable key. A mismatched key sees a clean, separate dataset that just happens to be empty. Check Crossdeck.diagnostics().options.environment against the customer's seeding env; the common cause is a dev environment hitting a key minted in the live workspace.

Active subscription in Stripe but no entitlement in Crossdeck

Almost always an unmapped SKU. Check the Reconciliation page for "unmapped and actively paid" alerts. Fix: create or update the CD Product to group the rail SKU and assign the entitlement. Re-projection runs automatically; no replay command needed.

Manual grant doesn't survive subscription cancellation

This should be impossible — the resolver short-circuits to the manual grant before walking subs. If you see it, capture the entitlement record (it should show source.rail: "manual") and the journal entries via the dashboard's audit view, and file an issue. Most likely cause when this happens in practice: someone issued a revokeEntitlement() as part of the cancellation flow without realising the manual grant was there.

Server-side isEntitled() returns false after a recent getEntitlements()

It is not the TTL — the Node cache never expires to false; past the entitlementCacheTtlMs refresh hint it keeps serving last-known-good. Real causes, in order of frequency:


Last updated 2026-05-15, reflecting the ADR-0001 revision that codified one-offs as revenue events distinct from entitlement grants. The contract is locked through v1.x of the SDKs; future revisions will be journaled here and in the ADR.