# Node SDK reference

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

Reference
Current version: 1.10.0
·
~22 min read
·
Updated May 15, 2026

`@cross-deck/node` is one SDK that handles all three Crossdeck pillars on a Node server: **verified subscriptions and entitlements**, **behavioural analytics**, and **error capture**. Construct one `CrossdeckServer` at process boot and every request gets durable batched events, a durable last-known-good entitlement cache, signed webhook verification, and full `uncaughtException` / `unhandledRejection` / `fetch` error capture flowing into the same dashboard your web users do.

## TL;DR

- **One install, three pillars.** One package — `@cross-deck/node` — covers server-side analytics, subscription/entitlement gating, signed webhook verification, and error capture. No separate Sentry + Mixpanel + RevenueCat backend stack.

- **Built for serverless.** `flush-on-exit` hooks `beforeExit` + `SIGTERM` + `SIGINT` to drain the queue before Lambda freezes the process or Cloud Run tears the container down. Framework adapters for Express, Lambda, and Firebase / Cloud Run ship the right flush-before-return contract automatically.

- **Microsecond entitlement gates, durable by design.** `server.isEntitled({ userId }, "pro")` is a synchronous per-customer cache read — safe inside a hot request handler. The cache is durable last-known-good: a Crossdeck outage never fails a paying customer down to free, and the 60 s TTL is a refresh hint, not an expiry. On serverless, wire an `entitlementStore` for cold-start durability.

- **Auto-wired error capture.** Constructing the SDK installs `process.on('uncaughtException')` + `process.on('unhandledRejection')` handlers and wraps `globalThis.fetch` for 5xx + network failures. Manual `captureError` / `captureMessage` share the same retry/idempotent queue analytics rides on.

- **Bank-grade plumbing.** Durable event queue with exponential-backoff retry, per-batch `Idempotency-Key`, bounded hard buffer cap (1000 events). Network errors are retried, not lost. `CrossdeckError` classes implement Stripe-style typed subclasses and `toJSON()` so structured loggers see `type` + `code` + `requestId`.

- **Stripe-compatible webhook signatures.** `verifyWebhookSignature(payload, header, secret)` uses constant-time HMAC comparison, a 5-minute replay window, and accepts a secret-array for in-flight rotation. `signWebhookPayload()` is exported for test fixtures.

- **Runtime enrichment, zero config.** Every event + error carries `runtime.host` (`aws-lambda` / `firebase-functions-v2` / `cloud-run` / `vercel` / …), `region`, `serviceName`, `serviceVersion`, and `instanceId` auto-detected from platform env vars. Cold-start signal included.

## Install

One package covers every Node-class runtime. The framework adapters live at the `@cross-deck/node/auto-events` subpath — same npm install, no extra dependency. The table below maps the surrounding wrapper to the runtime; the core `CrossdeckServer` API is identical across all of them.

Runtime Install path Where the SDK lives

Node.js (18+) — Express / Fastify / Koa / Hono / NestJS npm install @cross-deck/node App entry / DI container; single CrossdeckServer per process
AWS Lambda (Node 18+ / 20+) npm install @cross-deck/node Module-level new CrossdeckServer({...}) + wrapLambdaHandler(server, handler)
Vercel Serverless Functions (Node) npm install @cross-deck/node Per-route file; rely on flush-on-exit drain
Firebase Cloud Functions (v1 + v2) npm install @cross-deck/node Module entry + wrapFunction(server, handler) per export
Google Cloud Run / Cloud Run Functions npm install @cross-deck/node Process-level new CrossdeckServer({...}) ; wrapFunction for triggered handlers
Bun (≥ 1.0) bun add @cross-deck/node Same as Node — Bun ships a compatible process + fetch
Deno (compat mode) npm:@cross-deck/node Use Deno's npm specifier; runtime detection falls back to "node"

### npm — for every Node-class runtime

```
npm install @cross-deck/node
```

The package ships ESM + CJS dual exports plus TypeScript declarations. Tested against Node 18 LTS and Node 20 LTS. Bun 1.0+ and Deno (compat mode) work because both runtimes ship a compatible `process` + `globalThis.fetch`.

### Framework adapters subpath

```
import {
  crossdeckExpress,
  crossdeckExpressErrorHandler,
  wrapLambdaHandler,
  wrapFunction,
} from "@cross-deck/node/auto-events";
```

The `auto-events` subpath is a tree-shake-friendly barrel — importing `wrapLambdaHandler` doesn't pull in the Express adapter, importing `crossdeckExpress` doesn't pull in `aws-lambda` types. No adapter has a hard dependency on its framework's types either; they speak shape-only against `request`/`response`/`context` so a customer running Express doesn't pay for `firebase-functions` to be installed.

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 — ten lines, full server pillar

The fastest path from zero to "events flowing, errors captured, entitlements gated" is a single module that boots once at process start:

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

export const crossdeck = new CrossdeckServer({
  secretKey: process.env.CROSSDECK_SECRET_KEY!,  // cd_sk_test_… or cd_sk_live_…
});

// Anywhere in your request handlers:
await crossdeck.getEntitlements({ userId: req.user.id });   // warm cache
if (crossdeck.isEntitled({ userId: req.user.id }, "pro")) {
  // gate logic — sync, microsecond cache read
}

crossdeck.track({
  name: "checkout.started",
  developerUserId: req.user.id,
  properties: { amountUsd: 29 },
});
```

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

- Durable batched event delivery — `track()` returns sync; the SDK flushes at `eventFlushBatchSize` (20) or `eventFlushIntervalMs` (1500 ms), whichever fires first.

- `flush-on-exit` drain on `beforeExit` + `SIGTERM` + `SIGINT` so a SIGTERM from Cloud Run doesn't lose queued events.

- `process.on('uncaughtException')` + `process.on('unhandledRejection')` handlers installed — every uncaught error becomes an `error.unhandled` / `error.unhandledrejection` event with stack frames, breadcrumbs, and runtime enrichment.

- `globalThis.fetch` wrapped — any 5xx response or network failure from your outbound HTTP becomes an `error.http` event automatically. Crossdeck's own API calls are excluded to prevent cycle-amplification.

- A boot heartbeat fires on `setImmediate` after construction so the dashboard's "Verify install" row flips **LIVE** within ~200 ms of process start, no explicit `await` needed.

- Runtime enrichment — every event/error carries `runtime.host` (e.g. `aws-lambda`), `runtime.region`, `runtime.serviceName`, `runtime.serviceVersion`, `runtime.instanceId` auto-detected from env vars (`K_SERVICE` on Cloud Run / `AWS_LAMBDA_FUNCTION_NAME` on Lambda / `FUNCTION_NAME` on Cloud Functions v1).

## Initialise the SDK

Every Node process constructs `CrossdeckServer` exactly once at module load and holds the reference for the process's lifetime.

```
const crossdeck = new CrossdeckServer({
  secretKey: process.env.CROSSDECK_SECRET_KEY!,   // required
});
```

**One required option.** The secret key alone is enough — environment is inferred from the key prefix (`cd_sk_test_` → sandbox, `cd_sk_live_` → production), runtime metadata is auto-detected, error capture + flush-on-exit + boot heartbeat all default to ON. The constructor validates the key shape and throws `CrossdeckError({ type: "configuration_error", code: "invalid_secret_key" })` if it doesn't start with `cd_sk_` — fail loudly at boot, never silently at first network call.

Never put the secret key in source.

Secret keys (`cd_sk_…`) authenticate as your project on the server — anyone holding one can read entitlements, grant features, or POST events as you. Read from `process.env.CROSSDECK_SECRET_KEY`, Google Cloud Secret Manager, AWS Secrets Manager, or your platform's secret store. Never commit. Never ship to the browser.

### Constructing in serverless module scope

The Node SDK is designed to be constructed once at **module load**, not per-request:

```
// crossdeck.ts — imported once, instantiated once
import { CrossdeckServer } from "@cross-deck/node";

