@cross-deck/node — Node SDK reference
@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-exithooksbeforeExit+SIGTERM+SIGINTto 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 anentitlementStorefor cold-start durability. - Auto-wired error capture. Constructing the SDK installs
process.on('uncaughtException')+process.on('unhandledRejection')handlers and wrapsglobalThis.fetchfor 5xx + network failures. ManualcaptureError/captureMessageshare 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.CrossdeckErrorclasses implement Stripe-style typed subclasses andtoJSON()so structured loggers seetype+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, andinstanceIdauto-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.
@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 ateventFlushBatchSize(20) oreventFlushIntervalMs(1500 ms), whichever fires first. flush-on-exitdrain onbeforeExit+SIGTERM+SIGINTso a SIGTERM from Cloud Run doesn't lose queued events.process.on('uncaughtException')+process.on('unhandledRejection')handlers installed — every uncaught error becomes anerror.unhandled/error.unhandledrejectionevent with stack frames, breadcrumbs, and runtime enrichment.globalThis.fetchwrapped — any 5xx response or network failure from your outbound HTTP becomes anerror.httpevent automatically. Crossdeck's own API calls are excluded to prevent cycle-amplification.- A boot heartbeat fires on
setImmediateafter construction so the dashboard's "Verify install" row flips LIVE within ~200 ms of process start, no explicitawaitneeded. - Runtime enrichment — every event/error carries
runtime.host(e.g.aws-lambda),runtime.region,runtime.serviceName,runtime.serviceVersion,runtime.instanceIdauto-detected from env vars (K_SERVICEon Cloud Run /AWS_LAMBDA_FUNCTION_NAMEon Lambda /FUNCTION_NAMEon 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.
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.
Idempotency & lifecycle
The constructor is synchronous. By the time it returns:
- Secret key prefix is validated; throws
CrossdeckErroron 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
setImmediateso the constructor returns first (unlesstestMode: trueorbootHeartbeat: 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 canonicalcdcust_…identifier returned from a prioridentify()/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: "[email protected]",
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" }
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.<type>: 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.
Dateobjects become ISO strings.BigIntvalues become strings.Errorobjects become{ name, message, stack }.Mapbecomes a plain object,Setbecomes an array.- Strings longer than 1024 characters are truncated with an ellipsis.
- Circular references are replaced with
"[circular]". - Objects nested deeper than 5 levels become
"[depth-exceeded]". - If the property bag exceeds 8 KB total, the largest fields drop first and a
__truncated: truemarker is added. - The caller's input object is never mutated — sanitisation always produces a defensive copy.
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
droppedcounter increments (surfaced indiagnostics().events.droppedand on thequeue.droppedevent). - Retry policy: exponential backoff with full jitter on flush failures. Honours server
Retry-Afterheaders parsed ontoCrossdeckError.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 ClickHouseReplacingMergeTreeoneventId— 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_succeeded,queue.flush_failed,queue.dropped,queue.buffer_changedfor 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.
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.
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 tostore.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-asyncgetEntitlements(). 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));
},
},
});
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 becomeserror.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:
sampleRatein[0, 1]. Deterministic per fingerprint — a given fingerprint always either always sends or never does, no flapping. - Fingerprint window cap: the rate-limit window
Mapis 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=<unix-seconds>,v1=<hex> 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 on:
webhook_invalid_signature— missing / malformed header, HMAC mismatch, or non-JSON payload after verification (tampered).webhook_missing_secret— empty / undefined secret passed.webhook_replay_window_exceeded— timestamp outside the replay window.
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.
Pass { replayToleranceMs: 0 } to disable (NOT recommended — only do this if you have a separate replay defence).
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— defaulttrue; attaches{ url, method, route }ascontext.requeston 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.invokedon entry —requestId,functionName,functionVersion,coldStart,memoryLimitMb,remainingMs.function.completedon success —durationMs,memoryUsedMb, plusstatusCode+responseBytesfor API Gateway / Function URL handlers that return{ statusCode, body }.function.failedon 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<string, unknown>
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_succeeded | { batchSize, durationMs } | Per batch on successful flush. |
queue.flush_failed | { error, attempt, nextRetryMs } | On every failed flush attempt. |
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:
appVersionwhen passed to the constructor. - Super-properties: every key you've called
register()for (process-scoped). - Groups:
$groups: { type: id, … }whengroup()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.5.3",
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; carriesretryAfterMs.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. Inspectdiagnostics().entitlements.count— it should be > 0. - Serverless cold start with no
entitlementStore. A fresh Cloud Run / Lambda container starts with an empty cache. Checkdiagnostics().entitlements.coldStartDurable— iffalse, wire anentitlementStoreso a cold container loads last-known-good. See Serverless cold starts. - The entitlement genuinely lapsed. Its
validUntilpassed — 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 theisEntitled()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.
Versioning & changelog
The SDK follows semantic versioning. The current version is 1.3.0 — KPMG bank-grade audit closure (every P0 + 12 of 13 P1 findings) plus the SDK_VERSION constant now 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 — the browser counterpart. Same dashboard, same event shape, same auth pair (publishable + secret).
- API keys & authentication — publishable vs secret, key rotation, environment prefixes.
- Source maps — upload sourcemaps so stack frames in error reports resolve to original TypeScript line numbers.
- Create a project — the dashboard flow that produces the
secretKeyyou pass to the constructor. - Connect Stripe — wire your Stripe account so subscription state becomes available via
getEntitlements()and rail-event webhooks. - Source on GitHub — the SDK lives under
sdks/node/. SDK_TRUTH.md is the canonical contract reference.
Last updated when @cross-deck/[email protected] shipped (May 15, 2026). Future versions are documented in the table above as they publish to npm.