Crossdeck Docs
Pricing Dashboard

@cross-deck/web — Web SDK reference

Reference Current version: 1.0.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.0.0/…"><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.0.0/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 the version explicitly@latest is convenient for prototyping but 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: "wes@example.com",
  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 binary "what this user is allowed to do" facts your app reads to gate paid features. The SDK maintains a 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 and warms the local cache. Call once after identify() resolves; subsequent isEntitled() / useEntitlement() reads are sync cache hits.

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

Crossdeck.isEntitled(key): boolean

Synchronous. Reads from the local cache. Returns false until the cache has been populated by a successful getEntitlements() call (or a syncPurchases() on iOS hybrid apps). Microsecond lookup — call it inside any render path.

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 cache populates asynchronously; React has no way to know the cache changed, so the component would show the empty-cache result forever. 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

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).

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/wes@pinet.co.za/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.0.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,
    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.

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 — bare calls don't trigger re-renders when the cache populates async, so the component would show the empty-cache result forever. 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 is 1.0.0 — published when all three USPs (analytics, revenue, errors) landed in the box.

VersionHighlights
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/web@1.0.0 shipped (May 11, 2026). Future versions are documented in the table above as they publish to npm.