Crossdeck Docs
Dashboard

@cross-deck/web — Web SDK reference

Reference Current version: 1.9.0 · ~20 min read · Updated May 11, 2026

@cross-deck/web is one SDK that handles all three Crossdeck pillars in a browser: verified subscriptions and entitlements, behavioural analytics, and error capture. Install it once, initialise with three options, and every customer gets one timeline across Stripe, Apple, and Google Play — plus events, sessions, Web Vitals, and uncaught errors flowing into the same dashboard.

TL;DR

Install

Pick the install path that matches your stack. Every path ships the same SDK with the same API — only the surrounding wrapper changes. The onboarding flow asks which framework you're on and generates the right snippet automatically; this section is the manual reference.

FrameworkInstall pathWhere the snippet lands
Next.js (App Router)npm install @cross-deck/webapp/providers.tsx as a client component
React (CRA / Vite)npm install @cross-deck/webTop-of-tree Provider in src/main.tsx
Vue 3npm install @cross-deck/websrc/crossdeck.ts + installCrossdeck(app, getUserId) from main.ts
Svelte / SvelteKitnpm install @cross-deck/websrc/lib/crossdeck.ts + startCrossdeck() from +layout.svelte onMount
Vanilla JS (bundler)npm install @cross-deck/webYour entry file (typically src/main.ts)
Plain HTML (no bundler)<script src="https://unpkg.com/@cross-deck/web@^1/…"><head> of every page that needs Crossdeck (Webflow, Framer, plain HTML, <script> tag)
Node.js (SSR, scripts, tests)npm install @cross-deck/webServer entry, test fixture, or CLI main function — init with MemoryStorage + autoHeartbeat: false

npm — for bundled apps

npm install @cross-deck/web

If you're using React, the same package exports React hooks at @cross-deck/web/react. If you're using Vue 3, Vue composables live at @cross-deck/web/vue. Both are tree-shake-friendly subpath imports — no extra npm install step. Svelte and vanilla JS use the core @cross-deck/web import directly.

CDN script tag — for plain HTML pages

<script src="https://unpkg.com/@cross-deck/web@^1/dist/crossdeck.umd.min.js"></script>

The UMD bundle exposes window.Crossdeck.Crossdeck for direct use. jsdelivr.net works as a mirror at the same path. Pin to the current major (@^1) — it tracks every 1.x release but never a breaking 2.0. Avoid @latest, which ships breaking changes to your customers on every release.

Node.js — for server-side use

The same package works in Node 18+ for tests and rendering use cases. Pass { storage: new MemoryStorage(), autoHeartbeat: false } at init so the Node runtime doesn't try to read localStorage or fire boot heartbeats. Browser-only features (window.onerror, fetch wrapping, page.viewed, sessions) silently no-op — you only get the API methods you call explicitly.

import { Crossdeck, MemoryStorage } from "@cross-deck/web";

Crossdeck.init({
  appId: "app_node_xxx",
  publicKey: "cd_pub_test_…",
  environment: "sandbox",
  storage: new MemoryStorage(),
  autoHeartbeat: false,
});
The npm scope is @cross-deck with a hyphen.

Not @crossdeck. AI assistants frequently guess wrong here — if npm install errors with "package not found," the scope is the cause.

Quickstart — five lines, three pillars

The fastest path from zero to "events flowing, entitlements checking, errors caught" is a Next.js / React app:

"use client"
import { useEffect } from "react";
import { Crossdeck } from "@cross-deck/web";
import { useEntitlement } from "@cross-deck/web/react";

export function RootProvider({ userId, children }) {
  useEffect(() => {
    Crossdeck.init({
      appId: "app_web_xxx",
      publicKey: "cd_pub_test_…",
      environment: "sandbox",
    });
    // Warm the entitlement cache so isEntitled() answers in microseconds
    Crossdeck.getEntitlements();
  }, []);

  useEffect(() => {
    if (userId) Crossdeck.identify(userId);
    else Crossdeck.reset();
  }, [userId]);

  return children;
}

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

That single setup gives you, automatically and without writing any more code:

Initialise the SDK

Every browser app calls Crossdeck.init() exactly once at boot.

Crossdeck.init({
  appId: "app_web_xxx",           // required
  publicKey: "cd_pub_test_…",     // required
  environment: "sandbox",         // required: "sandbox" | "production"
});

Three required options. The trio is validated up-front — a typo'd key, a missing app ID, or a key prefix that doesn't match the declared environment all throw CrossdeckError at boot rather than failing silently at first network call.

Key prefix must match the environment.

cd_pub_test_… pairs with environment: "sandbox". cd_pub_live_… pairs with environment: "production". A mismatch throws CrossdeckError({ code: "environment_mismatch" }) — designed to fail loudly so test telemetry never silently routes to your production dashboards.

Localhost dev mode (automatic)

The SDK detects when it's running on a local-development hostname (localhost, 127.0.0.1, ::1, *.local, RFC1918 ranges like 10.x / 192.168.x) and switches to a silent dev mode: every method works normally, but no network calls fire. A single line lands in the console explaining what happened:

[crossdeck] Localhost detected — running in dev mode (no network calls).
Set publicKey: 'cd_pub_test_…' and deploy to a real domain to test against the Crossdeck Sandbox.

This is intentional and Stripe-grade. A developer running their app on localhost:3000 with a live key cannot accidentally pollute their production analytics.

Idempotency

