@cross-deck/web — Web SDK reference
@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
- One install, three pillars. One package —
@cross-deck/web— covers analytics, subscription/entitlement gating, and error capture. No separate Mixpanel + RevenueCat + Sentry stack. - Three lines to start.
npm install @cross-deck/web, callCrossdeck.init({ appId, publicKey, environment }), and you're collecting events, capturing errors, and ready to check entitlements. - Auto-tracking by default. Page views, sessions, clicks, Web Vitals, and runtime errors are captured without writing any tracking code. Disable any of them via
autoTrackoptions. - Microsecond entitlement checks.
Crossdeck.isEntitled("pro")reads from a local cache — safe to call inside a React render or a SwiftUIbody. - Bank-grade plumbing. Events survive crashes via a durable queue. Failed flushes retry with exponential backoff. Every batch carries an
Idempotency-Keyheader. Network errors are caught and re-tried, not lost. - Privacy-first defaults. Email and card-number patterns are scrubbed from event properties automatically. Three consent dimensions (
analytics/marketing/errors) gate every transmission.Crossdeck.forget()implements GDPR right-to-be-forgotten. - ~30 KB gzipped total. For comparison: Sentry's browser SDK is ~30 KB on its own, Mixpanel is ~55 KB, PostHog is ~40 KB. Crossdeck ships all three pillars in the same budget.
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.
| Framework | Install path | Where the snippet lands |
|---|---|---|
| Next.js (App Router) | npm install @cross-deck/web | app/providers.tsx as a client component |
| React (CRA / Vite) | npm install @cross-deck/web | Top-of-tree Provider in src/main.tsx |
| Vue 3 | npm install @cross-deck/web | src/crossdeck.ts + installCrossdeck(app, getUserId) from main.ts |
| Svelte / SvelteKit | npm install @cross-deck/web | src/lib/crossdeck.ts + startCrossdeck() from +layout.svelte onMount |
| Vanilla JS (bundler) | npm install @cross-deck/web | Your 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/web | Server 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,
});
@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:
page.viewedevents on every navigation (SPA-aware — pushState + popstate trigger them).session.started/session.endedevents with a 30-minute idle session window matching GA4 semantics.element.clickedevents for every interactive click, with selector, text, viewport coordinates, and anydata-cd-prop-*attributes you tagged.webvitals.lcp,webvitals.inp,webvitals.cls,webvitals.fcp,webvitals.ttfbevents flushed at page hidden.error.unhandled,error.unhandledrejection,error.httpevents for every uncaught JavaScript error, unhandled promise rejection, and HTTP 5xx response your app receives.- UTM parameters and paid-traffic click IDs (
gclid,fbclid,msclkid,ttclid,li_fat_id,twclid) captured at session start and attached to every event. - A durable event queue persisted to
localStorageso a tab crash doesn't lose data — events replay on next page load.
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.
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:
anonymousId— generated on first install, persisted across reloads. Identifies the device, not the human. Always present.developerUserId— your auth provider's stable user ID. Populated byCrossdeck.identify(userId). Links the anonymous device to a specific human.
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.
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:
- Functions, symbols, and undefined values are dropped.
Dateobjects become ISO strings.BigIntvalues become strings.Errorobjects become{ name, message, stack }.Mapbecomes a plain object,Setbecomes an array.- Strings longer than 1024 characters are truncated with an ellipsis.
- Circular references are replaced with
"[circular]". - Objects nested deeper than 5 levels become
"[depth-exceeded]". - If the property bag exceeds 8 KB total, the largest fields drop first and a
__truncated: truemarker is added. - The caller's input object is never mutated — sanitisation always produces a defensive copy.
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 |
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:
- Device:
os,osVersion,browser,browserVersion,locale,timezone,screenWidth,screenHeight,viewportWidth,viewportHeight,devicePixelRatio. - App:
appVersion(when passed toinit()). - Session:
sessionId,pageviewId. - Acquisition (first-touch, session-scoped):
utm_source,utm_medium,utm_campaign,utm_content,utm_term,referrer. - Paid-traffic click IDs (when present in the landing URL):
gclid,fbclid,msclkid,ttclid,li_fat_id,twclid. - Super properties: every key you've called
register()for. - Groups:
$groups: { type: id, … }.
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.
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
- Uncaught synchronous errors via
window.addEventListener("error", …)→error.unhandled. - Unhandled promise rejections via
window.addEventListener("unhandledrejection", …)→error.unhandledrejection. - Failed HTTP requests — the SDK wraps
fetchandXMLHttpRequest. Status >= 500 or a network-layer failure becomeserror.http. Crossdeck's own API calls are explicitly excluded to prevent cycle-amplification on a Crossdeck outage.
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
- Per-fingerprint cap: max 5 reports per minute, per fingerprint. Defends against runaway loops (an error inside
setInterval). - Per-session cap: max 100 total errors. After that, capture stops silently until the next session — the dashboard sees "1 unique error" instead of a million identical events.
- Sampling: pass
errorSampleRate: 0.5via init options (when exposed in a future version) for deterministic 50% sampling per fingerprint.
Built-in noise filtering
By default, the SDK suppresses:
ResizeObserver loop limit exceededand the related… undelivered notificationsvariant — classic browser-internal noise, not an application bug.Script error.— cross-origin script errors stripped by the browser before they reach the handler. No actionable info.- Errors with top-frame URLs starting with
chrome-extension://,moz-extension://,safari-extension://,webkit-extension://,safari-web-extension://— browser-extension errors aren't your code.
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:
error.*events → gated byconsent.errors.webvitals.*events → gated byconsent.errors(treated as performance/reliability data, not behavioural analytics).- Paid-traffic click IDs (
gclid,fbclid, etc.) + full referrer URL → gated byconsent.marketing. - Everything else → gated by
consent.analytics.
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
| Flag | Default | When to disable |
|---|---|---|
sessions | true | You're tracking sessions with a different tool. |
pageViews | true | You want full manual control (rare). |
deviceInfo | true | You don't want browser / OS / locale enriched on events. |
clicks | true | You only want explicit track() calls — no autocapture. |
webVitals | true | You have a separate RUM provider (DataDog, Sentry Performance). |
errors | true | You 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
| Method | Returns | Notes |
|---|---|---|
init(options) | void | Boot. Synchronous. Idempotent. |
reset() | void | Wipe 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) | void | Toggle verbose logging at runtime. |
diagnostics() | Diagnostics | Stable shape regardless of init state. See Diagnostics. |
Identity
| Method | Returns | Notes |
|---|---|---|
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) | void | Remove a single super-property. |
getSuperProperties() | Record<string, unknown> | Snapshot of the current super-property bag. |
group(type, id, traits?) | void | Set group membership. Pass id: null to clear. |
getGroups() | Record<string, { id, traits? }> | Snapshot of current groups. |
Events
| Method | Returns | Notes |
|---|---|---|
track(name, properties?) | void | Synchronous enqueue. Properties sanitised at the SDK boundary. |
flush() | Promise<void> | Force-flush. Returns when the in-flight request resolves. |
Entitlements
| Method | Returns | Notes |
|---|---|---|
getEntitlements() | Promise<PublicEntitlement[]> | Server fetch + cache warm. |
isEntitled(key) | boolean | Sync cache read. No await. |
listEntitlements() | PublicEntitlement[] | Sync cache snapshot with source / validUntil details. |
onEntitlementsChange(listener) | () => void | Subscribe to cache mutations. Returns idempotent unsubscribe. |
syncPurchases(input) | Promise<PurchaseResult> | Forward StoreKit 2 evidence to Crossdeck for verification. |
Error capture
| Method | Returns | Notes |
|---|---|---|
captureError(err, options?) | void | Manual capture from try/catch. Optional { context, tags, level }. |
captureMessage(msg, level?) | void | Non-error signal. Default level "info". |
setTag(key, value) | void | Attach a string tag to every subsequent error. |
setTags(tags) | void | Bulk-set tags. Merges with existing. |
setContext(name, data) | void | Attach named structured context to every subsequent error. |
addBreadcrumb(crumb) | void | Add a custom breadcrumb to the rolling buffer. |
setErrorBeforeSend(hook) | void | Install a pre-send filter. Return null to drop, or a modified CapturedError. |
Privacy & consent
| Method | Returns | Notes |
|---|---|---|
consent(state) | ConsentState | Update one or more dimensions. Returns full state snapshot. |
consentStatus() | ConsentState | Current 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:
session.*andpage.viewedandelement.clicked→consent.analytics.webvitals.*→consent.errors.error.*→consent.errors.
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.
| Signal | When it fires |
|---|---|
sdk.configured | Once at init(). |
sdk.first_event_sent | Once after the first successful event flush. |
sdk.no_identity | First track() before identify() has been called. |
sdk.sensitive_property_warning | An event property name looks PII-shaped (email, password, etc.). |
sdk.property_coerced | A property was coerced (Date → ISO, BigInt → string, etc.) during validation. |
sdk.flush_retry_scheduled | A flush failed and a retry was scheduled. |
sdk.queue_restored | Persisted event queue was rehydrated from a prior session. |
sdk.consent_changed | Crossdeck.consent({...}) mutated the state. |
sdk.consent_denied | An event was dropped because consent was denied. |
sdk.consent_dnt_applied | Init detected DNT (when respectDnt: true) and locked consent off. |
sdk.environment_mismatch | Key prefix didn't match declared environment. |
sdk.purchase_evidence_sent | syncPurchases() 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.
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.
| Version | Highlights |
|---|---|
1.0.0 | Error 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.0 | Privacy + 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.0 | Data completeness pass. identify(userId, { traits }), register / unregister, group, paid-traffic click IDs (gclid/fbclid/etc.), stable pageviewId, Web Vitals capture. |
0.8.0 | Bank-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.0 | Identity 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.
Related
- Error codes reference — every
CrossdeckErrorcode the SDK can throw, with description, resolution, and retryable flag. - API keys & authentication — publishable vs secret, key rotation, origin allowlists.
- Create a project — the dashboard flow that produces the
appId+ publishable key you pass toinit(). - Connect Stripe — wire your Stripe account so subscription state becomes available via
getEntitlements(). - Source on GitHub — the SDK lives under
sdks/web/. SDK_TRUTH.md is the canonical contract reference.
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.