export const crossdeck = new CrossdeckServer({
  secretKey: process.env.CROSSDECK_SECRET_KEY!,
});
```

Constructing per-invocation in a Lambda or Cloud Function is wasteful — you'd duplicate the boot heartbeat, the runtime detection, and the handler install on every cold start. Module scope is the idiomatic pattern and matches how Stripe's, Mixpanel's, and Sentry's Node SDKs document it.

**Safe under Next.js and any framework that re-evaluates modules.** Some frameworks (Next.js with HMR, per-route isolation, and React Server Components) re-run a module's top-level code more than once. The export above is still the correct pattern: for a given secret key the SDK returns the *same* instance every time, so re-evaluation can never give you a second client, a duplicate boot heartbeat, or stacked process handlers. Construct once at module scope and import it everywhere — there is nothing extra to do. (Requires `@cross-deck/node` 1.8.0 or later.)

### Idempotency & lifecycle

The constructor is synchronous. By the time it returns:

- Secret key prefix is validated; throws `CrossdeckError` on a bad prefix.

- Environment (`sandbox` | `production`) is inferred from the key prefix.

- Runtime info is detected (cached for the process's lifetime).

- Event queue is constructed and ready to accept `track()` calls.

- Error tracker installed (unless `errorCapture: false`).

- Flush-on-exit handlers installed (unless `flushOnExit: false`).

- Boot heartbeat scheduled on `setImmediate` so the constructor returns first (unless `testMode: true` or `bootHeartbeat: false`).

For tests and short-lived scripts where you need deterministic teardown, use `shutdown()` or the TC39 disposal hooks: `using server = new CrossdeckServer({...})` (sync dispose), `await using server = new CrossdeckServer({...})` (async dispose — awaits `flush()` before shutdown).

## Identity & users

The Node SDK has no per-device identity (servers handle many users; identity is per-request). Every event needs an identity hint — at least one of `developerUserId`, `anonymousId`, or `crossdeckCustomerId`:

- **`developerUserId`** — your auth provider's stable user ID. Most common case.

- **`anonymousId`** — a stable per-device ID forwarded from your web/mobile client when the user isn't signed in.

- **`crossdeckCustomerId`** — the canonical `cdcust_…` identifier returned from a prior `identify()` / `getEntitlements()` call. Use when you've already resolved it.

### server.identify(userId, anonymousId, options?)

Async. Link an anonymous device's events to a stable user ID on the server side — typically called from your sign-up / sign-in handler.

```
await crossdeck.identify("user_847", "anon_mp10knb…", {
  email: "wes@example.com",
  traits: {
    name: "Wes",
    plan: "pro",
    signedUpAt: "2026-05-15",
  },
});
```

Both `userId` and `anonymousId` are required (the call merges the anonymous-device's pre-signup events into the canonical customer record). The lower-level `server.aliasIdentity({ userId, anonymousId, email, traits })` takes the same inputs as a single options bag if you prefer that ergonomics.

Traits are sanitised at the SDK boundary — functions, symbols, and undefined values are dropped; `Date` objects become ISO strings; `BigInt` values become strings; nested objects deeper than 5 levels become `"[depth-exceeded]"`; circular references become `"[circular]"`.

### Super properties — process-wide enrichment

Register a property bag once; every subsequent `track()` from this `CrossdeckServer` instance carries those keys on its `properties` bag automatically.

```
crossdeck.register({ region: "us-east4", build: "v2.3.1" });
crossdeck.track({ name: "paywall.shown", developerUserId: req.user.id });
// → event carries region: "us-east4", build: "v2.3.1" on its properties

crossdeck.unregister("build");
crossdeck.getSuperProperties();
// → { region: "us-east4" }
```

Multi-tenant servers — read carefully.

Super-properties are **process-scoped**. In a single Node process handling requests for many tenants, calling `crossdeck.register({ tenant: "acme" })` taints every subsequent event from that process — including ones serving tenant "beta". For per-request properties, pass them on the `track()` call itself. Reserve `register()` for things that genuinely apply to every event from this process — service version, region, build commit. (For those, `runtime.*` already provides `serviceVersion` etc. automatically.)

### Group analytics — B2B SaaS

Associate the current SDK instance with one or more organisational entities (org, team, account, plan). Every subsequent event carries `$groups. : id` on its `properties` bag.

```
crossdeck.group("org", "acme_inc");
crossdeck.group("team", "design", { headcount: 12 });
crossdeck.track({ name: "paywall.shown", developerUserId: req.user.id });
// → properties.$groups = { org: "acme_inc", team: "design" }
```

Pass `id: null` to clear a membership. Same multi-tenant caveat as super-properties — groups are process-scoped, not per-request. The dominant pattern in multi-tenant servers is to pass `$groups` on the event's `properties` directly.

## Events & analytics

### server.track(event)

Synchronous — enqueues an event for batched delivery. The network round-trip happens in the background. The single-argument signature is intentional: the Node wire shape needs the full `ServerEvent` (identity hint + optional level + tags + categoryTags), not just `(name, properties)` like the browser does.

```
crossdeck.track({
  name: "checkout.completed",
  developerUserId: req.user.id,
  properties: {
    amountUsd: 29,
    plan: "pro",
  },
});
```

If no identity hint is supplied (e.g. an `uncaughtException` handler with no per-request context), the SDK auto-fills `anonymousId` with a process-stable pseudo-ID minted at construction time. That keeps events from the same process correlated even when the call site can't supply a user.

Properties are validated at the SDK boundary before the event enters the queue — same contract as the web SDK. One bad property can never crash `JSON.stringify` at flush time or poison the batch:

- Functions, symbols, and undefined values are dropped.

- `Date` objects become ISO strings.

- `BigInt` values become strings.

- `Error` objects become `{ name, message, stack }`.

- `Map` becomes a plain object, `Set` becomes 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: true` marker is added.

- The caller's input object is never mutated — sanitisation always produces a defensive copy.

Enrichment order (parity with web SDK): runtime info → super-properties → group memberships (as `$groups`) → caller-supplied properties. The caller's bag wins on key collision — a developer-set value always overrides what the SDK auto-attached.

### server.ingest(events, options?)

Immediate POST of one or more events. Bypasses the queue — no batching, no auto-fill of identity, no runtime enrichment. Returns the `IngestResponse` synchronously so the caller can confirm how many landed.

```
const result = await crossdeck.ingest(
  [
    { name: "signup.completed", developerUserId: "user_847" },
    { name: "trial.started", developerUserId: "user_847" },
  ],
  { idempotencyKey: "batch_2026_05_15_signups_847" },
);
// → { object: "list", received: 2, env: "sandbox", … }
```

Use `track()` for the standard fire-and-forget telemetry path. Use `ingest()` for:

- Bulk imports / one-shot replay (migrating from another analytics tool).

- Synchronous confirmation that the server accepted the batch.

- Caller-controlled `Idempotency-Key` (default mints a random key — pass one in for deterministic batch replay).

### server.flush()

Drain the event queue. Resolves when the in-flight batch completes — success or failure. On failure, events stay queued for the next scheduled retry; the resolved promise does NOT throw. Idempotent — flush on an empty queue is a no-op.

```
await crossdeck.flush();
```

Typical callers: end of a Lambda handler (the framework adapter does this automatically), Express SIGTERM handler, end-of-test cleanup.

### Flush-on-exit — the silent-loss defence

Constructing `CrossdeckServer` installs handlers for `process.on('beforeExit')`, `process.on('SIGTERM')`, and `process.on('SIGINT')` that synchronously await `flush()` before the process is allowed to terminate. Bounded by `flushOnExitTimeoutMs` (default 2000 ms) so a misbehaving server can't keep the function alive past the platform's SIGKILL (typically 5–10 s after SIGTERM).

This is non-optional for serverless. Without it, a Cloud Function cold-starts, fires three events synchronously, and the process exits before the HTTP POSTs complete — the events vanish without trace. `process.on('exit')` is too late (the event loop is dead by then); `beforeExit` is the last point at which async work can still run.

SIGTERM is what Cloud Run fires on container stop, what Lambda fires on idle termination. SIGINT is Ctrl-C in dev. `beforeExit` covers the normal-completion case. All three reach the same drain function; the drain is idempotent so multiple signals only flush once.

### The durable queue