init() is synchronous and idempotent. Calling it twice with the same options is a no-op; calling it with different options replaces the previous configuration. There is no second call needed to "start" the SDK — init() IS the start.

Identity & users

Crossdeck tracks two identity axes:

A third value, crossdeckCustomerId (the cdcust_… identifier), is the canonical Crossdeck-side handle. The backend resolves it from the other two via an identity-graph merge. Most developers never have to think about it directly — the SDK persists it on the device after the first identify() resolves and uses it for subsequent calls.

Crossdeck.identify(userId, options?)

Link the anonymous device to your stable user ID. Returns a promise resolving to the resolved customer record.

await Crossdeck.identify("user_847", {
  email: "[email protected]",
  traits: {
    name: "Wes",
    plan: "pro",
    signedUpAt: "2026-05-11",
  },
});

Each call merges additively per key on the customer record. A later identify("user_847", { traits: { plan: "enterprise" } }) updates only plan — it does not wipe name or signedUpAt.

Traits are sanitised at the SDK boundary. Functions, symbols, and undefined values are dropped. Date objects are coerced to ISO strings. BigInt values are coerced to strings. Strings longer than 1024 characters are truncated with an ellipsis. The server applies a second-layer cap: max 32 keys, max 1 KB per value, primitives only — nested objects and arrays are dropped silently.

Wire to a real auth provider — never hardcode a placeholder.

Code that calls Crossdeck.identify("user_123") aliases every visitor to the same fake user. Wire to session.user.id (NextAuth), user.uid (Firebase Auth), session.user.id (Supabase), or whatever your provider returns. Pass null when logged out — the SDK clears identity automatically.

Crossdeck.reset()

Wipe persisted identity, entitlement cache, super-properties, groups, breadcrumbs, error context, and queued events. Call on logout. Synchronous. The next session mints a fresh anonymousId so a new user on the same device starts as a clean identity graph entry.

Before wiping, the SDK emits a user.signed_out event with the old identity stamped on it. The event leaves the queue with the correct user before reset clears local state — your dashboard's Activity stream shows the right sign-out attribution, not an anonymous orphan.

Super properties — set once, attached to every event

Mixpanel's register pattern. Register a property bag once; the SDK auto-attaches those keys to every subsequent track() call's properties.

Crossdeck.register({ plan: "pro", releaseChannel: "beta" });
Crossdeck.track("paywall_viewed");
// → properties include plan: "pro", releaseChannel: "beta"

Crossdeck.unregister("releaseChannel");
Crossdeck.getSuperProperties();
// → { plan: "pro" }

Values that are null are deleted (explicit "stop tracking this key"). Values that are undefined are silently ignored. Caller-supplied properties on track() override super-properties on key collision — your per-event data is always more authoritative.

Super properties persist across page reloads via localStorage (under the key crossdeck:super_props). They are cleared on Crossdeck.reset() and Crossdeck.forget() because they are identity-scoped, not SDK-instance-scoped.

Group analytics — for B2B SaaS

Associate the current user with one or more organisational entities (an organisation, team, account, workspace, plan tier). Every subsequent event carries $groups.<type>: id on its properties bag, so dashboards can pivot on group membership.

Crossdeck.group("org", "acme_inc");
Crossdeck.group("team", "design", { headcount: 12 });
Crossdeck.track("paywall_viewed");
// → properties include $groups: { org: "acme_inc", team: "design" }

Pass id: null to clear a membership. Multiple types coexist — a user can simultaneously belong to an org, a team, and a plan. Crossdeck.getGroups() returns the current state.

Events & analytics

Crossdeck.track(name, properties?)

Synchronous — enqueues an event. The network round-trip happens in the background batched flush.

Crossdeck.track("paywall_viewed", {
  variant: "v3",
  source: "upgrade-button",
});

Properties are validated at the SDK boundary before the event enters the queue. The contract is bulletproof — one bad property can never crash JSON.stringify at flush time or poison the batch:

The output is JSON-safe by contract.

Auto-tracked events

When init() is called in a browser, the SDK installs its auto-tracker and emits the events in the table below without any further code. Each can be disabled via the autoTrack init option (covered in Configuration reference).

Event name When it fires Notable properties
session.started On init(), and after > 30 minutes of tab-hidden idle. sessionId
session.ended On pagehide / beforeunload. Deduped per session — one terminal flush. sessionId, durationMs
page.viewed On init(), and on every pushState / replaceState / popstate. 250 ms dedup window for framework double-fires. pageviewId, path, url, search, hash, title, referrer
element.clicked Every interactive click. Walks up to the nearest button, a, [role="button"], or [data-cd-event] ancestor. Skips form inputs, password fields, and cd-noTrack subtrees. selector, tag, text, elementId, href, viewportX/Y, plus every data-cd-prop-* attribute as a property
webvitals.lcp At page hidden — final Largest Contentful Paint time. valueMs
webvitals.inp At page hidden — worst Interaction to Next Paint time observed. valueMs
webvitals.cls At page hidden — accumulated Cumulative Layout Shift score (unitless). value
webvitals.fcp First Contentful Paint — fires as soon as the browser reports it. valueMs
webvitals.ttfb Time To First Byte — fires once after navigation timing is ready. valueMs
user.signed_out Emitted by Crossdeck.reset() immediately before identity is wiped. auto: true
error.unhandled Every uncaught synchronous error caught by window.onerror. See Error capture.
error.unhandledrejection Every unhandled promise rejection caught by window.onunhandledrejection. See Error capture.
error.http Every fetch / XMLHttpRequest that returns 5xx or fails at the network layer. api.cross-deck.com is excluded to prevent cycle-amplification. http: { url, method, status, statusText }
error.handled Emitted by Crossdeck.captureError(err). See Error capture.
error.message Emitted by Crossdeck.captureMessage(msg). level, message
Do not manually track("Page.viewed") or track("App.opened").

