# Track custom events

> Source: https://cross-deck.com/docs/track-events/ · Crossdeck developer docs (generated from the published page).

Guide
Applies to @cross-deck/web and @cross-deck/node
·
~15 min read
·
Updated May 15, 2026

Auto-capture gives you `page.viewed`, `session.started`, `element.clicked`, Web Vitals, and `fetch.completed` / `xhr.completed` network timing for free. Those answer *"is anyone there?"* The dashboard becomes genuinely useful — funnels, cohorts, retention curves, revenue attribution — the moment you start firing the dozen domain events that describe **your** business: signup, trial conversion, paywall, churn signal. This guide is the prescriptive spec for those events: the `track()` contract, the canonical taxonomy, naming, properties, and the failure modes you need to avoid.

## TL;DR

- **Shape.** `Crossdeck.track(name, properties?)` — synchronous, returns `void`, fire-and-forget. Never `await` it. *Delivery* never throws (it's always backgrounded and safe to ignore); `track()` throws synchronously in exactly two programmer-error cases — calling before `init()` (`not_initialized`) and an empty name (`missing_event_name`).

- **When to fire.** User crosses a meaningful threshold — signed up, started a trial, hit a paywall, upgraded, churned. **Not** on every component render or state change.

- **Naming.** `snake_case`, past-tense verb (`trial_started`, not `StartTrial`). Reserved prefixes (`page.`, `session.`, `element.`, `error.`, `webvitals.`, `user.`, `sdk.`, `cd.`) belong to the SDK — never use them. Your own dotted namespace (`billing.invoice_paid`) is fine; just not those.

- **Validation.** Property values are sanitised at the SDK boundary (functions dropped, Dates coerced to ISO, circular refs replaced, >1024-char strings truncated, total bag capped at 8 KB). One bad property can never poison the batch.

- **Super properties.** `Crossdeck.register({ plan: "pro" })` auto-attaches to every subsequent event. Right tool for plan tier, org ID, app version, cohort. Wrong tool for per-event data.

- **PII.** Email-shaped and card-number-shaped substrings inside string property values are auto-scrubbed before the event hits the queue. Don't rely on it — design events without PII as values to begin with.

## The track() contract

### Signature

```
function track(name: string, properties?: Record<string, unknown>): void;
```

Synchronous. Returns `void`. The HTTP round-trip happens later, on the next batched flush. The function never returns a Promise — there is nothing to `await`.

```
// Correct — fire and forget
Crossdeck.track("trial_started", { plan: "pro", trial_length_days: 14 });

// Incorrect — track() is not async; the await does nothing and
// turns a non-blocking call into a misleading no-op.
await Crossdeck.track("trial_started", { plan: "pro" });
```

### Durable queue

Events land in an in-memory queue, get persisted to `localStorage` under `crossdeck:queue.v1` on every enqueue, and flush in batches. A terminal flush fires on `pagehide` / `beforeunload` / `visibilitychange→hidden` with `keepalive: true` so the final batch lands even when the tab is being torn down. Failed flushes retry with exponential backoff, every batch carries an `Idempotency-Key` header, and the queue survives crashes and reloads. You don't have to think about delivery.

### What happens if called before init()

It throws. The web SDK's `track()` calls `requireStarted()` which raises a `CrossdeckError` with code `not_initialized` if `Crossdeck.init({ appId, publicKey, environment })` hasn't run yet. There is no pre-init buffer — the SDK doesn't queue events for a hypothetical future `init()`.

Wrap your first event behind init.

In React, the standard pattern is to call `Crossdeck.init()` inside a `useEffect` in your `CrossdeckProvider` on mount. Any component below that provider can safely call `Crossdeck.track()` from its own effects. If you fire a `track()` in the same module-load tick as the provider mounts, you'll race init — guard it with a flag or fire from a `useEffect` instead.

### What track() guarantees

- **Sync enqueue.** The event is in the queue before `track()` returns.

- **Validation.** The property bag is sanitised through `validateEventProperties` before enqueue — see [Validation at the boundary](#validation).

- **Enrichment.** Device info, `sessionId`, `pageviewId`, acquisition (UTMs, referrer, click IDs), super properties, and `$groups` memberships are auto-merged. Caller-supplied properties win on key collision.

- **PII scrub.** Email-shaped and card-number-shaped substrings inside string property values are replaced with ` ` / ` ` as the last step before enqueue (unless `scrubPii: false` was passed to `init()`).

- **Breadcrumb.** The event is added to the in-memory breadcrumb buffer so a subsequent error report carries "the last N things the user did" automatically.

- **Consent gate.** If `Crossdeck.consent({ analytics: false })` has been set, the event drops silently — no network call, no exception.

## Canonical event taxonomy

This is the Crossdeck-recommended taxonomy for any SaaS-shaped product. Adopting it means the dashboard's pre-built funnels (signup → activation, trial → conversion, paywall → purchase, churn signal → save), cohorts, and retention reports work out of the box. Diverge with *intent*, not by accident — every name and property below maps to a dashboard surface.

Required properties must be sent on every fire. Optional properties are recommended where applicable; missing them won't break anything, but the funnels lose resolution.

Coming from Segment / Mixpanel / Amplitude?

Most teams arrive with an existing taxonomy. The shortest path: keep your existing event firing where it is and rename to Crossdeck's `snake_case` equivalent — `Trial Started` → `trial_started`, `Purchase Completed` → `purchase_completed`, `Checkout Started` → `checkout_started`. (Pageviews are already auto-captured as `page.viewed` — don't remap your own event onto that reserved name.) A side-by-side rename guide (*Segment → Crossdeck*, *Mixpanel → Crossdeck*, *Amplitude → Crossdeck*) is planned.

### Lifecycle & activation

Event When to fire Required properties Optional properties

signup_started
User submits the signup form (before account creation succeeds).
source — UTM campaign name, or "organic" when no UTM is present.
landing_page — pathname of the page they landed on first.

signup_completed
Account creation succeeds and the user has a verified identity in your auth provider.
method — one of "email" , "google" , "apple" , "github" .
signup_duration_ms — wall-clock ms from signup_started to here.

email_verified
User clicks the email-verification link and the backend confirms.
— (use identify() 's email trait, not a property)
—

onboarding_step_completed
Each step in your onboarding flow finishes (named questions, picker screens, profile setup).
step_name , step_index (number).
time_in_step_ms — ms spent on the step.

onboarding_completed
User exits the onboarding flow successfully — the "activation" anchor.
—
—

### Trial & conversion

Event When to fire Required properties Optional properties

trial_started
Backend creates a trial subscription (Stripe checkout success with trial, or equivalent).
plan , trial_length_days (number).
source — the UTM campaign or referrer that drove trial start.

trial_converted
Trial transitions to a paid subscription (first paid invoice).
plan , mrr (number, USD cents preferred for consistency).
original_trial_source — preserved from trial_started.source .

trial_expired
Trial ends without conversion to paid.
plan , converted (always false ).
—

### Paywall & purchase

Event When to fire Required properties Optional properties

paywall_viewed
Paywall modal / page renders and becomes visible to the user.
paywall_name , plans_shown (array of plan slugs), triggered_by — the feature or limit that surfaced it.
—

paywall_dismissed
User closes the paywall without converting.
paywall_name , dismissed_reason — one of "close_button" , "backdrop_click" , "escape_key" , "navigated_away" .
—

purchase_started
User clicks "Buy" / "Subscribe" and a checkout session is created.
plan , mrr , paywall_name .
—

purchase_completed
Payment confirmed and entitlement granted. Fire server-side from a Stripe webhook handler — see Server-side track() .
plan , mrr , payment_method — one of "card" , "apple_pay" , "google_pay" , "sepa" , etc.
—

purchase_failed
Checkout returns an error or the subsequent payment intent fails.
plan , failure_code — Stripe decline code or your own taxonomy, failure_message .
—

### Plan changes & churn

Event When to fire Required properties Optional properties

plan_upgraded
Subscription moves to a higher-tier plan.
from_plan , to_plan , mrr_delta (positive number).
—

plan_downgraded
Subscription moves to a lower-tier plan (still paid).
from_plan , to_plan , mrr_delta (negative number).
—

subscription_cancelled
User schedules a cancellation or it cancels immediately.
plan , cancel_at_period_end (boolean).
cancellation_reason — pick from a small enum your save-flow surfaces.

churn_signal
You detect a soft signal that a user is likely to churn (decreased usage, billing failure, support escalation).
signal_type — short slug describing the signal, severity — one of "low" , "medium" , "high" .
—

### Engagement & quality

Event When to fire Required properties Optional properties

feature_used
User completes a meaningful interaction with a named feature. Fire once per session per feature unless usage frequency is itself the metric.
feature_name , feature_category .
—

error_recovered
An error surfaced to the user, they took a recovery action, and it succeeded.
error_code , recovery_action — what the user did (e.g. "retry" , "sign_in_again" ).
—

support_contacted
User initiates a support conversation (chat opened, ticket submitted, email sent).
channel — "chat" / "email" / "phone" , topic .
—

Treat this taxonomy as the default, not a buffet.

The dashboard's funnels, cohorts, and revenue widgets are built against these exact names. You're free to add domain events on top (`document_exported`, `collab_invited`, etc.) but the lifecycle / trial / paywall / churn events above should fire *exactly* as named. If you have a strong reason to diverge, document the mapping in your team's analytics README so the dashboard editors aren't guessing.

## Event naming conventions

Crossdeck uses **`snake_case`** with underscores between words and a past-tense verb. Pick one casing style for your whole codebase and never mix.

You may group related events under a **dotted namespace** — `billing.invoice_paid`, `checkout.completed` — as long as the prefix isn't one of the reserved system prefixes listed below. The `snake_case` + past-tense rule still applies to each segment; the dot just buys you a namespace.

Rule Example Bad example

Lowercase only. trial_started TrialStarted , TRIAL_STARTED
Underscores between words. paywall_viewed paywallviewed , paywall-viewed
Past-tense verb at the end. plan_upgraded upgrade_plan , upgrading
Noun first, verb last. onboarding_completed completed_onboarding
One event = one name. Encode variation as a property. feature_used with feature_name: "export" export_feature_used , import_feature_used , share_feature_used (each as separate names)

### Reserved prefixes — never use these for custom events

The SDK's auto-tracker owns the following name prefixes. The dispatcher inside `track()` applies different consent gates and breadcrumb categories depending on the prefix — colliding with them silently changes the routing of your event and corrupts auto-capture funnels.

Reserved prefix Owned by Examples

page. Auto-tracker page-view emitter. page.viewed
session. Auto-tracker session emitter. session.started , session.ended
element. Auto-tracker click autocapture. element.clicked
error. Error-capture pillar — gated on consent.errors , not consent.analytics . error.unhandled , error.unhandledrejection (auto-fired); error.handled , error.http , error.message (via captureError() / captureMessage() )
webvitals. Web Vitals tracker — also gated on consent.errors . webvitals.lcp , webvitals.inp , webvitals.cls
user. Identity lifecycle ( Crossdeck.reset() emits user.signed_out ). user.signed_out
sdk. SDK-internal signals (web: debug-mode console only; server: sdk.boot telemetry). sdk.configured , sdk.boot
cd. Reserved for Crossdeck-internal events. Filtered out of your custom-event feed, breakdowns and alerts. cd.*

Don't manually fire page.viewed , session.started , or error.* .

The auto-tracker emits these. Firing them manually doubles every count and corrupts every funnel that joins on them. If you've turned auto-capture off via the `autoTrack` init option, you still shouldn't reuse the reserved names — emit a custom equivalent under your own namespace.

## Property design

### Allowed value types

- **string** — short, low-cardinality slugs preferred. Free-text titles and URLs are fine, but they're cardinality bombs in cohort breakouts.

- **number** — integers and finite floats. `NaN`, `Infinity`, and `-Infinity` become `null` with a warning at validation time.

- **boolean** — straight `true` / `false`.

- **array** — flat arrays of primitives are fine (`plans_shown: ["pro", "team"]`). Avoid arrays of objects.

- **nested object** — allowed up to depth 5, but **don't go beyond one level of nesting in practice**. The dashboard's column-pickers flatten the first level; deeper paths require manual SQL.

### What gets coerced or dropped

The validator at the SDK boundary (`validateEventProperties`, see [below](#validation)) accepts almost anything and rewrites it into JSON-safe shapes. You don't have to pre-format your data, but knowing the coercions lets you write properties in the shape the dashboard will actually receive:

You pass Wire sees Warning emitted

new Date() ISO 8601 string coerced_date
123n (BigInt) String "123" coerced_bigint
new Error("oops") { name, message, stack } coerced_error
new Map([["a", 1]]) { a: 1 } coerced_map
new Set(["a"]) ["a"] coerced_set
String > 1024 chars Truncated with … suffix truncated_string
Function Dropped dropped_function
undefined Dropped dropped_undefined
Circular object "[circular]" circular_reference
Object nested > 5 levels deep "[depth-exceeded]" depth_exceeded
Properties totalling > 8 KB Largest fields dropped, __truncated: true appended size_cap_exceeded

### Property count

No hard cap on property count exists in the validator — the limit is the 8 KB total-bag size. **Keep events to ≤30 properties** as a recommendation. Above that, you're almost certainly modelling state that should live on the user (via `identify()` traits) or as a super property, not on every event.

### Property naming

- **`snake_case`**, lowercase, matching the event name style.

- Don't prefix with `$` — that's reserved (Mixpanel-style) for SDK-attached internal fields like `$groups`.

- Don't use property names that look like PII (`email`, `password`, `token`, `secret`, `card`, `phone`). In debug mode the SDK emits an `sdk.sensitive_property_warning` signal when it sees these — that's the SDK's polite cough at a leak in progress. The field isn't stripped (that's your call) but the warning shows up.

- For currency values, use **cents as integer** not `"$19.99"` as string. Pick a single currency per app or include an explicit `currency` property.

### What gets PII-scrubbed automatically

Right before the event hits the queue, every **string** property value (and every string inside a top-level array) is run through `scrubPii`:

- Email-shaped substrings (RFC 5322 obs-local-part common case) are replaced with ` `.

- Card-number-shaped substrings (13–19 digits with optional space / hyphen separators) are replaced with ` `.

This is defensive scrubbing, not the primary defence. It catches the common case where an email or PAN ended up inside a URL (`/users/wes@example.com/`) or an auto-attached referrer. Don't rely on it to redact intentional PII — **don't pass PII as property values**. Use `Crossdeck.identify(userId, { traits: { email } })` to attach PII to the user record, where access controls and right-to-be-forgotten apply.

To disable the scrub (for pipelines that do their own redaction):

```
Crossdeck.init({
  appId: "app_web_xxx",
  publicKey: "cd_pub_test_…",
  environment: "sandbox",
  scrubPii: false,
});
```

## Validation at the SDK boundary

The validator in `event-validation.ts` is a pure function — no I/O, no console — and runs synchronously inside every `track()` call before the event reaches the queue. It exists for one reason: **one bad property must never be able to crash `JSON.stringify` at flush time and poison the entire batch indefinitely.**

### What the validator does

- Walks the property bag depth-first, visiting each key.

- Drops functions, symbols, and `undefined` values.

- Coerces `Date`, `BigInt`, `Error`, `Map`, `Set` into JSON-safe shapes (see the [coercion table](#properties)).

- Truncates oversized strings (default cap: 1024 chars).

- Replaces circular references with `"[circular]"` via a `WeakSet` tracker.

- Coerces depth > 5 to `"[depth-exceeded]"`.

- Enforces an 8 KB total bag size, dropping the largest fields first and marking the result with `__truncated: true`.

- Returns a brand-new object — the caller's input is never mutated.

### What the validator does not reject

The SDK only sanitises property *values*. The **event name** is checked in `track()` itself with a single guard: `if (!name)` throws `missing_event_name` synchronously, with no length check beyond the empty / truthy gate. The *server*, however, does enforce a hard cap: event ingestion rejects any name longer than **128 characters** (`name must be 1–128 characters`). Treat the 64-character soft cap as a convention for readability — the dashboard tab listings start to wrap above that — but know that names over 128 chars are rejected outright on the wire.

track("") throws synchronously.

Empty or missing event names raise `CrossdeckError` with code `missing_event_name`. This is intentional: a silently-dropped untracked event is worse than a loud failure. Wrap in `try/catch` at the call site if you're constructing names dynamically and there's any chance of an empty string slipping through.

### How to see validation warnings

Set `debug: true` in `init()` (or call `Crossdeck.setDebugMode(true)` at runtime) to surface every coercion / drop as a `console.info` line tagged `sdk.property_coerced`. The same hook also emits `sdk.sensitive_property_warning` for PII-shaped property names. In production, leave debug off and trust the validator — every coercion is benign by design.

## Super properties

Super properties are key/value pairs you register *once* and the SDK auto-attaches to every subsequent event of that SDK instance. They persist across reloads via `localStorage` and clear automatically on `Crossdeck.reset()` (logout).

```
// Set once, typically right after identify().
Crossdeck.register({
  plan: "pro",
  app_version: "4.12.0",
  org_id: "org_acme",
  cohort: "2026_q2_launch",
});

// Every subsequent event automatically includes plan / app_version / org_id / cohort:
Crossdeck.track("paywall_viewed", { paywall_name: "upgrade_modal" });
// → wire payload includes plan, app_version, org_id, cohort, paywall_name, plus all auto-enrichment.
```

### Right tool for super properties

- **Plan tier** — every event needs to know whether the user is on `free` / `pro` / `team` / `enterprise`.

- **App version** — so a regression to one build can be isolated in cohort analysis.

- **Org / team / workspace ID** — for B2B; combined with `Crossdeck.group()` for multi-tenant pivots.

- **Cohort / experiment bucket** — A/B test variant, signup cohort, release channel.

- **Feature flag state at session start** — if a flag's value won't change mid-session and you need every event tagged with it.

### Wrong tool for super properties

- **Per-event variation** — `paywall_name`, `step_index`, `plan` on a purchase event. Pass those on `track()` directly.

- **Anything PII** — same auto-scrub applies, but a super property leak is amplified across every subsequent event.

- **Anything that changes within a single page load** — super properties are read at every `track()` call but writing them is a `localStorage` round-trip; don't churn them.

### API surface

Method Behaviour

Crossdeck.register({ key: value }) Merges keys into the bag. value: null deletes the key (explicit "stop tracking").
Crossdeck.unregister("key") Removes one key. Idempotent.
Crossdeck.reset() Clears the entire bag (called on logout). Groups clear too.

On every `track()`, super properties merge *after* auto-enrichment (device, session, acquisition) and *before* caller-supplied properties. So:

- You can't accidentally override `sessionId` or `utm_source` via `register()` — the auto-enrichment wins on shared keys.

- The per-call `track()` properties win over super properties on shared keys (`register({ plan: "pro" })` then `track("downgrade", { plan: "free" })` ships `plan: "free"` for that one event).

## Sampling

Sampling isn't exposed in the SDK yet — every track() call ships.

There is no sample-rate option in `@cross-deck/web` today: every event you `track()` is delivered, subject only to consent and the auto-scrub. Sampling is on the roadmap for very high-volume projects (roughly 5M+ events/month), where trading fidelity on high-frequency autocapture for cost starts to make sense. Below that, ship everything — which is the default and needs no configuration.

When sampling lands, conversions, errors, page views, sessions, and Web Vitals will stay at 100% by design — a sampled funnel with missing conversions is worse than no funnel — and only high-frequency autocapture and high-volume custom events will be eligible. We'll document the exact API here the moment it ships; there is nothing to wire today.

For the per-event price and the included monthly volume, see [Pricing](https://cross-deck.com/pricing/). The math worth carrying in your head: events bill as one unit each at every tier — the same number whether you're at 5M or 50M — so sampling is a straight cost lever.

## Server-side track()

The Node SDK exposes a parity `track()` with the same enrichment / validation / breadcrumb pipeline as the web SDK. The signature differs in one way: the server takes a single `ServerEvent` object so the caller can attach an identity hint per call (the server doesn't have a long-lived per-user instance the way a browser does).

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

const server = new CrossdeckServer({
  appId: "app_srv_xxx",
  secretKey: "cd_sk_test_…",
  environment: "sandbox",
});

server.track({
  name: "purchase_completed",
  developerUserId: "user_847",
  properties: {
    plan: "pro",
    mrr: 1900,
    payment_method: "card",
  },
});
```

### When to fire server-side instead of client

- **Stripe / Apple / Google webhook receivers** — `purchase_completed`, `trial_converted`, `subscription_cancelled`. The payment provider is the source of truth; firing client-side races the webhook and a closed tab loses the event entirely.

- **Cron jobs and scheduled tasks** — `trial_expired`, daily churn-signal scoring, weekly digest events.

- **Backend conversion events** — anything where the final state change happens in a backend service after the user has already navigated away.

- **Sensitive properties you don't want on the client** — server-side `track()` uses your secret key and never has its property values exposed to the user's devtools.

The same canonical taxonomy applies. The dashboard joins client-side and server-side events on identity, so the funnel sees a single timeline regardless of which side emitted each step.

See the [Node SDK reference](https://cross-deck.com/docs/node-sdk/) for the full server-side API.

## Testing locally

The web SDK auto-detects localhost and short-circuits every API call to a no-op. You'll see this line in your browser console at boot:

```
[crossdeck] Localhost detected — running in dev mode (no network calls).
```

This is intentional: it prevents accidentally polluting your sandbox project with developer-machine noise (every refresh = a session, every dev login = an identify). Events you call `track()` on still go through validation, enrichment, the breadcrumb buffer, and the durable queue — they just never hit the wire.

### To test against the real sandbox

- **Vercel / Netlify preview deploys** — push a branch, hit the preview URL, events flow into your sandbox project. The fastest path.

- **ngrok** — `ngrok http 3000`, hit the public ngrok URL instead of `localhost:3000`. Useful for testing in a real mobile browser too.

- **Custom dev hostname** — add an entry to `/etc/hosts` mapping `dev.yourdomain.com` to `127.0.0.1`, run your dev server on port 80, hit the hostname. The localhost detector matches against the bare strings `localhost` and `127.0.0.1` (plus a few standard variants) — a different hostname bypasses it.

### To verify a single event landed

- Hit a real (non-localhost) URL.

- Fire the event from devtools: `Crossdeck.track("test_event", { source: "manual" })`.

- Either wait up to 5 seconds for the batched flush, or call `Crossdeck.flush()` to force it immediately.

- Open the Crossdeck dashboard's Live Events view. Your event appears within seconds.

To enable verbose diagnostics in any environment (including localhost), pass `debug: true` to `init()`. The SDK emits a stable vocabulary of signals to `console.info` — `sdk.configured`, `sdk.first_event_sent`, `sdk.property_coerced`, `sdk.consent_denied`. See the [Web SDK Diagnostics section](https://cross-deck.com/docs/web-sdk/#diagnostics) for the full list.

## Anti-patterns

The most common ways to make the dashboard *less* useful, in roughly the order they show up in code reviews.

### Tracking on every render

Calling `Crossdeck.track()` from a React component's render function (or a Vue `setup()`, or a Svelte reactive statement) fires the event every time the component re-renders. A paywall component that re-renders three times during a single user view emits `paywall_viewed` three times, and the funnel conversion rate drops by 3× overnight with no real change in user behaviour.

Always fire from an effect / lifecycle hook tied to the meaningful state transition:

```
function Paywall({ visible, name }) {
  useEffect(() => {
    if (visible) {
      Crossdeck.track("paywall_viewed", { paywall_name: name });
    }
  }, [visible, name]);
  // …render
}
```

### Tracking PII as property values

Don't:

```
Crossdeck.track("signup_completed", {
  email: user.email,         // PII — flagged in debug, scrubbed on the wire
  phone: user.phone,         // same
  card_last4: card.last4,    // payment data — never
});
```

Do:

```
// Identify the user once with traits — PII goes on the user record,
// where access controls and right-to-be-forgotten apply.
Crossdeck.identify(user.id, {
  traits: { email: user.email, phone: user.phone },
});

// The event carries the action, not the identifying data.
Crossdeck.track("signup_completed", { method: "email" });
```

### Using events for state the dashboard should derive

Don't emit `plan_changed_to_pro`, `plan_changed_to_team`, `plan_changed_to_enterprise` as separate events to represent the user's current plan. The dashboard derives "current plan" from the latest `plan_upgraded` / `plan_downgraded` event for each user, or — better — from the entitlement state. State belongs on the user record (via `identify()` traits) or as a super property (via `register()`); events describe *changes* to state, not state itself.

### High-cardinality identifiers as event names

Don't:

```
Crossdeck.track(`feature_${featureName}_used`);
// → exports the event names: feature_export_used, feature_import_used,
//   feature_share_used, … one event name per feature you'll ever add.
```

Do:

```
Crossdeck.track("feature_used", { feature_name: featureName });
```

The dashboard treats event names as a low-cardinality enum: they show up in dropdowns, drive autocomplete, and gate access to pre-built funnels. Stamping a high-cardinality value into the name explodes those dropdowns and makes the data un-queryable. Push variation into properties instead.

### Awaiting track()

`track()` is synchronous and returns `void`. `await Crossdeck.track(...)` is a no-op that gives the false impression you're waiting for delivery — you aren't. Delivery happens in a background flush you have no reason to block on. The only time you ever need to wait is at page-tear-down, and the SDK already handles that with a `pagehide`-triggered terminal flush with `keepalive: true`. If you have a custom unload path, call `await Crossdeck.flush({ keepalive: true })` explicitly there — not on every event.

### Re-emitting auto-tracked events

Don't fire `track("page.viewed")` manually after a route change in your SPA. The auto-tracker hooks `pushState` / `replaceState` / `popstate` and emits the event with its own 250 ms dedup window. Manually re-emitting doubles the count and breaks every funnel that joins on `pageviewId`.

### Double-firing the same event from client and server

One of the most expensive bugs in event analytics: the same lifecycle moment ends up firing from both sides. The browser calls `Crossdeck.track("purchase_started")` on the Buy click; the Stripe webhook handler calls `server.track({ name: "purchase_started" })` a few seconds later when `checkout.session.completed` arrives. Two events per real purchase. Conversion math now reads 2× the truth and every funnel that joins on this event is silently wrong.

The rule: **each event has exactly one owner — client OR server, not both.** Pick the side where the source of truth lives.

- **User-visible moments** (paywall viewed, modal opened, button clicked, intent captured) — fire from the client. The server doesn't observe these.

- **Payment / subscription lifecycle** (`purchase_completed`, `trial_converted`, `subscription_cancelled`) — fire from the server. The rail webhook is canonical; the client can race or lose the event on tab-close.

- **Mixed cases** (`signup_completed` — the client knows the moment, the server knows the durable record) — pick one and document it. The team rule that scales: "the side that holds the row writes the event."

If both sides genuinely need to know, the safer pattern is to fire *different* events. The client emits `purchase_started` (intent), the server emits `purchase_completed` (truth). The funnel joins on identity, not on a single duplicated event.

### Firing one event per loop iteration

If you find yourself calling `track()` inside a `for` loop or a `.map()` callback, you're almost certainly conflating "a thing happened to a list" with "this user did N things". Aggregate first:

```
// Wrong — 47 events for a single user action
selected.forEach(item => Crossdeck.track("item_deleted", { id: item.id }));

// Right — one event, count as a property
Crossdeck.track("items_deleted", {
  count: selected.length,
  source: "bulk_action",
});
```

Per-item tracking is fine when each item-touch is a separate user decision (one click, one delete confirm). It's wrong when one user action affects N items.

## Seeing events in the dashboard

Once your SDK is firing `track()`, four dashboard surfaces consume that stream. None of them require additional setup — they are scoped to the same app(s) the SDK is sending to and read straight from the event pipeline.

### Live event feed

[Dashboard → Analytics → Events](https://app.cross-deck.com/events-explorer) is a streaming feed of every `Crossdeck.track()` call as it lands. Auto-refreshes every ~3 seconds; tap any row to inspect the full property payload, anonymous / developer / Crossdeck customer ID, SDK + version, and session anchor.

- **Filter by event name** — typing in the search box autocompletes against the actual event names observed in the last 7 days. No prior registration of event names required.

- **Filter by property** — the property-key autocomplete is driven by the same schema discovery that powers the breakdown chart (see below). Pick a key, then a value (also autocompleted from observed values).

- **Pause** — pauses the auto-refresh without losing the buffered rows. Useful when triaging a fast-moving event.

### Counts over time, grouped by any property

[Dashboard → Analytics → Breakdown](https://app.cross-deck.com/event-breakdown) answers *"how often did event X fire in the last 24h / 7d / 30d, broken down by any property?"*

- Pick an event from the autocomplete (24h / 7d / 30d windows; auto-bucketed to 5-min / hourly / daily as appropriate).

- Optionally pick a property to group by. The autocomplete lists every key observed on that event in the chosen window, with its value type and cardinality.

- The chart stacks one series per group value (top-12 by count; the rest collapse into `(other)` so the total still ties out).

- Deep-linkable: `/dashboard/event-breakdown/?event=checkout.completed` opens directly on that event.

Behind the scenes the breakdown reads straight from your event stream in Firestore — each event carries its own properties, aggregated at query time, so there's no property schema to declare in advance. **Fire `Crossdeck.track()` with new property keys and they appear in the autocomplete on the next refresh.**

### Schema discovery

Both the events feed and the breakdown chart read the property schema dynamically from the events themselves. There is no admin step to register properties, no JSON-schema config to maintain, no manual whitelist. The trade-off is that a high-cardinality property (e.g. `userId`, `requestId`) shows up in the autocomplete with a cardinality hint — grouping by it will still work but the legend will be dominated by `(other)`.

The dashboard treats *any* property your SDK emits as a filterable dimension. Practically: design property names with the dashboard in mind (see [Property design](#properties)), and they become first-class analytics knobs the moment they hit the ingest.

### Alerts

[Dashboard → Analytics → Alerts](https://app.cross-deck.com/alerts) turns the breakdown engine into a paging surface. Create a rule like:

```
{
  "name": "Payment failures (Android)",
  "event": "payment.failed",
  "propertyFilters": [
    { "key": "sdk_platform", "equals": "android", "valueType": "string" }
  ],
  "threshold": 1,
  "windowSec": 300,
  "cooldownSec": 900,
  "channels": {
    "slack": true,
    "email": { "enabled": true, "recipients": ["oncall@yourco.com"] },
    "webhook": { "enabled": true, "url": "https://your.app/crossdeck-alerts" }
  }
}
```

The evaluator runs every minute. When the rule's event fires `threshold` times in `windowSec` seconds, dispatch goes to every enabled channel:

- **Slack** — uses the workspace's existing Slack integration (no per-rule connection).

- **Email** — Resend-backed transactional email to a comma-separated recipient list.

- **Webhook** — HTTPS-only signed POST. Each rule has its own HMAC-SHA256 secret (minted at rule creation, stored in Secret Manager). Verify the `x-crossdeck-signature: sha256=…` header against the raw body.

`cooldownSec` suppresses re-fires for the configured duration so a runaway event doesn't page-storm. The cooldown gate is enforced in a Firestore transaction; two concurrent evaluator runs cannot double-page.

Every fire writes a row to `projects/{projectId}/alertEvents`, surfaced on the same page as "Recent fires" with the channel-delivery breakdown — you see at a glance whether Slack succeeded but the webhook failed, etc.

### Cross-app comparison

The workspace switcher in the sidebar has a **Compare** toggle in its popover. Turn it on and check additional apps to scope the events feed and breakdown chart across multiple apps in one view — the canonical use case is a product shipped on multiple platforms, where Android / iOS / Web each have their own `appId` and you want one chart of an event like `checkout.completed` across all of them.

Cross-app scope respects authorisation: the backend membership-checks every app id on every call, so you can never aggregate across an app you're not a member of. The maximum scope per query is 25 apps.

Alerts deliberately stay single-app — a rule belongs to one project so its cooldown / fire history have unambiguous ownership. To page on a cross-app condition, create the same rule on each app.

## Related

- [Identify users](https://cross-deck.com/docs/identify-users/) — set the user ID and attach traits before firing track() so events join correctly across sessions and devices.

- [Web SDK reference](https://cross-deck.com/docs/web-sdk/) — the full `@cross-deck/web` API including init options, auto-capture config, and the React hooks.

- [Node SDK reference](https://cross-deck.com/docs/node-sdk/) — server-side `track()`, the `ServerEvent` shape, and webhook-receiver patterns for purchase / subscription events.

- [Entitlements](https://cross-deck.com/docs/entitlements/) — the gating side of the same SDK; the dashboard joins entitlement state with event streams for plan-aware cohorts.

- [Error codes](https://cross-deck.com/docs/web-sdk/error-codes/) — every `CrossdeckError` code `track()` can throw (today: just `missing_event_name` and `not_initialized`), with retryable flag and resolution.

- [Events explorer](https://app.cross-deck.com/events-explorer), [Breakdown chart](https://app.cross-deck.com/event-breakdown), [Alerts](https://app.cross-deck.com/alerts) — the four dashboard surfaces that consume custom-event traffic.

Last updated against `@cross-deck/web@1.9.1` and `@cross-deck/node@1.10.0`. The canonical taxonomy is the dashboard's contract — changes to event names or required properties are versioned with the SDK.