The event queue is the analytics workhorse — used by `track()` and by every error report:

- **Buffer cap**: hard limit at 1000 events. Above that, the OLDEST events are evicted and a `dropped` counter increments (surfaced in `diagnostics().events.dropped` and on the `queue.dropped` event).

- **Retry policy**: exponential backoff with full jitter on flush failures. Honours server `Retry-After` headers parsed onto `CrossdeckError.retryAfterMs`. Replaces "retry on the next idle window" which hot-looped against a flapping endpoint.

- **Per-batch `Idempotency-Key`**: the same key is reused across retries of the same batch so the server can short-circuit duplicate work. The backend also dedupes individual events via ClickHouse `ReplacingMergeTree` on `eventId` — belt-and-suspenders.

- **Permanent network outage**: keep retrying with bounded backoff; never drop events because of network failures alone. The only drop path is the hard buffer cap.

- **EventEmitter events**: `queue.flush_failed`, `queue.permanent_failure`, `queue.dropped`, `queue.buffer_changed` for caller observation.

### Backpressure — isReady() & awaitReady()

For high-volume servers that need to shed load when the SDK is in a sustained retry storm:

```
if (!crossdeck.isReady()) {
  // shed load — SDK has consecutive flush failures >= 5
  // OR the buffer is at >= 80% of HARD_BUFFER_CAP
  return res.status(503).send("backpressure");
}

// Or async wait, useful for warm-up:
await crossdeck.awaitReady(2000);
```

The default isn't "perfectly healthy" — the SDK is happy to enqueue events even during transient flush failures because the queue's retry path handles them. Only sustained failure flips `isReady()` to `false`.

For Kubernetes liveness + readiness probes, `server.getHealth()` returns a structured snapshot suitable for direct response in a `/healthz` handler.

## Entitlements

Entitlements are the named "what this customer is allowed to do" facts your app reads to gate paid features — `pro`, `team`, `ai_addon`, whatever your catalog defines; a customer holds a *set* of them. The Node SDK keeps a per-customer **durable last-known-good cache** so `isEntitled()` is a synchronous memory read after the first warm — safe inside a hot request handler, and resilient: a Crossdeck outage never fails a paying customer down to free.

### server.getEntitlements(hints, options?)

Async. Fetches the customer's active entitlements from the server and replaces their cache entry. The `hints` argument accepts any combination of `customerId`, `userId`, `anonymousId` — the backend resolves them to a canonical `crossdeckCustomerId` via the identity graph.

```
const response = await crossdeck.getEntitlements({ userId: req.user.id });
// → { object: "list", data: PublicEntitlement[], crossdeckCustomerId, env }
```

Only a **successful** fetch replaces a cache entry — a network failure is routed to the failed-refresh path and never overwrites last-known-good, so an outage can never fail a paying customer down to free. When an `entitlementStore` is configured, a successful fetch is also persisted to it (`await store.save(...)`), and on a network failure the SDK falls back to `store.load(...)` to recover last-known-good — the cold-start durability path described below.

Side effect: the response's canonical `crossdeckCustomerId` is recorded as an alias for the hint, so a subsequent `isEntitled({ userId }, "pro")` resolves to the same cache entry. The alias map is bounded to defend long-running multi-tenant servers from leaking `Map` entries forever.

### server.getCustomerEntitlements(customerId, options?)

Same as above, but takes the canonical `crossdeckCustomerId` directly. Use when you've already resolved it (e.g. from a webhook payload).

### server.isEntitled(hint, key): boolean

**Synchronous, zero I/O.** Reads from the local cache. Returns `true` iff the customer has a matching entitlement that is active and not past its own `validUntil`. It returns `false` only when there is genuinely nothing to grant: the key isn't in the customer's set, the matching entitlement is inactive or has expired, or the customer has no cache entry at all (a genuine cold miss the process has never warmed).

```
await crossdeck.getEntitlements({ userId: req.user.id });    // warm once

if (crossdeck.isEntitled({ userId: req.user.id }, "pro")) {
  // hot path — no HTTP, memory read
}
```

The `hint` can be a `string` (treated as `crossdeckCustomerId`) or an `IdentityHints` object.

The TTL is a refresh hint, not an expiry. isEntitled() never expires a customer to false .

`entitlementCacheTtlMs` (default 60 s) controls only *when a re-fetch is due*. Once a customer is warm, `isEntitled()` keeps serving their last-known-good entitlements past the TTL — it does **not** start returning `false` because the entry aged. A paying customer must not be locked out 60 seconds after a warm just because Crossdeck was briefly unreachable. The cache forgets a customer only on `shutdown()`, an explicit clear, or LRU eviction under the `maxCustomers` cap. Re-fetching on the TTL hint keeps the data current; it is never an invalidation.

Re-warm on whatever cadence fits your workload — a per-request `getEntitlements()`, a background timer, or post-webhook. The TTL tells you when a re-fetch is *due*; until that re-fetch succeeds the customer keeps their access. Caching the gate this way removes the 50–200 ms per-request HTTP round-trip a naive `GET /v1/entitlements` on every request would cost.

### server.listEntitlements(hint): PublicEntitlement[]

Sync snapshot of the customer's cached entitlements. Returns `[]` only when the customer has no cache entry — a past-TTL or stale entry still returns its last-known-good entitlements, same durability posture as `isEntitled()`. Use when you need `source.rail` / `validUntil` details and not just the boolean (honour each entitlement's `validUntil` yourself from the returned objects).

### server.onEntitlementsChange(listener): () => void

Subscribe to cache mutations. The listener fires after `getEntitlements()` populates the cache. Returns an idempotent unsubscribe function. Listener errors are swallowed and surfaced via `diagnostics().entitlements.listenerErrors`.

### Serverless cold starts — the entitlementStore option

The cache is durable last-known-good *within the process*: a long-lived server (a VM, a container that stays warm) keeps the in-memory cache between requests, so it rides out a Crossdeck outage. A serverless host does not. On Cloud Run or Lambda a cold start begins with an **empty cache** — and the SDK does not pretend otherwise.

On serverless with no durable store, a cold start during a Crossdeck outage reads a paying customer as un-entitled.

The window is narrow — a fresh container, plus a Crossdeck outage, before the first `getEntitlements()` for that customer succeeds — but in that window `isEntitled()` has nothing cached and returns `false`. This is unavoidable for a pure in-memory cache on a stateless host. The SDK states it rather than hiding it: on a serverless runtime with no `entitlementStore` it emits a `sdk.no_durable_store` debug warning and stamps a `durability` fact on its boot telemetry event. The fix is to wire a durable store.

The **`entitlementStore`** constructor option closes the gap. It is a pluggable async durable store — the `EntitlementStore` interface, a `load` / `save` pair — that you back with Redis, your primary database, or a KV. Once wired:

- Every successful `getEntitlements()` persists the result to it (`await store.save(key, snapshot)`).

- On a network failure, `getEntitlements()` falls back to `store.load(key)` and serves last-known-good from there — so even a cold container survives a Crossdeck outage.

- `isEntitled()` never touches the store. It stays a synchronous in-memory read; the store is only ever awaited from the already-async `getEntitlements()`. A broken store weakens durability — it never breaks the SDK or slows the hot path.

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

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const crossdeck = new CrossdeckServer({
  secretKey: process.env.CROSSDECK_SECRET_KEY!,
  entitlementStore: {
    // load / save are awaited only inside getEntitlements(), never isEntitled().
    async load(key) {
      const raw = await redis.get(key);
      return raw ? JSON.parse(raw) : null;
    },
    async save(key, snapshot) {
      await redis.set(key, JSON.stringify(snapshot));
    },
  },
});
```

A managed Crossdeck KV is not available yet.

Today the durable store is yours to supply — Redis, your database, any KV. A managed Crossdeck-hosted store is a planned fast-follow, not a shipped option. On a long-lived host with no serverless cold starts, you can run without an `entitlementStore`; on serverless, wire one. Check `diagnostics().entitlements.coldStartDurable` to confirm your posture.

### server.syncPurchases(input, options?)

Forward Apple StoreKit 2 transaction evidence 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.

### Manual entitlement controls — grantEntitlement / revokeEntitlement

For ops workflows (compensation grants, beta bypass, abuse revocation):

```
await crossdeck.grantEntitlement({
  customerId: "cdcust_xxx",
  entitlementKey: "pro",
  duration: "P30D",                      // ISO 8601 — "P30D" | "P90D" | "P1Y" | "lifetime"
  reason: "compensation:incident_2026_05",
});