The auto-tracker already emits page.viewed and session.started. Calling them manually doubles every count and corrupts every funnel. Use track() for domain events your app actually generates — paywall_viewed, export_started, onboarding_completed — not for lifecycle events the SDK handles.

Enrichment attached to every event

Every event the SDK ships carries a baseline set of properties auto-attached by the client. You don't have to send any of these explicitly; they appear on every event automatically:

Click autocapture with custom tags

The default element.clicked event covers most behavioural analytics. For higher-fidelity tagging, add data-cd-* attributes to your HTML:

<button data-cd-event="upgrade_clicked" data-cd-prop-plan="pro" data-cd-prop-source="paywall">
  Upgrade
</button>

This emits upgrade_clicked (instead of element.clicked) with { plan: "pro", source: "paywall" } on the properties bag.

To exclude a subtree from autocapture (e.g. a sensitive form, an embedded widget), add class="cd-noTrack" or data-cd-noTrack to its container. No events fire from descendants of an opted-out element.

Flush control

Events flush automatically when the buffer reaches eventFlushBatchSize (default 20) or after eventFlushIntervalMs of inactivity (default 1500 ms). To force-flush before a critical moment:

await Crossdeck.flush();

The SDK also installs pagehide, beforeunload, and visibilitychange→hidden listeners that fire a terminal flush with keepalive: true, so events queued just before unload still land on the server.

Entitlements & gating

Entitlements are the named "what this user is allowed to do" facts your app reads to gate paid features — pro, team, ai_addon, whatever your catalog defines. A customer holds a set of them. The SDK maintains a durable local cache so isEntitled("pro") answers in microseconds — safe inside a React render or any hot path.

Crossdeck.getEntitlements()

Async. Fetches the current customer's active entitlements from the server, replaces the local cache, and persists it to device storage. Call once after identify() resolves; subsequent isEntitled() / useEntitlement() reads are sync cache hits. A failed fetch never clears the cache — only a successful fetch replaces it.

const entitlements = await Crossdeck.getEntitlements();
// → PublicEntitlement[]

Crossdeck.isEntitled(key): boolean

Synchronous. Reads from the local cache, with zero I/O. Returns true iff a matching entitlement is active and not past its own validUntil — so a timed-out trial reads false even while still cached. Microsecond lookup — call it inside any render path. For a returning customer it is correct from the very first call: the cache hydrates from device storage on SDK boot (see below). It only returns false for a genuinely unknown entitlement, an expired one, or a brand-new install with nothing stored yet.

The cache is durable last-known-good

The entitlement cache (sdks/web/src/entitlement-cache.ts) is not a plain TTL cache. It is durable last-known-good — the RevenueCat model — and that has two consequences worth relying on:

Serving stale data through an outage is the right trade, but it must be visible. Once a refresh attempt fails — or last-known-good ages past 24 hours — the cache flags itself stale, surfaced as diagnostics().entitlements.stale. Staleness never changes what isEntitled() returns; it only makes serving-through-an-outage something you can observe and alert on (an event-based revoke such as a chargeback has no validUntil, so this flag is how that case stays visible).

Never await Crossdeck.isEntitled(...).

It is not a promise. The await works (resolves the boolean) but signals the wrong mental model in code review and in AI-generated diffs.

In React, do not call isEntitled directly inside render. The hydrated cache is correct at first paint, but a later getEntitlements() or syncPurchases() mutates it asynchronously — and React has no way to know, so a bare-call component is frozen on whatever the cache held at mount. Use the useEntitlement(key) hook from @cross-deck/web/react instead — it subscribes to cache mutations and re-renders when the answer changes.

useEntitlement(key) — React

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

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

SSR-safe: useEffect is a no-op on the server, the initial state is the cache's current value (false pre-init), so server output never claims a non-existent entitlement. Hydrates correctly on the client.

Sibling hook: useEntitlements() returns an array of active entitlement keys, kept in sync with the cache.

useEntitlement(key) — Vue 3

<script setup>
import { useEntitlement } from "@cross-deck/web/vue";
const isPro = useEntitlement("pro");  // Ref<boolean>
</script>

<template>
  <span v-if="isPro">Pro</span>
</template>

Crossdeck.syncPurchases(input)

Async. Forward Apple StoreKit 2 transaction evidence directly to Crossdeck for server-side verification. Used by hybrid web apps that live alongside an iOS native app on the same Crossdeck customer.

await Crossdeck.syncPurchases({
  signedTransactionInfo: "<JWS from Transaction.currentEntitlements>",
  signedRenewalInfo: "<optional JWS>",
});

Stripe and Google Play do not need this call — those rails deliver evidence server-side via webhooks. Only Apple's flow requires client-side forwarding.

Error capture

The third Crossdeck pillar. Default-on — installing the SDK gets you Sentry-grade error monitoring with no further code. Every captured error flows through the same durable, retried, idempotent queue analytics uses; reports are gated by the consent.errors dimension; PII is scrubbed automatically.

What gets caught automatically

Getting complete error data — symptom → fix

