Entitlements & gating — verified access control across every surface
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.
- 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.
-
Create the entitlement key. Dashboard → Entitlements → New entitlement. Give it the key you'll check in code (
pro,team_seat, …). The key is permanent and case-sensitive. - 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.
-
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(); - 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
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. |
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:
- Keep your existing
is_procode running and reading as-is. Don't touch it yet. Every gate that today readsuser.is_prokeeps readinguser.is_pro. The webhook that flips the flag on a Stripe event keeps flipping it. Your live revenue stays on the path that works. - Add Crossdeck alongside —
init→identify→getEntitlements. 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). - 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. - 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.
- Decommission your old write path. Now — and only now — disable the webhook that sets
is_pro. Crossdeck is the canonical source. Anything writing tois_profrom this point is fighting reality and will surface in Reconciliation as drift. - Retire the old column later, once confident. Don't delete data early. Keep
is_proon 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:
- Log in as an account you know is paid → confirm the gated feature unlocks.
- 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
- An entitlement is a capability key, not a SKU.
prois the right; the Stripe price, AppleproductId, or Google base plan that earned it is implementation detail. The same human paying through different rails on different devices resolves to the same entitlement. - Resolution walks a four-hop chain. Active subscription → rail SKU → CD Product (which groups the SKU) → CD Entitlements (which the Product grants). The materialised result lives at
projects/{p}/customers/{cdcust}/entitlements/{entitlementKey}and the SDK reads from there. See ADR-0001. isEntitled("pro")is microsecond-fast. Synchronous read off the local cache. Safe inside a React render path or a SwiftUIbody. No network call, noawait, no Promise.- The cache is durable last-known-good. Every successful
getEntitlements()is persisted to device storage and re-hydrated synchronously on SDK boot — soisEntitled()is correct from the first call for a returning customer, not cold. A Crossdeck outage never fails a paying customer down to free; only a successful fetch ever replaces the cache. CallgetEntitlements()again after a purchase confirmation to pull the new grant in. - Manual grants are first-class. Comp accounts, gift codes, refund-of-goodwill, design-partner access — fire
crossdeck.grantEntitlement()from your server. The grant survives every re-projection until explicitly revoked. Every mutation lands in the audit ledger with operator UID and rationale. - Paying customer ≠ active entitlement. A refund, chargeback, or subscription cancellation revokes the entitlement on the next projection. The dashboard surfaces drift as "revenue without entitlement" — a critical-severity alert.
- One-off purchases do not grant entitlements. A paid one-off records a Purchase. It does not auto-unlock capabilities. The developer's server explicitly fires
grantEntitlement()after the charge confirms if it wants to. This separation makes revenue accounting and access state independently auditable — and impossible to conflate during refund or chargeback workflows. - Three doors for seeding existing payers — pick the right one. Active Stripe subscribers come in automatically the moment you connect Stripe (don't seed them at all). Existing payers you can't reach via a rail right now go through
POST /v1/migration/users— a developer-assertion claim that yields the moment rail truth arrives.grantEntitlement()is for capabilities with no rail source ever — lifetime deals, founder comps, gifts, support goodwill — and it locks until explicitly revoked, so never use it to import active subscribers. See § Migrating off your existing entitlement flag for the full decision and the safe cutover order. falsefromisEntitled()means three different things. Confirmed not entitled, not loaded yet, OR the key doesn't exist — the same bare boolean for all three. The dangerous one is "not loaded yet": a paying customer on a new device or first-ever load readsfalsebetweenidentify()andgetEntitlements()resolving, identical to a free user. Show a neutral / loading state until your firstgetEntitlements()resolves; treatfalseas authoritative only after that.- The entitlement key is a literal, case-sensitive string with no runtime validation.
isEntitled("Pro")against a dashboard key of"pro"returnsfalseindistinguishably from a genuine non-grant — no "unknown key" warning. Pin keys in a single shared constant; a typo is a silent locked-out customer. - Client
isEntitled()is a UX gate; server-side is the security boundary. The web cache lives inlocalStorageandvalidUntilis checked against the device clock — a motivated user can edit storage or roll their clock back to keep a trial alive. Fine for "should I show the Pro UI?" gating. NOT fine for anything that actually costs you money to serve — paid API responses, paid exports, license-gated asset downloads. Those gate server-side regardless of what the client believes.
The entitlement model
Crossdeck's access model has three layers, authored in different places and serving different roles:
| Layer | Authored where | Example | Role |
|---|---|---|---|
| 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:
- Rail-derived — for each active subscription, walk SKU → Product → Entitlements. While any active source touches an entitlement, it stays granted.
- 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.
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.
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.
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.
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:
| Method | Return | Network | When 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.
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:
- Persisted on every success. Each successful
getEntitlements()writes the entitlement set to device storage (localStorage, via the SDK's storage adapter). - Hydrated on boot, synchronously. The SDK loads that blob back in its constructor before any code runs. A returning Pro customer therefore reads
isEntitled("pro") === truefrom the first call on a fresh page load — there is no cold-start window. - A failed fetch never clears the cache. Only a successful
getEntitlements()replaces it; a network failure throws before it can touch the stored set. A Crossdeck outage cannot fail a paying customer down to free. - Cleared only on identity change.
reset()(logout) and an identity switch wipe both memory and device storage, so one user's entitlements never leak to the next person on the device. Nothing else clears it — there is no TTL on the web cache.
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:
- Resolves an existing
crossdeckCustomerIdfrom the identity-graph (the anonymous device was previously aliased to a user), and returns their entitlements; - Returns an empty list with a fresh
crossdeckCustomerIdminted 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:
- Initial state on mount:
Crossdeck.isEntitled(key)— reads the cache synchronously. Returnsfalseif the SDK isn't initialised yet (pre-initisEntitledthrows; the hook swallows and returnsfalse). - On mount, subscribes via
Crossdeck.onEntitlementsChange(). On every cache mutation, recomputesisEntitled(key)and pushes it into React state — the component re-renders. - SSR-safe:
useEffectis a no-op during server rendering, and the initial state defaults tofalse, so server output never claims a non-existent entitlement. - Idempotent unsubscribe on unmount.
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:
- Listeners fire AFTER the cache mutates (so each listener sees fresh state via
isEntitled()/listEntitlements()). The triggers are: every successfulgetEntitlements(), everysyncPurchases()that lands fresh entitlements, everyreset()(fires the empty-cache state for logout), and everyidentify()(fires the just-rehydrated per-user cache for the new identity). - Listeners are NOT fired on subscribe. The caller is expected to read current state synchronously after registering. The subscription only delivers future changes.
- Listener errors are swallowed. A buggy subscriber cannot crash the SDK or block other listeners. Cumulative error count is surfaced via
diagnostics().entitlements.listenerErrors. - Unsubscribe is idempotent. Calling the returned function multiple times is safe.
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.
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)),
},
});
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
- SSR'd pages. Resolve entitlements during render so the server-rendered HTML matches the post-hydration state — no flash of "logged-in-as-free" before the client cache catches up.
- API gating. A request handler reads
isEntitled({ userId }, "pro")to decide whether to fulfil a request. Auth-grade — the entitlement check is the authoritative gate, not a UI hint. - Scheduled jobs. A nightly batch (sending pro-only emails, running pro-only enrichment) iterates customers and skips non-entitled ones from the same source of truth the SDK serves.
- Webhook handlers. Your server receives a Stripe webhook, takes some action, then needs to know whether to also do a pro-only side effect. Resolve from Crossdeck, not from your local DB or Stripe state directly.
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
| Scenario | Duration | Reason 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:
operatorUid— the secret key's owner identity, or an explicit operator UID for dashboard-driven actions.rationale— thereasonstring the caller supplied. Free-form but stored and surfaced in the dashboard.prevHash— the hash-chained predecessor entry. Tampering is detectable viaverifyJournal().- Before/after state of the entitlement record.
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.
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
| Scenario | Reason 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.
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:
pro(the base plan) +ai_addon(a paid add-on the customer toggles on).pro+pro_plus— the "stacking" pattern. A "Pro Plus" CD Product grants["pro", "pro_plus"]; the resolver materialises both. Gate base features onpro, premium features onpro_plus. The same customer who upgrades from Pro to Pro Plus keepsproactive throughout.cloud_syncas a cross-plan capability granted by multiple plans.
"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
- 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.
- 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.
- 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.
- 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 type | Severity | Meaning |
|---|---|---|
| Unmapped SKU | Warning | A rail SKU exists in catalog/skus but no CD Product groups it. Customers on this SKU get zero entitlements. |
| Unmapped and actively paid SKU | Critical | A rail SKU has paying customers but no CD Product groups it. Revenue without entitlement. |
| Active rail subscription, no entitlement projected | Critical | Customer has an active sub on a SKU that should grant pro, but no pro entitlement record exists. Projection failure. |
| Entitlement projected, no active source | Warning | Customer 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 projection | Critical | Cache/wire issue. Almost always client-side staleness. |
The manual fix for "paying customer, no entitlement" is almost always:
- Identify the unmapped SKU on the Rail SKUs page.
- Create the missing CD Product, or attach the SKU to an existing Product.
- 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:
- The entitlement is the authoritative source of truth — gated by the subscription state, manually grantable, surface-agnostic.
- LaunchDarkly doesn't know about chargebacks, refunds, subscription cancellations, or comp grants. It would have to be told, and that's a second source of truth waiting to drift.
- Crossdeck's projection latency from rail to SDK is sub-second on Stripe webhooks. Feature flag tooling is typically used for roll-outs measured in hours or days; the latencies don't compete.
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:
paywall_viewed— the user hit a gate. Properties:feature,source(which surface triggered it),variant(if A/B testing).paywall_dismissed— the user closed the paywall without converting. Properties:feature,variant,dismissal_method(close-button / back / outside-click).purchase_completed— the user converted. Properties:plan,price,currency,rail(stripe / apple / google).
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
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:
- A new layer attached to an existing group concept (
Crossdeck.group("org", "acme_inc")): a group can hold entitlements. - Resolution becomes the union of: (a) the customer's own entitlements, (b) entitlements attached to every group the customer belongs to.
- An entitlement record on a group surfaces with
source.kind: "group"and the group ID for provenance. - Manual grant / revoke applies to groups via
grantGroupEntitlement({ groupType, groupId, entitlementKey, ... }).
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:
- Stale cache. Last
getEntitlements()was before the purchase. Fix: re-callgetEntitlements()after the purchase confirmation handler — see Refresh after purchase. - The fetch failed silently. Network blip, CORS error, key mismatch. Check
Crossdeck.diagnostics().events.lastErrorand the browser DevTools network tab for/v1/entitlementscalls. - Subscription not yet ingested. Stripe webhook lag (typically < 2 seconds, occasionally longer). Verify in the dashboard's Activity stream that the
subscription.createdwebhook landed. If it didn't, your Stripe webhook endpoint is misconfigured.
getEntitlements() throws on first call
Either:
Crossdeck.init()wasn't called yet — every SDK method throwsnot_initializeduntil init runs.- The publishable key is invalid or doesn't match the environment. Look for
environment_mismatchin the error message — fix by reconciling the key and theenvironmentinit option.
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:
- Always return the unsubscribe function from
useEffect. - For non-React contexts, debounce the listener body if duplicate fires are operationally costly.
- Check
Crossdeck.diagnostics().entitlements.listenerErrors— if it's growing, a listener is throwing on every fire (and the SDK is swallowing the error, but the listener is still wired).
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:
- The entitlement genuinely lapsed. Its
validUntilpassed — a trial or a non-renewing subscription period ended.isEntitled()honours each entitlement's own expiry; this is correct behaviour, not a cache bug. - Serverless cold start with no
entitlementStore. A new Cloud Run / Lambda container starts with an empty cache. The firstgetEntitlements()for a customer warms it; before that call resolves,isEntitled()has nothing. Wire anentitlementStoreso a cold container loads last-known-good — see Serverless cold starts. Checkdiagnostics().entitlements.coldStartDurable. - Wrong identity hint. The
getEntitlements({ userId })warm and theisEntitled({ userId }, …)read must resolve to the same customer. A mismatched hint reads an unwarmed (empty) entry.
Related
- Web SDK reference — full
@cross-deck/websurface including theCrossdeck.init()options, identity model, event tracking, and error capture. - Node SDK reference —
@cross-deck/nodeserver-side surface, including grant/revoke, bulk operations, and Kubernetes readiness probe wiring. - Identify users — how the identity-graph resolves
anonymousId→developerUserId→crossdeckCustomerIdand why entitlements always answer against the canonical customer. - Connect Stripe — wire your Stripe account so subscriptions become entitlements without polling.
- ADR-0001 — Rail SKUs, CD Products, CD Entitlements — the canonical contract this document describes.
- Source on GitHub — the resolver lives at
backend/src/lib/entitlement-resolver.ts; the client cache atsdks/web/src/entitlement-cache.ts.
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.