await crossdeck.revokeEntitlement({
  customerId: "cdcust_xxx",
  entitlementKey: "pro",
  reason: "abuse:tos_violation",
});
```

`bulkGrantEntitlement(grants, { maxConcurrency? })` and `bulkRevokeEntitlement(...)` run client-side fan-out at bounded concurrency (default 5). The result is a settled-array — partial failures don't drop the rest; the caller handles each `{ ok: true, value } | { ok: false, error }`.

### server.getAuditEntry(eventId, options?)

Fetch the rail-event audit row for a given `eventId`. Returns the `AuditEntry` for the matched webhook / rail event — useful for post-hoc inspection of why a particular subscription transition happened. Returns the resolved entry directly (the wire response's `data` field is unwrapped for ergonomic use).

## Error capture

The third Crossdeck pillar — default-on. Constructing `CrossdeckServer` installs the error tracker and you get Sentry-grade backend error monitoring with no further code. Every captured error flows through the same durable, retried, idempotent queue analytics uses.

### What gets caught automatically

- **Uncaught synchronous errors** via `process.on('uncaughtException')` → `error.unhandled`.

- **Unhandled promise rejections** via `process.on('unhandledRejection')` → `error.unhandledrejection`.

- **Failed HTTP requests** — the SDK wraps `globalThis.fetch`. Status >= 500 or a network-layer failure becomes `error.http`. Crossdeck's own API calls are explicitly excluded to prevent cycle-amplification on a Crossdeck outage.

Console-error capture is opt-in (`errorCapture: { captureConsole: true }`) — most servers log too aggressively for default-on capture to be useful. XHR wrapping doesn't exist on the server (Node has no XHR).

### Manual capture

```
try {
  await risky();
} catch (err) {
  crossdeck.captureError(err, {
    context: { jobId, cart: { items: 3 } },
    tags: { flow: "checkout", region: "us-east4" },
    level: "error",
  });
}
```

`server.captureMessage(msg, level?)` reports a non-error signal (e.g. "deprecated path hit", "soft-warning triggered"). Emits `error.message`. Default level `"info"`.

### 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 dashboard filtering. Context is a named structured blob for richer detail. Both attach to every subsequent error report. Cleared on `shutdown()`.

### Breadcrumbs — what the process was doing right before the crash

The SDK keeps a rolling buffer of the last 50 things the process did — every `track()` call auto-emits a breadcrumb (except `error.*` events, which would create a cycle). When an error fires, the buffer is attached so the engineer reading the report can replay the path into the broken state.

```
crossdeck.addBreadcrumb({
  timestamp: Date.now(),
  category: "custom",
  message: "about-to-charge-customer",
  data: { amountUsd: 29, plan: "pro" },
});
```

Categories: `navigation`, `ui.click`, `ui.input`, `http`, `console`, `custom`, `info`. Levels: `debug`, `info`, `warning`, `error`.

### Fingerprinting — group identical errors

Every error gets an 8-character hex fingerprint derived from the message and the top in-app stack frames (djb2 hash). 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**: `maxPerFingerprintPerMinute` (default 5). Defends against runaway loops (an error inside a per-request middleware).

- **Per-session cap**: `maxPerSession` (default 100). Hard limit per process lifetime, after which capture stops silently. The dashboard sees "1 unique error" instead of a million identical events.

- **Sampling**: `sampleRate` in `[0, 1]`. Deterministic per fingerprint — a given fingerprint always either always sends or never does, no flapping.

- **Fingerprint window cap**: the rate-limit window `Map` is bounded at 4096 entries. Beyond that, dead entries are pruned and the oldest evicted FIFO so long-running processes don't leak Map entries.

### Built-in noise filtering

`denyPaths` defaults to skipping the SDK's own frames (`node_modules/@cross-deck/node/`) so SDK self-skip is automatic. There's no Node equivalent of the browser's `ResizeObserver` / `Script error.` noise, so the default `ignoreErrors` list is empty — add your own substrings or regexes via `errorCapture.ignoreErrors` if a specific library generates known noise.

`allowPaths` (empty by default = "capture everything") flips the model: when populated, only errors whose top in-app frame matches one of the patterns are captured. Useful for narrowing capture to your own code in environments with noisy third-party packages.

### server.setErrorBeforeSend(hook) — the redaction last line

Install a pre-send filter. The hook is called LAST, after rate limit + sampling + path gates already passed. Return `null` to drop the error, or a modified `CapturedError` to scrub 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 — 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's last-resort error path. A `_reporting` recursion guard prevents the SDK from reporting its own errors recursively forever.

## Webhook verification

Stripe pattern. Customers receive a signing secret from the Crossdeck dashboard (one-time reveal at mint time; rotated as needed). Each Crossdeck-sent webhook carries a `Crossdeck-Signature: t= ,v1= ` header where `v1` is `HMAC-SHA256(secret, "${t}.${payload}")`.

### verifyWebhookSignature(payload, header, secret, options?)

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

app.post(
  "/crossdeck-webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    try {
      const event = verifyWebhookSignature(
        req.body.toString("utf8"),
        req.headers["crossdeck-signature"],
        process.env.CROSSDECK_WEBHOOK_SECRET,
      );
      handleCrossdeckEvent(event);
      res.sendStatus(200);
    } catch (err) {
      res.sendStatus(401);
    }
  },
);
```

Returns the parsed JSON payload on success. Throws `CrossdeckError` with one of these codes:

- `webhook_missing_secret` — no secret configured (empty / undefined).

- `webhook_invalid_tolerance` — `replayToleranceMs` is negative, `NaN`, `Infinity`, or above the 24h cap.

- `webhook_timestamp_missing` — the signature header is absent or has no `t=` timestamp.

- `webhook_timestamp_outside_tolerance` — the timestamp drift exceeds `replayToleranceMs` (replay defence).

- `webhook_signature_mismatch` — the HMAC didn't match any configured secret.

- `webhook_payload_not_json` — signature verified, but the body wasn't valid JSON (tampered).

Pass the RAW body, not the JSON-parsed one.

Once Express's default JSON middleware reformats the body, the HMAC won't match. Mount `express.raw({ type: "application/json" })` on the webhook route specifically — your other routes can keep `express.json()`. Same rule as Stripe's webhooks; same fix.

### Constant-time comparison

HMAC verification uses `crypto.timingSafeEqual` so a malicious caller can't extract the signature by measuring response timing. Belt-and-suspenders for an HMAC-only scheme — if you have an idle reverse proxy in front, treat it as defence-in-depth.

### Replay defence

`replayToleranceMs` defaults to 5 minutes. Timestamps older than that are rejected as a replay, with the diff included in the error message ("Webhook timestamp is N ms outside the M ms replay-tolerance window"). Required because HMAC-SHA256 is stateless and would otherwise allow an attacker to replay an old webhook indefinitely.

`{ replayToleranceMs: 0 }` is **maximally strict**, not "off": with a zero window, any webhook whose timestamp isn't the exact current millisecond is rejected as a replay. Replay defence cannot be disabled — the only knob is how wide the window is (1ms–24h; negatives and values above 24h throw `webhook_invalid_tolerance`).

### Rotation — multi-secret arrays

Pass the `secret` argument as an array to support rotation:

```
const event = verifyWebhookSignature(payload, header, [
  process.env.CROSSDECK_WEBHOOK_SECRET_NEW,
  process.env.CROSSDECK_WEBHOOK_SECRET_OLD,
]);
```

The helper tries each, accepts on the first match. Lets you rotate the dashboard secret without dropping in-flight webhooks during the cutover.

### signWebhookPayload(payload, secret, timestampSec): string