Capture is on by default, but whether each error arrives complete and useful depends on a few things on your side. When one is missing the failure is quiet or misleading — a blank stack, an anonymous error, nothing at all — and it looks like Crossdeck is broken when really a setup step was skipped. Every row is framed by what you'd see, so you can self-diagnose from the symptom.

What you seeWhyThe fix
No errors arrive at all. The SDK isn't initialised, or error capture was turned off. Make sure Crossdeck.init({ appId, publicKey }) runs. Capture is on by default — there is no "enable errors" flag to set; you'd only ever pass errors: false to turn it off.
Stacks are blank — a generic Script error with "frames": []. The failing script is served cross-origin and the browser strips its detail before your page (or Crossdeck) can see it. Add crossorigin="anonymous" to the <script> tag and serve that script with an Access-Control-Allow-Origin header. Crossdeck does not hide these — it keeps them, tags them cross_origin, and points you at this fix.
Stacks are present but minified — a.b.c is not a function at bundle.min.js:1:48217. No source map was uploaded for that release, so frames can't be mapped back to your source. Upload maps at build time (see the command below). A release whose maps weren't uploaded is flagged "not on file" in the dashboard.
Async / promise errors are missing while synchronous errors appear. Not a setup step — unhandled promise rejections are captured automatically. Nothing to do. If a rejection is genuinely missing, your own code is catching it (see manual capture below).
Errors you handle in try/catch never show up. A caught error is swallowed — the global handler never sees it. This is invisible by design. Forward it yourself: Crossdeck.captureError(err).
React / Vue render crashes are missing. The framework's error boundary catches them before they reach the global handler. Forward from your boundary with captureError (example below). Crossdeck ships no boundary component — it's two lines in your own.
Errors arrive but show Developer ID: Not linked — anonymous. identify() hadn't run for that visitor before the error fired, so there's no user to attach to. Anonymity is an absence, not an error. Call Crossdeck.identify(userId) after sign-in. Every error after that attaches to the known user.
Nothing reaches Crossdeck, and your browser console shows a CSP violation for api.cross-deck.com. A strict Content-Security-Policy is blocking the reporting request. Crossdeck never receives it, so there's nothing for it to flag — but your console shows it. Allowlist the endpoint in connect-src (below).
Some users' errors are silently missing. Ad / tracking blockers eat third-party beacons before they send. Serve first-party: point the SDK's baseUrl at a same-origin proxy on your domain that forwards to https://api.cross-deck.com/v1.

Blank or empty stack traces — fix cross-origin scripts

If your errors arrive as a generic Script error with an empty frames array, the failing script is cross-origin and the browser is hiding its detail. The browser only exposes a cross-origin script's error detail when the tag carries crossorigin and the script's origin sends a CORS header:

<!-- the script tag on your page -->
<script src="https://cdn.yourdomain.com/app.js" crossorigin="anonymous"></script>

// the server/CDN serving app.js must send:
Access-Control-Allow-Origin: *        // (or your specific page origin)

Minified, unreadable stack traces — upload source maps

If stacks arrive present but minified (a.b.c is not a function at bundle.min.js:1:48217), no source map was uploaded for that release. Upload maps at build time, keyed to the same release identifier your build ships with:

crossdeck upload-sourcemaps \
  --release v1.2.3 \
  --url-prefix https://app.example.com/static/js/ \
  ./dist

Missing React / Vue render crashes — forward from your error boundary

If your React or Vue render crashes never appear, the framework's error boundary is catching them before they reach the global handler. Forward them yourself — there's no Crossdeck boundary component to install; it's two lines in your own:

class ErrorBoundary extends React.Component {
  componentDidCatch(error, info) {
    Crossdeck.captureError(error, { context: { react: info } });
  }
  render() { return this.props.children; }
}

Anonymous errors (Developer ID: Not linked) — call identify()

If errors arrive but aren't attributed to a known user, identify() hadn't run for that visitor before the error fired — anonymity is an absence, not an error, so there's nothing for the dashboard to flag. Call it after sign-in and every error after that attaches to the user:

await Crossdeck.identify(userId);

Nothing arrives under a strict CSP — allowlist the endpoint

Under a strict Content-Security-Policy the reporting request is blocked before it sends — Crossdeck never receives it, so there's nothing for it to flag, but your browser console shows a CSP violation for api.cross-deck.com. Allowlist the endpoint in connect-src:

Content-Security-Policy: connect-src 'self' https://api.cross-deck.com;

One rule ties these together: if the failure leaves a fingerprint Crossdeck receives (a blank cross-origin stack, a minified frame with no map), the dashboard flags it for you. If nothing reaches Crossdeck at all (a swallowed try/catch, a CSP-blocked beacon, an error that fired before identify()), only this setup prevents it — there's no error for Crossdeck to annotate.

Manual capture

try {
  await risky();
} catch (err) {
  Crossdeck.captureError(err, {
    context: { cart: { items: 3 } },
    tags: { flow: "checkout" },
    level: "error",
  });
}

Crossdeck.captureMessage(msg, level?) reports a non-error signal as an issue (e.g. "deprecated path hit", "soft warning"). Emits error.message.

Tags & context — attach metadata to every error

Crossdeck.setTag("release", "v2.3.1");
Crossdeck.setTags({ flow: "checkout", experiment: "v3" });
Crossdeck.setContext("cart", { items: 3, total: 42.99 });

Tags are flat { key: value } string pairs for filtering. Context is a named structured blob for richer detail. Both attach to every subsequent error report. Cleared on reset() / forget().

