Crossdeck Docs
Dashboard

@cross-deck/node — Node SDK reference

Reference Current version: 1.5.3 · ~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

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.

RuntimeInstall pathWhere the SDK lives
Node.js (18+) — Express / Fastify / Koa / Hono / NestJSnpm install @cross-deck/nodeApp entry / DI container; single CrossdeckServer per process
AWS Lambda (Node 18+ / 20+)npm install @cross-deck/nodeModule-level new CrossdeckServer({...}) + wrapLambdaHandler(server, handler)
Vercel Serverless Functions (Node)npm install @cross-deck/nodePer-route file; rely on flush-on-exit drain
Firebase Cloud Functions (v1 + v2)npm install @cross-deck/nodeModule entry + wrapFunction(server, handler) per export
Google Cloud Run / Cloud Run Functionsnpm install @cross-deck/nodeProcess-level new CrossdeckServer({...}); wrapFunction for triggered handlers
Bun (≥ 1.0)bun add @cross-deck/nodeSame as Node — Bun ships a compatible process + fetch
Deno (compat mode)npm:@cross-deck/nodeUse 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:

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.

Idempotency & lifecycle

The constructor is synchronous. By the time it returns:

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:

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" }
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.<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:

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:

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:

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:

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

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

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:

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.

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:

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:

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

FlagDefaultWhen to override
enabledtrueMaster switch.
onUncaughtExceptiontrueDisable if another error tracker already installs this handler.
onUnhandledRejectiontrueSame — Sentry, Datadog, etc. also hook this.
wrapFetchtrueDisable if you have a separate HTTP instrumentation layer.
captureConsolefalseOpt-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.
denyPathsSDK self-skipAdd paths whose top frames you don't want captured (e.g. a noisy third-party module).
sampleRate1.0Per-fingerprint deterministic sampling — 0.5 = half of distinct errors, no flapping.
maxPerFingerprintPerMinute5Defends against runaway loops.
maxPerSession100Hard 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

MethodReturnsNotes
new CrossdeckServer(options)CrossdeckServerBoot. 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?)voidTear down handlers + clear in-memory state. Tests + custom lifecycle only.
setDebugMode(enabled)voidToggle verbose logging at runtime.
diagnostics()DiagnosticsStable shape regardless of state. See Diagnostics.
isReady()booleanSync readiness for K8s readiness probes / backpressure.
awaitReady(timeoutMs?, pollMs?)Promise<boolean>Wait until ready or timeout.
getHealth()structured snapshotFor /healthz endpoints.
[Symbol.dispose]()voidusing server = … sync disposal.
[Symbol.asyncDispose]()Promise<void>await using server = … async disposal — flushes before shutdown.

Identity

MethodReturnsNotes
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)voidRemove a single super-property.
getSuperProperties()Record<string, unknown>Snapshot.
group(type, id, traits?)voidSet group membership. id: null clears.
getGroups()Record<string, GroupMembership>Snapshot.

Events

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

Entitlements

MethodReturnsNotes
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)booleanSync durable-cache read; serves last-known-good. No await.
listEntitlements(hint)PublicEntitlement[]Sync cache snapshot with source / validUntil.
onEntitlementsChange(listener)() => voidSubscribe 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 arrayClient-side fan-out at bounded concurrency.
bulkRevokeEntitlement(revokes, options?)settled arraySame.
getAuditEntry(eventId, options?)Promise<AuditEntry>Fetch the rail-event audit row.

Error capture

MethodReturnsNotes
captureError(err, options?)voidManual try/catch capture. Options: { context, tags, level }.
captureMessage(msg, level?)voidNon-error signal. Default level "info".
setTag(key, value)voidFlat string tag attached to every subsequent error.
setTags(tags)voidBulk-set; merges.
setContext(name, data)voidStructured context blob attached to every subsequent error.
addBreadcrumb(crumb)voidAdd to rolling buffer.
setErrorBeforeSend(hook)voidPre-send filter. Return null to drop.

Webhooks (standalone exports)

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

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

Error code helpers (standalone exports)

ExportTypeNotes
CROSSDECK_ERROR_CODESconst dictionaryEvery error code the SDK / backend can return.
getErrorCode(err)CrossdeckErrorCode | nullExtract a typed code from a thrown value.
isCrossdeckErrorCode(value)booleanType guard for caller code switching on err.code.

EventEmitter signals

EventPayloadWhen 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 nameSourceNotable 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 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.

SignalWhen it fires
sdk.configuredOnce at construction.
sdk.first_event_sentOnce after the first successful event flush.
sdk.no_identityA track() ran with no identity hint (auto-filled with the process anonymousId).
sdk.sensitive_property_warningAn event property name looks PII-shaped (email, password, token, secret, etc.).
sdk.property_coercedA property was coerced (Date → ISO, BigInt → string, etc.) during validation.
sdk.flush_retry_scheduledA flush failed and a retry was scheduled.
sdk.flush_on_exit_startedThe beforeExit / SIGTERM drain started.
sdk.flush_on_exit_completedThe drain completed (success or timeout).
sdk.entitlement_cache_usedAn isEntitled() call was a cache hit.
sdk.entitlement_cache_warmA getEntitlements() populated the cache.
sdk.no_durable_storeOnce 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_registeredregister() mutated the super-property bag.
sdk.webhook_verifiedA webhook signature verified successfully.
sdk.purchase_evidence_sentsyncPurchases() succeeded.
sdk.runtime_detectedRuntime info detection ran (once per process).
sdk.invalid_keyThe secret key was rejected at construction (failure path; thrown).
sdk.environment_mismatchKey prefix didn't match declared environment (paranoia path).
sdk.boot_heartbeat_failedThe non-blocking boot heartbeat failed (event flushing still works).

Typed error classes

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

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:

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.

VersionHighlights
1.3.0KPMG 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.0Pluggable 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.0All 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.


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