Pure-function signing — mirrors what the Crossdeck backend does when sending a webhook. Exported so customers building test fixtures (a service that sends Crossdeck-signed webhooks to their own test harness) can re-use the canonical signing scheme.

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

const ts = Math.floor(Date.now() / 1000);
const sig = signWebhookPayload(payload, secret, ts);
const header = `t=${ts},v1=${sig}`;
```

NOT marked as a security primitive for general HMAC — use `node:crypto` directly for that. This is only the Crossdeck-signature shape.

## Framework adapters

Three adapters ship at the `@cross-deck/node/auto-events` subpath. Each is shape-only against its framework's runtime types — no hard dependency, no forced install. Fastify is deferred to a later release; Cloudflare Workers and Vercel Edge are deferred because they lack a Node-style `process.on(...)` lifecycle (flush-on-exit needs a runtime-specific pattern there).

### Express — crossdeckExpress + crossdeckExpressErrorHandler

```
import express from "express";
import {
  crossdeckExpress,
  crossdeckExpressErrorHandler,
} from "@cross-deck/node/auto-events";
import { crossdeck } from "./crossdeck";

const app = express();

app.use(crossdeckExpress(crossdeck, {
  getIdentity: (req) => ({ developerUserId: req.user?.id }),
}));

app.use(routes);

app.use(crossdeckExpressErrorHandler(crossdeck)); // LAST
```

`crossdeckExpress(server, options?)` emits a `request.handled` event on response `finish` (or `close` for client-aborted requests). Properties: matched route pattern (NOT the full URL — high-cardinality URL paths kill dashboards), method, statusCode, durationMs, plus `userAgent` and `responseBytes` when available. Compatible with Express 4 and 5.

Options:

- `skipPaths` — array of strings or regexes; default `[/^\/crossdeck($|\/)/]` to skip the SDK's own routes.

- `getIdentity` — runs once per request, return `{ developerUserId? | anonymousId? | crossdeckCustomerId? }`. Thrown errors are swallowed — telemetry never breaks the request pipeline.

- `captureErrorsWithRequestContext` — default `true`; attaches `{ url, method, route }` as `context.request` on errors captured by the error middleware.

`crossdeckExpressErrorHandler(server, options?)` is a 4-arg error middleware. Register LAST. Captures the error with request context, then forwards to `next(err)` so your framework still produces the normal 500 response. Express 5 supports async handlers natively; Express 4 needs you to forward async errors via `next(err)` — not a Crossdeck limitation.

### Lambda — wrapLambdaHandler

```
import { wrapLambdaHandler } from "@cross-deck/node/auto-events";
import { crossdeck } from "./crossdeck";

export const handler = wrapLambdaHandler(crossdeck, async (event, context) => {
  // your handler
});
```

Emits:

- `function.invoked` on entry — `requestId`, `functionName`, `functionVersion`, `coldStart`, `memoryLimitMb`, `remainingMs`.

- `function.completed` on success — `durationMs`, `memoryUsedMb`, plus `statusCode` + `responseBytes` for API Gateway / Function URL handlers that return `{ statusCode, body }`.

- `function.failed` on throw — `errorType`, `errorMessage`, `durationMs`. The error is re-thrown so Lambda still reports it to CloudWatch.

`await server.flush()` runs in `finally` for every invocation. This is non-optional on Lambda — the runtime freezes the process between invocations, so any event queued but not sent vanishes silently when the function returns. `flush-on-exit` doesn't fire because the process isn't exiting; it's hibernating.

Cold-start detection is per-container: the first invocation of a fresh container gets `coldStart: true`; subsequent invocations of the same warm container get `coldStart: false`. AWS spawns multiple containers for concurrent invocations, so this is a per-container signal.

### Firebase / Cloud Run — wrapFunction

```
import { onRequest } from "firebase-functions/v2/https";
import { wrapFunction } from "@cross-deck/node/auto-events";
import { crossdeck } from "./crossdeck";

export const myFunction = onRequest(
  wrapFunction(crossdeck, async (req, res) => {
    // your handler
  }),
);
```

`wrapFunction` is shape-preserving: it accepts ANY function signature and returns one with the same signature. This is intentional because Firebase has many handler shapes across v1 + v2 (`onRequest`, `onCall`, `onWrite`, `onPublish`, `onDocumentWritten`, …). Rather than ship one wrapper per signature, the generic wrap with a plug-in `getMetadata` extractor covers every trigger type without forcing a dependency on `firebase-functions`.

```
wrapFunction(crossdeck, handler, {
  getMetadata: (args) => ({
    identity: { developerUserId: args[0].auth?.uid },
    properties: { docPath: args[0].ref?.path, region: "us-central1" },
  }),
  runtime: "firebase-firestore",
});
```

Same lifecycle as Lambda: `function.invoked` / `function.completed` / `function.failed` with cold-start signal and a `finally` flush. Same critical contract — Firebase tears down idle containers; queued events vanish if the SDK doesn't flush before the handler returns.

Use the same wrapper for Cloud Run services (any Node handler that freezes / tears down between invocations). Override `options.runtime` to distinguish trigger types in the dashboard.

## Privacy & consent

The Node SDK deliberately does NOT ship a `ConsentManager` class. Consent is a client-side UX concern (DNT detection, per-dimension consent gating); the API caller on the server has already received whatever consent decision the user made in their client. Shipping server-side consent gating would imply a trust model that doesn't match the deployment shape.

What the Node SDK DOES ship: opt-in PII-scrub utilities customers apply at their own discretion.

### scrubPii(value): string

Pure helper. Replaces email-shaped substrings with `[email]` and card-number-shaped substrings with `[card]`. Returns the original string (`===`) when nothing matched — caller can identity-check to skip allocating a new copy.

### scrubPiiFromProperties(properties): Record

Walk a property bag and replace PII-shaped strings recursively. Returns a new defensive copy — the input is never mutated.

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

crossdeck.track({
  name: "checkout.started",
  developerUserId: req.user.id,
  properties: scrubPiiFromProperties({
    url: req.url,         // "/users/wes@…/" → "/users/[email]/"
    lastError: e.message, // card numbers in error strings → "[card]"
  }),
});
```

Email pattern matches the practical 99% of emails (RFC 5322 obs-local-part common case). Card pattern matches 13–19 digits possibly split by space or hyphen — no Luhn validation; this is best-effort scrubbing, not card-data tokenisation. If you're handling actual PAN data you should not be passing it through analytics in the first place.

Plain objects, arrays, and nested plain objects are walked. `Date`, `Map`, `Set`, `Error`, functions, symbols pass through untouched — those are the `validateEventProperties` sanitiser's job at `track()` time.

## Configuration reference

Every option accepted by `new CrossdeckServer(...)`. Required options are bold.

Option
Type
Default
Description

secretKey
string
required
Secret API key. Must start with cd_sk_test_ or cd_sk_live_ . Environment is inferred from the prefix.

baseUrl
string
"https://api.cross-deck.com/v1"
Override the API base URL — for self-hosted setups or local emulator.

timeoutMs
number
15000
Per-request abort timeout. Pass 0 to disable. Lands as CrossdeckError({ type: "network_error", code: "request_timeout" }) on expiry.

sdkVersion
string
package version
Override the SDK version reported on the wire. Useful only for white-label builds.

appId
string
none
Optional informational appId stamped onto event batches. The server trusts the API key's resolved app routing — this is best-effort metadata, not source of truth.

errorCapture
boolean | Partial<ErrorCaptureConfig>
true (auto-wired)
Default: enabled with onUncaughtException + onUnhandledRejection + wrapFetch . Pass false to disable entirely; pass a partial object to override individual knobs.

eventFlushBatchSize
number
20
Maximum events buffered before forced flush.

eventFlushIntervalMs
number
1500
Idle ms after the last track() before flushing.

flushOnExit
boolean
true
Install beforeExit + SIGTERM + SIGINT handlers that drain the queue. Critical for serverless. Set false only if your runtime manages SDK shutdown explicitly.

flushOnExitTimeoutMs
number
2000
Bounded timeout for the on-exit drain. Two seconds is enough to flush a healthy batch without holding teardown past the platform's SIGKILL window.