Breadcrumbs — what the user did just before the crash

The SDK keeps a rolling buffer of the last 50 things the user did — page views, clicks, custom events. When an error fires, the buffer is attached so the engineer reading the report can replay the path into the broken state.

Automatic breadcrumbs cover every track() call (except error.* and webvitals.* events, which would create noise). For domain-specific moments not already auto-captured, add manual crumbs:

Crossdeck.addBreadcrumb({
  timestamp: Date.now(),
  category: "custom",
  message: "user-opened-paywall",
  data: { variant: "v3" },
});

Fingerprinting — group identical errors

Every error gets an 8-character hex fingerprint derived from the message and the top 3 in-app stack frames. Identical errors share a fingerprint, so 1,000 occurrences of one bug appear as 1 issue in your dashboard, not 1,000 events.

Rate limiting & sampling

Built-in noise filtering

By default, the SDK suppresses:

Add to the ignore list or URL gates via init options (see Configuration reference).

HTTP 0 is connection noise, not an error

A captured error with HTTP status 0 is classified as a distinct connection_noise class — never a real error, regardless of host, URL, method, or framework. Status 0 means the request never reached a server: a cancelled request, a closed tab, a dropped connection, an ad blocker, tracking protection, or VPN interference. There is nothing on your side to fix. The trigger is status === 0 alone — Crossdeck fixes the category, not the host, because every blocked tracker (Firestore Listen, GA g/collect beacons, a new pixel domain) is otherwise an endless game of whack-a-mole.

Crossdeck.setErrorBeforeSend(hook) — the redaction last line

Install a pre-send filter. The hook is called LAST, after the rate limit, sampling, and URL gates have already passed. Return null to drop the error entirely, or a modified CapturedError to scrub specific fields.

Crossdeck.setErrorBeforeSend((err) => {
  if (err.message.includes("auth-token")) return null;
  err.context = { ...err.context, scrubbed: true };
  return err;
});

A throwing hook falls back to the original error rather than dropping the report — your buggy hook code cannot make errors disappear silently.

Crash safety

Every code path in the error-capture module is wrapped in try / swallow. A bug in the SDK can never take down the host app's error handler. A recursion guard prevents the SDK from reporting its own errors recursively forever.

Privacy & consent

Three consent dimensions

Crossdeck.consent({
  analytics: false,    // drops track() + identify() + auto events
  marketing: false,    // drops paid-traffic click IDs + referrer URL
  errors: true,        // keeps error reporting + Web Vitals on
});

Each dimension defaults to true (granted). Pass partial state — only the keys you provide are changed. Crossdeck.consentStatus() returns the current snapshot.

Gating policy per event family:

Do Not Track

Opt in to honour the browser's navigator.doNotTrack header by passing respectDnt: true to init(). When enabled, and the browser reports DNT, all three consent dimensions are locked off permanently. Subsequent consent({...: true}) calls cannot flip a DNT-derived deny back on.

Defaults to false because industry has effectively deprecated DNT — opt-in support is the polite default for privacy-first apps.

PII scrubbing

Enabled by default (scrubPii: true). The SDK runs a regex pass on every string property value, URL, and title before flush — email-shaped substrings become [email], card-number-shaped sequences become [card]. The caller's original object is never mutated.

This protects against unintentional PII leakage in URLs like /users/[email protected]/profile which would otherwise ship verbatim. Set scrubPii: false only when your pipeline does its own redaction downstream.

Right to be forgotten

await Crossdeck.forget();

Calls the backend's /v1/identity/forget endpoint to queue server-side deletion of the customer's events and profile, then wipes all local state — identity, entitlements, queued events, super-properties, groups, breadcrumbs, error context. Idempotent. Server-side failure does not block local wipe — when the user asks to be forgotten, declining to clear their device because the backend hiccupped would be the wrong call.

Configuration reference

Every option accepted by Crossdeck.init(). Required options are bold.

Option Type Default Description
appId string required Crossdeck App ID — found in the dashboard's Apps page (e.g. app_web_…).
publicKey string required Crossdeck publishable key. Prefix must match the environment (cd_pub_test_ ↔ sandbox, cd_pub_live_ ↔ production).
environment "sandbox" | "production" required Explicit environment declaration. Validated against the key prefix at boot.
baseUrl string "https://api.cross-deck.com/v1" Override the API base URL — for self-hosted setups or pointing at a local emulator.
appVersion string none Your app's version (e.g. "1.2.3"). Auto-attached to every event as properties.appVersion.
autoTrack boolean | Partial<AutoTrackOptions> all flags true Disable everything with false, or pass a partial object to override individual flags: { sessions, pageViews, deviceInfo, clicks, webVitals, errors }.
autoHeartbeat boolean true Send a heartbeat to /v1/sdk/heartbeat on init. Disable in high-frequency boot scenarios where the heartbeat is pure overhead (CLI scripts, tests).
eventFlushBatchSize number 20 Maximum events buffered before forced flush.
eventFlushIntervalMs number 1500 Idle ms after the last track() before flushing.
timeoutMs number 15000 Per-request abort timeout. Pass 0 to disable. Lands as CrossdeckError({ code: "request_timeout" }) on expiry.
persistIdentity boolean true (browser) / false (Node) Persist anonymousId + crossdeckCustomerId across sessions. Set false for strict-consent flows that defer all storage writes until opt-in.
storage KeyValueStorage localStorage (browser) / MemoryStorage (Node) Custom storage adapter — useful for RN AsyncStorage, encrypted vaults, or test fixtures.
storagePrefix string "crossdeck:" Key prefix for the SDK's persisted state.
respectDnt boolean false Honour navigator.doNotTrack by locking all three consent dimensions OFF when DNT is signalled.
scrubPii boolean true Scrub email and card-number patterns from event property values, URLs, and titles before flush.
debug boolean false Enable verbose diagnostic logging. Equivalent to calling Crossdeck.setDebugMode(true) after init.
sdkVersion string package version Override the SDK version reported on heartbeats. Useful only for white-label builds.

AutoTrackOptions sub-flags

FlagDefaultWhen to disable
sessionstrueYou're tracking sessions with a different tool.
pageViewstrueYou want full manual control (rare).
deviceInfotrueYou don't want browser / OS / locale enriched on events.
clickstrueYou only want explicit track() calls — no autocapture.
webVitalstrueYou have a separate RUM provider (DataDog, Sentry Performance).
errorstrueYou have a separate error tracker (Sentry, Bugsnag) and don't want duplicate reports.

API reference

Every public method on the Crossdeck singleton (and on new CrossdeckClient() instances).

Lifecycle

MethodReturnsNotes
init(options)voidBoot. Synchronous. Idempotent.
reset()voidWipe identity + entitlements + queue + super-props + groups + breadcrumbs + error context. Emits user.signed_out before wiping.
forget()Promise<void>Server-side deletion + local wipe. Idempotent.
heartbeat()Promise<HeartbeatResponse>Manual boot ping. Captures server time for clock-skew diagnostics.
flush(options?)Promise<void>Force-flush queued events. Pass { keepalive: true } from terminal handlers.
setDebugMode(enabled)voidToggle verbose logging at runtime.
diagnostics()DiagnosticsStable shape regardless of init state. See Diagnostics.

Identity

MethodReturnsNotes
identify(userId, options?)Promise<AliasResult>Link anonymous device to user. Optional { email, traits }.
register(props)Record<string, unknown>Set super-properties — attached to every subsequent event.
unregister(key)voidRemove a single super-property.
getSuperProperties()Record<string, unknown>Snapshot of the current super-property bag.
group(type, id, traits?)voidSet group membership. Pass id: null to clear.
getGroups()Record<string, { id, traits? }>Snapshot of current groups.

Events

MethodReturnsNotes
track(name, properties?)voidSynchronous enqueue. Properties sanitised at the SDK boundary.
flush()Promise<void>Force-flush. Returns when the in-flight request resolves.

Entitlements

MethodReturnsNotes
getEntitlements()Promise<PublicEntitlement[]>Server fetch + cache warm.
isEntitled(key)booleanSync cache read. No await.
listEntitlements()PublicEntitlement[]Sync cache snapshot with source / validUntil details.
onEntitlementsChange(listener)() => voidSubscribe to cache mutations. Returns idempotent unsubscribe.
syncPurchases(input)Promise<PurchaseResult>Forward StoreKit 2 evidence to Crossdeck for verification.

Error capture

MethodReturnsNotes
captureError(err, options?)voidManual capture from try/catch. Optional { context, tags, level }.
captureMessage(msg, level?)voidNon-error signal. Default level "info".
setTag(key, value)voidAttach a string tag to every subsequent error.
setTags(tags)voidBulk-set tags. Merges with existing.
setContext(name, data)voidAttach named structured context to every subsequent error.
addBreadcrumb(crumb)voidAdd a custom breadcrumb to the rolling buffer.
setErrorBeforeSend(hook)voidInstall a pre-send filter. Return null to drop, or a modified CapturedError.

Privacy & consent

MethodReturnsNotes
consent(state)ConsentStateUpdate one or more dimensions. Returns full state snapshot.
consentStatus()ConsentStateCurrent consent snapshot.
forget()Promise<void>GDPR right-to-be-forgotten. Server delete + local wipe.

Auto-tracked events reference

Full list above in Events & analytics. Auto-tracked events are gated by the same consent rules as manual ones:

Diagnostics & debugging

Crossdeck.diagnostics() returns a stable shape regardless of init state — pre-init values are sensible empties. Use it from your DevTools console at any time to verify the SDK is healthy.

{
  started: true,
  anonymousId: "anon_mp10knb…",
  crossdeckCustomerId: "cdcust_xxx",
  developerUserId: "user_847",
  sdkVersion: "1.9.0",
  baseUrl: "https://api.cross-deck.com/v1",
  clock: {
    lastServerTime: 1715414400000,
    lastClientTime: 1715414400003,
    skewMs: 3            // positive = client ahead of server
  },
  entitlements: {
    count: 2,
    lastUpdated: 1715414410000,
    stale: false,         // true once a refresh failed, or data aged past 24h
    listenerErrors: 0     // swallowed throws from buggy subscribers
  },
  events: {
    buffered: 0,
    dropped: 0,
    inFlight: 0,
    lastFlushAt: 1715414411500,
    lastError: null,
    consecutiveFailures: 0,
    nextRetryAt: null     // epoch ms when next retry is scheduled
  }
}

Healthy SDK: started: true, events.consecutiveFailures: 0, events.nextRetryAt: null, clock.skewMs within ±60 seconds. entitlements.stale: true is not a fault on its own — the cache is correctly serving last-known-good — but a persistently stale flag means getEntitlements() has not succeeded in a while, so a recent revoke may not be reflected; check it if a customer's access looks wrong.