bootHeartbeat
boolean
true
Fire a heartbeat in the background the moment the SDK is constructed. Drives the dashboard's "Verify install" surface. Fire-and-forget; failures are swallowed.

entitlementCacheTtlMs
number
60000
Refresh- hint interval for the per-customer cache — when a re-fetch becomes due, not an expiry. isEntitled() keeps serving last-known-good past it; it never expires a customer to false . Pass 0 to make every entry immediately refresh-due (useful in tests) — it still serves last-known-good.

entitlementStore
EntitlementStore
undefined
Pluggable async durable store (a load / save pair — Redis, your DB, a KV) for cold-start durability. Without it, a serverless cold start begins with an empty cache. See Serverless cold starts . Touched only from getEntitlements() , never the synchronous isEntitled() .

serviceName
string
env-detected
Service name for runtime enrichment. Detected via K_SERVICE / AWS_LAMBDA_FUNCTION_NAME / FUNCTION_NAME ; falls back to process.pid .

serviceVersion
string
env-detected
Service version. Detected via K_REVISION / AWS_LAMBDA_FUNCTION_VERSION .

appVersion
string
none
Your app's version. Auto-attached as appVersion on every event/error. Parity with web SDK.

breadcrumbsMaxSize
number
50
Breadcrumb buffer size. The last N tracked events + manual breadcrumbs are attached to every error report.

debug
boolean
false
Enable verbose diagnostic logging. Equivalent to setDebugMode(true) after construction.

testMode
boolean
false
Every HTTP call short-circuits to a synthetic success response — no network goes out. onRequest / onResponse hooks still fire. Implicitly disables bootHeartbeat . Never enable in production.

onRequest
(info: HttpRequestInfo) => void
none
Hook fired BEFORE every HTTP request including retries. Synchronous; thrown errors are swallowed.

onResponse
(info: HttpResponseInfo) => void
none
Hook fired AFTER every HTTP response. Carries durationMs , attempt , and testMode .

httpRetries
HttpRetriesConfig
3 attempts, jittered backoff
Retry config for idempotent GETs. Default retries on 408 + 5xx (except 501) and network failures. Set maxAttempts: 1 to disable. POST retries are handled by the EventQueue separately.

runtimeToken
string
env-detected
Override the runtime token in the User-Agent header. Default detects node/<version> <platform> .

### ErrorCaptureConfig sub-flags

Flag Default When to override

enabled true Master switch.
onUncaughtException true Disable if another error tracker already installs this handler.
onUnhandledRejection true Same — Sentry, Datadog, etc. also hook this.
wrapFetch true Disable if you have a separate HTTP instrumentation layer.
captureConsole false Opt-in — most servers log too aggressively for default-on capture.
ignoreErrors [] Add substrings / regexes for known-noisy library errors.
allowPaths [] Populate to narrow capture to your own code; empty means capture everything.
denyPaths SDK self-skip Add paths whose top frames you don't want captured (e.g. a noisy third-party module).
sampleRate 1.0 Per-fingerprint deterministic sampling — 0.5 = half of distinct errors, no flapping.
maxPerFingerprintPerMinute 5 Defends against runaway loops.
maxPerSession 100 Hard per-process cap. The dashboard sees "1 unique error" instead of a million.

## API reference

Every public method on `CrossdeckServer`. The class extends `EventEmitter` — typed `on` / `once` / `off` / `emit` overloads narrow listener arguments for the events listed under **EventEmitter signals** below.

### Lifecycle

Method Returns Notes

new CrossdeckServer(options) CrossdeckServer Boot. Synchronous. Validates key; throws on bad prefix.
heartbeat(options?) Promise<HeartbeatResponse> Validate the secret key against the backend. Returns { projectId, appId, platform, env, serverTime } .
flush() Promise<void> Drain the event queue. Does not throw on flush failure (retries are queued).
shutdown(reason?) void Tear down handlers + clear in-memory state. Tests + custom lifecycle only.
setDebugMode(enabled) void Toggle verbose logging at runtime.
diagnostics() Diagnostics Stable shape regardless of state. See Diagnostics .
isReady() boolean Sync readiness for K8s readiness probes / backpressure.
awaitReady(timeoutMs?, pollMs?) Promise<boolean> Wait until ready or timeout.
getHealth() structured snapshot For /healthz endpoints.
[Symbol.dispose]() void using server = … sync disposal.
[Symbol.asyncDispose]() Promise<void> await using server = … async disposal — flushes before shutdown.

### Identity

Method Returns Notes

identify(userId, anonymousId, options?) Promise<AliasResult> Convenience wrapper around aliasIdentity .
aliasIdentity(input, options?) Promise<AliasResult> Link userId + anonymousId; merge pre-signup events.
forget(hints, options?) Promise<ForgetResult> GDPR right-to-be-forgotten. Server-side deletion.
register(properties) Record<string, unknown> Set process-scoped super-properties. Sanitised at the boundary.
unregister(key) void Remove a single super-property.
getSuperProperties() Record<string, unknown> Snapshot.
group(type, id, traits?) void Set group membership. id: null clears.
getGroups() Record<string, GroupMembership> Snapshot.

### Events

Method Returns Notes

track(event) void Synchronous enqueue. Sanitised + enriched.
ingest(events, options?) Promise<IngestResponse> Immediate POST. Bypasses queue. Caller-controlled idempotency key.
flush() Promise<void> Force-flush.

### Entitlements

Method Returns Notes

getEntitlements(hints, options?) Promise<EntitlementsListResponse> Server fetch + cache warm. Records hint→customerId alias.
getCustomerEntitlements(customerId, options?) Promise<EntitlementsListResponse> Same but takes canonical crossdeckCustomerId .
isEntitled(hint, key) boolean Sync durable-cache read; serves last-known-good. No await .
listEntitlements(hint) PublicEntitlement[] Sync cache snapshot with source / validUntil .
onEntitlementsChange(listener) () => void Subscribe to cache mutations.
syncPurchases(input, options?) Promise<PurchaseResult> Forward StoreKit 2 evidence to Crossdeck.
grantEntitlement(input, options?) Promise<EntitlementMutationResult> Ops grant.
revokeEntitlement(input, options?) Promise<EntitlementMutationResult> Ops revoke.
bulkGrantEntitlement(grants, options?) settled array Client-side fan-out at bounded concurrency.
bulkRevokeEntitlement(revokes, options?) settled array Same.
getAuditEntry(eventId, options?) Promise<AuditEntry> Fetch the rail-event audit row.

### Error capture

Method Returns Notes

captureError(err, options?) void Manual try/catch capture. Options: { context, tags, level } .
captureMessage(msg, level?) void Non-error signal. Default level "info" .
setTag(key, value) void Flat string tag attached to every subsequent error.
setTags(tags) void Bulk-set; merges.
setContext(name, data) void Structured context blob attached to every subsequent error.
addBreadcrumb(crumb) void Add to rolling buffer.
setErrorBeforeSend(hook) void Pre-send filter. Return null to drop.

### Webhooks (standalone exports)

Function Returns Notes

verifyWebhookSignature(payload, header, secret, options?) unknown (parsed JSON) Timing-safe HMAC verify. Throws CrossdeckError on failure.
signWebhookPayload(payload, secret, timestampSec) string (hex) Pure-function signing. For test fixtures.

### Privacy (standalone exports)

Function Returns Notes

scrubPii(value) string Single-string PII regex pass.
scrubPiiFromProperties(properties) Record<string, unknown> Recursive walk; defensive copy.

### Error code helpers (standalone exports)

Export Type Notes

CROSSDECK_ERROR_CODES const dictionary Every error code the SDK / backend can return.
getErrorCode(err) CrossdeckErrorCode | null Extract a typed code from a thrown value.
isCrossdeckErrorCode(value) boolean Type guard for caller code switching on err.code .

### EventEmitter signals

Event Payload When it fires