Debug-mode console signals

Setting debug: true in init() (or calling setDebugMode(true)) prints a stable vocabulary of signals to console.info. They're suppressed at runtime but available for onboarding integration checks and dashboard verification flows.

SignalWhen it fires
sdk.configuredOnce at init().
sdk.first_event_sentOnce after the first successful event flush.
sdk.no_identityFirst track() before identify() has been called.
sdk.sensitive_property_warningAn event property name looks PII-shaped (email, password, etc.).
sdk.property_coercedA property was coerced (Date → ISO, BigInt → string, etc.) during validation.
sdk.flush_retry_scheduledA flush failed and a retry was scheduled.
sdk.queue_restoredPersisted event queue was rehydrated from a prior session.
sdk.consent_changedCrossdeck.consent({...}) mutated the state.
sdk.consent_deniedAn event was dropped because consent was denied.
sdk.consent_dnt_appliedInit detected DNT (when respectDnt: true) and locked consent off.
sdk.environment_mismatchKey prefix didn't match declared environment.
sdk.purchase_evidence_sentsyncPurchases() succeeded.

AI install prompts

If you're using an AI coding assistant (Cursor, Copilot, Claude Code, Continue) to install Crossdeck, paste the prompt below into the chat. The prompt encodes the SDK's conventions tightly so the AI doesn't guess wrong on the npm scope, the env-prefix rule, or the React hook pattern.

Replace the placeholders.

Swap app_web_xxxxxxxxxxxx and cd_pub_test_xxxxxxxxxxxx for the real values from your Crossdeck Apps page. Pick cd_pub_test_… for sandbox / staging and cd_pub_live_… for production.

You are helping me install Crossdeck into my React/Next web app. The npm scope is "cross-deck" with a hyphen — NOT "crossdeck".

Configure with:
Package: @cross-deck/web (core SDK)
React subpackage: @cross-deck/web/react (subpath import on the same package — no extra install)
App ID: app_web_xxxxxxxxxxxx
Public SDK Key: cd_pub_test_xxxxxxxxxxxx
Environment: sandbox

Tasks:
1. Run "npm install @cross-deck/web". The React hooks live at the "@cross-deck/web/react" subpath — same package, no separate install. Requires react >= 18.
2. Create or open a top-level Provider component (Next.js: app/providers.tsx as a client component with "use client"; CRA/Vite: a top-level wrapper inside src/main.tsx). The CrossdeckProvider takes a userId prop and handles BOTH Crossdeck.init() and Crossdeck.identify() internally — wrap my tree in <CrossdeckProvider userId={session?.user?.id}> (NextAuth) or <CrossdeckProvider userId={user?.uid}> (Firebase Auth) or my equivalent. Inside, the Provider calls Crossdeck.init({ appId, publicKey, environment }) once on mount, then mirrors the userId prop into Crossdeck.identify(userId) / Crossdeck.reset() as the auth state changes. Do NOT call Crossdeck.start() — that's a deprecated alias.
3. Wire userId from MY actual auth provider — figure out from my code which one it is (NextAuth: useSession().data?.user?.id, Firebase Auth: useAuthState() or onAuthStateChanged user.uid, Supabase: useUser().data?.id, custom backend: whatever my session hook returns). Do NOT hardcode a placeholder string — that aliases every visitor to the same fake user. Pass null/undefined while logged out — the Provider calls Crossdeck.reset() automatically.
4. Do NOT manually track "Page.viewed" or "App.opened" — the SDK's auto-tracker emits page.viewed + session.started/ended automatically. Only track domain-specific events (e.g. Crossdeck.track("paywall_viewed", { variant })). track() is sync, fire-and-forget — never await it.
5. For React entitlement gating, import { useEntitlement } from "@cross-deck/web/react" and use it inside components: const isPro = useEntitlement("pro"); return isPro ? <ProBadge /> : null. The hook subscribes to the SDK's reactive entitlements cache so the component re-renders the moment the cache changes; every subsequent check is a microsecond sync cache hit. SSR-safe. Do NOT call Crossdeck.isEntitled() directly inside JSX render paths — a bare call reads the cache at mount but does not re-render when a later getEntitlements() mutates it, so the component freezes on the mount-time answer. The bare Crossdeck.isEntitled(key): boolean is fine in non-React contexts (vanilla JS, server-side renders, route handlers); it's sync (returns boolean, NOT a Promise — no await).
6. Do NOT expose any secret server keys in the browser bundle — only the publishable key above. Secret keys (cd_sk_…) are server-only.
7. Tell me exactly which files to edit and what the imports should look like, including the "use client" directive on the Provider file (Next.js App Router), where to wrap the tree (app/layout.tsx for App Router, _app.tsx for Pages Router, src/main.tsx for Vite/CRA), and how to source userId from my specific auth provider.

Same shape for the other six frameworks — only the wrapper around Crossdeck.init() / Crossdeck.identify() changes. Vue uses composables from @cross-deck/web/vue with Ref<boolean> returns, Svelte uses a Readable<boolean> store built on onEntitlementsChange, vanilla JS / plain HTML / Node call the core SDK directly.