queue.flush_failed { error, attempt, nextRetryMs } On every failed flush attempt (will retry).
queue.permanent_failure { status, droppedCount, error } A batch was dropped for good (e.g. a 4xx the SDK won't retry).
queue.dropped { count } When the queue drops events at HARD_BUFFER_CAP.
queue.buffer_changed { size } When the buffer size changes.
error.captured { fingerprint, kind, message } When an error is captured (manual or auto).
entitlements.warmed { customerId, count } After getEntitlements() warms the cache.
sdk.shutdown { reason } On shutdown() / [Symbol.dispose] / [Symbol.asyncDispose] .

## Auto-tracked events

The Node SDK doesn't auto-emit lifecycle events from the core class — there's no equivalent of the browser's `page.viewed` / `session.started` on a server (servers don't have sessions; each request is its own context). Auto-events come from the framework adapters and the error tracker.

Event name Source Notable properties

request.handled
Express adapter — fires on response finish or close .
route (matched pattern, not full URL), method , statusCode , durationMs , userAgent , responseBytes

function.invoked
Lambda / Firebase / Cloud Run wrappers — on handler entry.
runtime , coldStart , requestId , functionName , functionVersion , memoryLimitMb , remainingMs

function.completed
Same — on handler success.
runtime , durationMs , memoryUsedMb ; plus statusCode + responseBytes for API Gateway-style returns

function.failed
Same — on handler throw. Error is re-thrown after capture.
runtime , errorType , errorMessage , durationMs

error.unhandled
Every process.on('uncaughtException') .
Stack frames, fingerprint, breadcrumbs, runtime enrichment.

error.unhandledrejection
Every process.on('unhandledRejection') .
Same.

error.http
Every fetch() returning 5xx or failing at network. api.cross-deck.com excluded.
http: { url, method, status, statusText }

error.handled
Emitted by server.captureError() .
Caller-supplied context + tags + level.

error.message
Emitted by server.captureMessage() .
level , message .

### Enrichment attached to every event

Every event and error carries a baseline set of properties auto-attached at `track()` time. You don't have to send any of these explicitly:

- **Runtime**: `runtime.host` (`aws-lambda` / `firebase-functions-v2` / `cloud-run` / `vercel` / `netlify` / `heroku` / `render` / `railway` / `fly` / `kubernetes` / `node`), `runtime.nodeVersion`, `runtime.platform`, `runtime.platformRelease`, `runtime.hostname`, `runtime.region`, `runtime.serviceName`, `runtime.serviceVersion`, `runtime.instanceId`.

- **App**: `appVersion` when passed to the constructor.

- **Super-properties**: every key you've called `register()` for (process-scoped).

- **Groups**: `$groups: { type: id, … }` when `group()` has been called.

Runtime detection runs ONCE per process — the returned object is cached at module level. Zero per-event overhead. Caller overrides (`serviceName` / `serviceVersion` / `appVersion`) win over env-derived values on the constructor's first call.

## Diagnostics & debugging

`server.diagnostics()` returns a stable shape regardless of state — pre-warm values are sensible empties. Use it from your debugger or a `/internal/crossdeck` route to verify the SDK is healthy.

```
{
  sdkVersion: "1.15.1",
  baseUrl: "https://api.cross-deck.com/v1",
  secretKeyPrefix: "cd_sk_test_",
  env: "sandbox",
  runtime: {
    nodeVersion: "20.11.0",
    platform: "linux",
    hostname: "ip-10-0-1-23",
    host: "aws-lambda",
    region: "us-east-1",
    serviceName: "crossdeck-webhook",
    serviceVersion: "v2.3.1",
    instanceId: "2026/05/15/[$LATEST]xxx"
  },
  entitlements: {
    count: 42,             // customers currently cached
    lastUpdated: 1715414410000,
    ttlMs: 60000,         // refresh hint, not an expiry
    staleCustomers: 0,    // cached customers whose last refresh failed / aged past 24h
    isStale: false,        // true iff any cached customer is stale
    durableStore: false,   // true iff an entitlementStore is configured
    coldStartDurable: false, // false = serverless + no store → cold start has an empty cache
    listenerErrors: 0
  },
  events: {
    buffered: 0,
    dropped: 0,
    inFlight: 0,
    lastFlushAt: 1715414411500,
    lastError: null,
    consecutiveFailures: 0,
    nextRetryAt: null
  },
  errors: {
    sessionCount: 3,
    fingerprintsTracked: 2,
    handlersInstalled: true
  }
}
```

Healthy SDK: `events.consecutiveFailures: 0`, `events.nextRetryAt: null`, `errors.handlersInstalled: true`, `runtime.host` matching the platform you expect. On a serverless host, `entitlements.coldStartDurable: false` means a cold container starts with an empty entitlement cache — wire an `entitlementStore` to flip it true. `entitlements.isStale: true` is not a fault on its own (the cache is correctly serving last-known-good) but a persistent `staleCustomers` count means `getEntitlements()` has not been succeeding — check it before trusting an access decision against a recent revoke.

### Debug-mode console signals

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

Signal When it fires

sdk.configured Once at construction.
sdk.first_event_sent Once after the first successful event flush.
sdk.no_identity A track() ran with no identity hint (auto-filled with the process anonymousId).
sdk.sensitive_property_warning An event property name looks PII-shaped ( email , password , token , secret , 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.flush_on_exit_started The beforeExit / SIGTERM drain started.
sdk.flush_on_exit_completed The drain completed (success or timeout).
sdk.entitlement_cache_used An isEntitled() call was a cache hit.
sdk.entitlement_cache_warm A getEntitlements() populated the cache.
sdk.no_durable_store Once at construction — running on a serverless host with no entitlementStore , so a cold start has no entitlement durability. Wire entitlementStore to silence it. See Serverless cold starts .
sdk.super_property_registered register() mutated the super-property bag.
sdk.webhook_verified A webhook signature verified successfully.
sdk.purchase_evidence_sent syncPurchases() succeeded.
sdk.runtime_detected Runtime info detection ran (once per process).
sdk.invalid_key The secret key was rejected at construction (failure path; thrown).
sdk.environment_mismatch Key prefix didn't match declared environment (paranoia path).
sdk.boot_heartbeat_failed The non-blocking boot heartbeat failed (event flushing still works).

### Typed error classes

`CrossdeckError` is the base; specific subclasses let TypeScript narrow on `instanceof`:

- `CrossdeckAuthenticationError` — bad / revoked secret, webhook signature failures.

- `CrossdeckPermissionError` — authenticated but not authorised.

- `CrossdeckValidationError` — caller-side input invalid.

- `CrossdeckRateLimitError` — 429 from the backend; carries `retryAfterMs`.

- `CrossdeckNetworkError` — transport failure (timeout, abort, DNS, TCP).

- `CrossdeckInternalError` — 5xx from the backend.

- `CrossdeckConfigurationError` — SDK misconfiguration (bad key prefix, missing webhook secret).

Every subclass implements `toJSON()` so structured loggers (Pino, Winston) emit the full diagnostic surface — `type`, `code`, `requestId`, `status`, `retryAfterMs`, `stack` — not just the default `name`+`message`+`stack` a plain `Error` would serialise to.

## Troubleshooting

### "Cannot find module @crossdeck/node "

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

### invalid_secret_key thrown at construction

The constructor validated the key prefix before doing anything else. Confirm the key starts with `cd_sk_test_` (sandbox) or `cd_sk_live_` (production). The publishable key (`cd_pub_…`) is for the browser SDK and won't work here.

### Events not appearing — Lambda / Cloud Functions

You're likely missing a flush before return. Wrap your handler with `wrapLambdaHandler` or `wrapFunction` from `@cross-deck/node/auto-events` — both `await server.flush()` in `finally`. If you can't use the wrapper, call `await server.flush()` at the end of your handler manually.

### Events not appearing — long-running Node server

Check `server.diagnostics().events`. `consecutiveFailures > 0` means flushes are hitting an error — inspect `lastError`. `buffered > 0` with healthy flushes means the batch hasn't filled yet; force a flush with `await server.flush()` to verify the path works.

### Webhook signature failures

Make sure you pass the RAW body to `verifyWebhookSignature`, not a JSON-parsed object. In Express, mount `express.raw({ type: "application/json" })` on the webhook route specifically. The signature is computed over the byte-exact body Crossdeck sent — once `express.json()` reformats it, the HMAC won't match.

### webhook_replay_window_exceeded

The webhook timestamp is more than 5 minutes off the receiving server's clock. Either the request is genuinely replayed (rare — verify the source) or the host's clock is skewed. Check NTP on the receiving machine. If you need a wider window for testing, pass `{ replayToleranceMs: }` to `verifyWebhookSignature`.

### isEntitled() returns false in a hot path

It is **not** a TTL expiry — the cache never expires a warm customer to `false`; past the refresh hint it keeps serving last-known-good. Real causes:

- **Cold miss — the customer was never warmed.** Confirm `await server.getEntitlements({ userId })` ran at least once for *this* customer in *this* process. Inspect `diagnostics().entitlements.count` — it should be > 0.

- **Serverless cold start with no `entitlementStore`.** A fresh Cloud Run / Lambda container starts with an empty cache. Check `diagnostics().entitlements.coldStartDurable` — if `false`, wire an `entitlementStore` so a cold container loads last-known-good. See [Serverless cold starts](#durability).

- **The entitlement genuinely lapsed.** Its `validUntil` passed — a trial or non-renewing period ended. `isEntitled()` honours each entitlement's own expiry; this is correct, not a cache fault.

- **Mismatched identity hint.** The `getEntitlements()` warm and the `isEntitled()` read must resolve to the same customer — a different hint reads an unwarmed entry.

### 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 `server.diagnostics().errors.handlersInstalled`. `false` means error capture was disabled (`errorCapture: false` at construction). Sentry's own `process.on('uncaughtException')` hook does NOT prevent ours from running — both can capture the same error.

### Boot heartbeat failures in logs

The boot heartbeat is fire-and-forget and failure is non-fatal — events still flush, error capture still works. If you don't want the diagnostic line in your logs, pass `bootHeartbeat: false` at construction. The dashboard's "Verify install" surface will then only flip LIVE on the first event flush instead of within 200 ms of boot.

### SIGTERM doesn't drain the queue on Cloud Run

Confirm `flushOnExit: true` (the default). If you've registered your own SIGTERM handler that calls `process.exit()` directly, the SDK's handler won't get a chance to run — chain through it or remove your handler and let the SDK's drain run. The drain is bounded by `flushOnExitTimeoutMs` (default 2000 ms); if your queue is larger than that can drain, increase the timeout.

## Deploying: the gate must ship in your runtime bundle

`@cross-deck/node` is your server-side gate — entitlements, Crossdeck Trust / blocking, the bouncer at your door. It is imported at server boot and **must be present in the runtime artifact when your server starts**.

**The trap.** Some deploy pipelines assemble a *serving bundle* and copy only the files they think the entry point needs — Firebase App Hosting, Next.js `output: "standalone"`, and esbuild / webpack builds that mark packages `external`. If `@cross-deck/node` isn't traced into that bundle, the container boots, the `import` throws `MODULE_NOT_FOUND`, and Node exits **before it listens on its port** — the deploy fails its health check and the site never goes live.

Never "fix" a bundling MODULE_NOT_FOUND by making the import optional.

Wrapping it in `try/catch`, lazy-loading it, or marking it optional makes the crash disappear — and silently ships a server with **no gate**: entitlements unresolved, blocked users let in, Trust off. A crash is loud and safe; a disabled gate is invisible and worse. The SDK has **zero runtime dependencies**, so there is never a bundling reason to drop it.

**The fix — guarantee it ships in the runtime artifact:**

- **Firebase App Hosting / a custom `server.mjs`:** bundle `@cross-deck/node` *into* the server artifact (e.g. esbuild without listing it in `external`) so it is self-contained when Cloud Run starts.

- **Next.js (`standalone`):** add `@cross-deck/node` to `serverExternalPackages` so it is copied into the standalone output instead of being bundled-and-broken.

- **esbuild / webpack:** do **not** put `@cross-deck/node` in `external`; let it bundle in (zero deps — it bundles cleanly).

- **Verify** it landed: `node_modules/@cross-deck/node` present in the deployed image (or inlined into the server file) *before* you cut traffic over.

The rule: **the gate ships with the server, or the server doesn't ship.**

## Versioning & changelog

The SDK follows semantic versioning. The current version and full release history live in the [changelog](https://cross-deck.com/docs/changelog/); the `SDK_VERSION` constant derives from `package.json` so the wire header can never drift from the published bundle.

Version Highlights

1.3.0 KPMG bank-grade audit closure. PII scrub sentinel tokens aligned with the backend ( <email> / <card> ). setErrorBeforeSend contract cleaned up to a getter — removed the Object.defineProperty workaround that compensated for the same broken contract on web. Event queue drops 4xx batches (401 / 400 / 403 / 404 etc.) and fires onPermanentFailure + queue.permanent_failure EventEmitter signal + loud console.error — pre-fix the queue retried 4xx forever silently with the same Idempotency-Key. Error-capture self-skip derives from baseUrl (closes the recursive-capture loop for customers on staging / regional / self-hosted base URLs). Ingest envelope now ships environment — backend cross-checks against the API-key env and rejects env_mismatch loudly. syncPurchases body spread bug fixed (explicit rail: undefined no longer overrides the "apple" default). bootHeartbeat: false no longer silences the sdk.no_durable_store warning. isEntitled(string) requires cdcust_ prefix for canonical-path resolution (closes a small cross-tenant primitive). Backend-paired: v1-events ingest now honours per-project piiAllowList via a 60s TTL cache (defence-in-depth scrub).
1.2.0 Pluggable durable entitlementStore for serverless cold-start durability. diagnostics() exposes coldStartDurable / durableStore / staleCustomers . sdk.no_durable_store debug signal for serverless-with-no-store. entitlementCacheTtlMs repurposed as a refresh hint instead of a hard invalidation.
1.0.0 All three USPs land server-side. Errors : captureError / captureMessage / setTag / setContext / addBreadcrumb / setErrorBeforeSend + auto-wired uncaughtException + unhandledRejection + globalThis.fetch wrap, fingerprint dedup, per-fingerprint rate limit, per-session cap. Analytics : durable retried idempotent event queue, flush-on-exit drain ( beforeExit + SIGTERM + SIGINT), super-properties ( register / unregister ), group analytics ( group ), framework adapters ( crossdeckExpress , wrapLambdaHandler , wrapFunction ) at the @cross-deck/node/auto-events subpath, runtime enrichment (host / region / serviceName / serviceVersion / instanceId / coldStart). Entitlements : per-customer durable last-known-good cache so isEntitled() is a synchronous memory read after warm and rides out a Crossdeck outage, pluggable entitlementStore for serverless cold-start durability, onEntitlementsChange subscriber API. Cross-cutting : webhook signature verification ( verifyWebhookSignature + signWebhookPayload , Stripe-compatible HMAC + 5-minute replay defence + secret-array rotation), typed CrossdeckError subclasses with toJSON() , boot heartbeat for dashboard "Verify install", TC39 using / await using disposal, K8s-ready isReady() / getHealth() .

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

## Related

- [Web SDK reference](https://cross-deck.com/docs/web-sdk/) — the browser counterpart. Same dashboard, same event shape, same auth pair (publishable + secret).

- [API keys & authentication](https://cross-deck.com/docs/api-keys/) — publishable vs secret, key rotation, environment prefixes.

- [Source maps](https://cross-deck.com/docs/source-maps/) — upload sourcemaps so stack frames in error reports resolve to original TypeScript line numbers.

- [Create a project](https://cross-deck.com/docs/create-a-project/) — the dashboard flow that produces the `secretKey` you pass to the constructor.

- [Connect Stripe](https://cross-deck.com/docs/connect-stripe/) — wire your Stripe account so subscription state becomes available via `getEntitlements()` and rail-event webhooks.

- [Source on GitHub](https://github.com/VistaApps-za/crossdeck) — the SDK lives under `sdks/node/`. SDK_TRUTH.md is the canonical contract reference.

Last updated when `@cross-deck/node@1.10.0` shipped (May 15, 2026). Future versions are documented in the table above as they publish to npm.