The fastest way to get the right snippet + an AI prompt for your stack is the framework picker on the dashboard's SDKs page: pick one of nextjs, react, vue, svelte, vanilla-bundler, plain-html, node and you get a snippet pre-filled with your app's key, plus a paste-ready AI prompt tuned to that framework's conventions. Every snippet is locked in by scripts/test-sdk-snippets.mjs (runs node --check on JS, tsc --noEmit on TS/TSX, HTML tag-balance on the CDN snippet, plus must-contain / must-not-contain assertions per framework) so a regression in any of the seven generators fails CI.

Troubleshooting

"Cannot find module @crossdeck/web"

The npm scope is @cross-deck with a hyphen. AI assistants frequently guess wrong. Run npm install @cross-deck/web (note the hyphen).

"Environment mismatch" thrown at init

The key prefix and the environment option disagree. cd_pub_test_… requires environment: "sandbox". cd_pub_live_… requires environment: "production". Reconcile in the Apps page or update the init options.

Events not appearing in the dashboard during local development

The SDK auto-detects localhost and similar dev hostnames, and short-circuits every API call to a no-op. Look for the [crossdeck] Localhost detected — running in dev mode line in your console. Deploy to a real domain (Vercel preview, Netlify branch deploy, custom staging) or use ngrok to test against the real Sandbox.

useEntitlement("pro") always returns false in a React app

You probably haven't called Crossdeck.getEntitlements() after init. The hook reads from the cache; the cache is populated by a server fetch. Add Crossdeck.getEntitlements() inside the same boot useEffect that calls Crossdeck.init().

Errors appearing in Sentry but not in Crossdeck (or vice versa)

Both can run side-by-side without interfering. If only one sees an error: check Crossdeck.consentStatus() in DevTools. If errors: false, Crossdeck is silently dropping the report. Sentry has its own consent / sample-rate config that may differ.

"Failed to fetch" errors are spamming Sentry but Crossdeck is quiet

Working as intended. The Crossdeck SDK has exponential backoff and retries built in — a transient network blip is invisible. Sentry catches the moment of failure with no retry-aware noise filtering. After running both for a week the comparison answers "did Crossdeck's plumbing actually catch what we hoped".

Tab close losing events

Should not happen — the SDK persists the queue to localStorage under crossdeck:queue.v1 on every enqueue, and a terminal flush fires on pagehide / beforeunload / visibilitychange→hidden with keepalive: true. Inspect the localStorage key in DevTools → Application; if events are stuck there across reloads, paste the JSON shape into a support ticket.

Versioning & changelog

The SDK follows semantic versioning. The current version and full release history live in the changelog; the SDK_VERSION constant derives from package.json so the wire header can never drift from the published bundle.

VersionHighlights
1.3.0KPMG bank-grade audit closure. PII scrub now walks nested objects (every error.* event ships frames[] / breadcrumbs[] / context{} / http{} — the old top-level-only walk was the SDK's #1 PII leak vector). Sentinel tokens aligned with the backend (<email> / <card>). setErrorBeforeSend hooks installed AFTER init() now fire (pre-fix they were silently inert). Event queue gains a pendingBatch slot so the in-flight batch survives a hard-crash mid-flush; retries reuse the same Idempotency-Key; permanent 4xx errors now drop the batch + fire onPermanentFailure + loud console.error (pre-fix retried 4xx forever silently). identify() unconditionally clears the entitlement cache on cdcust change (closes a cross-customer leak). Error-capture self-skip derives from baseUrl instead of hardcoded api.cross-deck.com (fixes recursive capture loop for customers on staging / regional / self-hosted base URLs). Plus a sweep of P1 cleanup: Retry-After honoured above maxMs, pageviewId nulls on session boundary, reset() clears clock-skew snapshot, event-validation DAG sibling sharing no longer false-flags as [circular], init() re-entry tears down prior listeners, isLocalHostname() matches 0.0.0.0 + IPv6 link-local. Backend-paired: v1-events ingest now honours per-project piiAllowList via a 60s TTL cache (defence-in-depth scrub). Bundle budgets +2 KB to fit the durability surface.
1.2.0Durable last-known-good entitlement cache. Boot-hydrates from device storage so isEntitled() is correct on the first call after a process restart, and survives a Crossdeck outage. Cache staleness signal in diagnostics(). Cleared on reset() + identity switch.
1.0.0Error capture pillar (the third USP) — automatic window.onerror, unhandledrejection, fetch/XHR wrap, manual captureError / captureMessage, breadcrumb buffer, fingerprinting, rate limiting, beforeSend hook, browser-extension noise filter. All three pillars now in one ~30 KB bundle.
0.10.0Privacy + compliance + operational pass. Crossdeck.consent({...}), respectDnt opt-in, default-on PII scrub, Crossdeck.forget() for GDPR. Vue subpackage. UMD CDN bundle. CROSSDECK_ERROR_CODES JSON sidecar. Bundle-size budgets enforced at publish.
0.9.0Data completeness pass. identify(userId, { traits }), register / unregister, group, paid-traffic click IDs (gclid/fbclid/etc.), stable pageviewId, Web Vitals capture.
0.8.0Bank-grade plumbing pass. Durable event queue, exponential backoff with jitter, Retry-After header support, per-batch Idempotency-Key, AbortController timeout, property validation at enqueue, listener-error counter, clock-skew diagnostics.
0.6.0Identity continuity (localStorage + cookie redundancy). First-touch session-pinned acquisition (UTMs + referrer).

The full changelog ships with every npm tarball at node_modules/@cross-deck/web/CHANGELOG.md.


Last updated when @cross-deck/[email protected] shipped (May 11, 2026). Future versions are documented in the table above as they publish to npm.