Crossdeck Docs
Dashboard

Server-side event ingestion — events your client SDK can't fire

Pattern Applies to: @cross-deck/node + raw HTTP · ~18 min read · Updated May 15, 2026

A meaningful share of every customer's timeline can only be reconstructed from the server. Stripe payment confirmations, ESP click redirects, cron-driven renewals, admin overrides, SSR-rendered pages — none of these run inside the browser, so the web SDK never sees them. This doc covers when to fire from the server, how to do it through @cross-deck/node, when raw HTTP is the right call, and — most importantly — how server-side events link back to the same crossdeckCustomerId as the events your client SDK is already firing.

TL;DR

This is a patterns doc, not the API reference.

The Node SDK reference documents every option, return shape, and error code. This doc shows when you'd reach for those primitives and how the moving parts fit together — webhook receivers, scheduled work, email-click attribution, identity continuity across browser + server. If you're after the literal type signatures, the SDK reference is the source of truth.

When to send events from the server

The default mental model — "fire all events from the client" — falls over the moment the action that matters doesn't happen in a browser tab. Five concrete scenarios you'll hit:

(a) Third-party webhooks — the payment is captured server-side

Stripe fires payment_intent.succeeded to your /webhooks/stripe endpoint after the customer's bank confirms the charge. By the time you hear about it, the customer may have closed the tab, lost network, navigated away, or just be waiting on a redirect. Firing purchase_completed from the client at the moment they click "Pay" is hopeful telemetry — half the time the charge actually fails downstream and you've already counted revenue. Fire it from the webhook receiver instead, after Stripe says the money moved.

// /webhooks/stripe — fired by Stripe, not by the browser
switch (event.type) {
  case "payment_intent.succeeded": {
    const pi = event.data.object;
    crossdeck.track({
      name: "purchase_completed",
      developerUserId: pi.metadata.userId,
      properties: {
        amount: pi.amount,
        currency: pi.currency,
        stripePaymentIntentId: pi.id,
      },
      // Stripe replays this webhook if our 2xx is slow — same
      // event.id ensures Crossdeck dedupes the replay.
      eventId: `stripe_${event.id}`,
    });
    break;
  }
}

(b) Marketing email clicks — the server resolves the user before they hit your site

A customer clicks a link in your re-engagement email. The link goes through your redirect endpoint (/r?u=<signedUserId>&c=<campaign>), which logs the click, attributes the campaign, and 302s them to the destination. By the time the browser SDK boots on the landing page, the click is already in the past — and the user might not even be logged in yet. Fire email_link_clicked from the redirect handler, with developerUserId resolved from the signed query param. See the full pattern in Marketing email click attribution below.

(c) Cron jobs & scheduled work — nothing client-side ever runs

A nightly Cloud Function checks for subscriptions renewing in 24h and fires subscription_renewal_processed for each one. No browser, no client SDK, no session — the event is purely server-driven. Same shape applies to: trial-expiry warnings, weekly summary emails sent, dunning state transitions, daily report generation. If it's cron.yaml or a Cloud Scheduler trigger, the event has to come from the server.

(d) Server-only state changes — admin overrides, ticket resolutions, programmatic grants

Your support team grants a free month from the admin console; an ops engineer resolves a support ticket; a workflow auto-tags a churn-risk account. The customer never sees the action — it happens entirely in your internal tools. Fire admin_grant_applied / support_ticket_resolved / churn_tag_applied from the server, and the customer timeline gets the full picture.

(e) SSR'd pages — the SDK hasn't booted yet

A Next.js App Router page renders server-side at /dashboard with the user's plan, recent activity, and personalised pricing. The render itself is a meaningful customer-journey event ("the dashboard was actually delivered, not just requested") — but the client SDK won't page.viewed until hydration finishes, which may be hundreds of ms later and won't fire at all on bot-skipped renders. Fire dashboard_rendered from the server component, identifying with the authenticated user. The browser SDK still fires its own page.viewed on hydration; the server event is the canonical one for "we definitely served this page."

Rule of thumb.

If the event would land in a "we don't actually know if the action happened" column when only the client fires it — the action is authoritative from the server's perspective — fire it from the server. If both client and server can see the action (e.g. a button click that calls an API), fire from one side only, never both. See anti-pattern (a) below.

The Node SDK path

For Node 18+ services — Express, Fastify, Next.js API routes, Cloud Functions, Lambda, Cloud Run, plain TS scripts — the default is @cross-deck/node. The SDK encodes every operational concern that a server-side telemetry pipeline needs to handle, and gets the defaults right for serverless deployment.

npm install @cross-deck/node

Initialise once per process (not per request — see Cron & scheduled work for why) and call track() wherever you'd otherwise log:

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

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

export async function handlePurchase(req, res) {
  // ... charge logic ...
  crossdeck.track({
    name: "purchase_completed",
    developerUserId: req.user.id,
    properties: { amount: 1900, currency: "usd" },
  });
  res.status(200).json({ ok: true });
}

That single track() call — synchronous, fire-and-forget — gives you:

For the full set of init options, return shapes, and method signatures, see the Node SDK reference. The rest of this doc covers patterns built on top of these primitives.

The raw HTTP path

Sometimes you can't add a dependency. Legacy PHP shared host, a Go binary you don't own the deploy of, a Ruby app on a shared host without npm, a Bash cron job. Crossdeck's ingest endpoint is plain HTTP + JSON — call it from anything that speaks TLS.

Endpoint shape

POST https://api.cross-deck.com/v1/events
Authorization: Bearer cd_sk_live_xxxxxxxxxxxx
Content-Type: application/json
Idempotency-Key: batch_a1b2c3d4e5f6                # strongly recommended
Crossdeck-Sdk-Version: [email protected]         # identifies your caller in our logs

{
  "events": [
    {
      "eventId": "evt_local_abc123",
      "name": "purchase_completed",
      "timestamp": 1717891200000,
      "properties": { "amount": 1900, "currency": "usd" },
      "developerUserId": "user_847"
    }
  ]
}

Required per-event fields: name (non-empty), and exactly one of developerUserId, anonymousId, or crossdeckCustomerId (see Identity linking). Strongly recommended: a stable eventId per event for server-side dedup, and timestamp (ms epoch) for the actual moment the event occurred — defaults to the server's receipt time otherwise.

curl example

curl -X POST https://api.cross-deck.com/v1/events \
  -H "Authorization: Bearer $CROSSDECK_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: batch-$(uuidgen)" \
  -H "Crossdeck-Sdk-Version: [email protected]" \
  -d '{
    "events": [
      {
        "eventId": "evt_'$(uuidgen)'",
        "name": "support_ticket_resolved",
        "timestamp": '$(date +%s000)',
        "developerUserId": "user_847",
        "properties": { "ticketId": "T-12345", "resolutionMinutes": 42 }
      }
    ]
  }'

Wire limits

LimitValueBehaviour on breach
Events per batch1–100400 invalid_param_value with the first failing index
Per-event properties size (JSON-serialised)8 KB400 invalid_param_value on the offending event
Request body size1 MB413 from the platform; chunk into smaller batches
developerUserId length1–256 chars400 invalid_param_value
Per-project rate limitToken-bucket, default ~1000 events/sec sustained with burst429 with Retry-After

Responses

Success is 202 Accepted with { "object": "list", "received": <n>, "env": "production" | "sandbox" }. The 202 means the batch was queued for asynchronous indexing — identity resolution and rollup happens in a background worker, so events appear in the dashboard within a few seconds of ingest. Common failure modes:

You handle retry and idempotency yourself on this path.

The Node SDK does both for free. On raw HTTP you're responsible for: (1) generating a stable Idempotency-Key per batch and re-using it on retry, (2) backing off on 429/5xx with jitter, (3) batching small writes together so you don't pummel the endpoint with one-event batches, and (4) draining anything in-flight before your process exits. If your stack could use the Node SDK, the operational cost difference is significant — use it.

Authentication — secret vs publishable

Crossdeck has two API key types and they're not interchangeable for server-side ingest.

Key typePrefixWhere it goesCan ingest events with arbitrary identity?
Publishablecd_pub_test_ / cd_pub_live_Browser bundles, mobile apps, anywhere the bundle ships to a user deviceNo — limited to the device's own anonymousId; origin-allowlisted on web keys
Secretcd_sk_test_ / cd_sk_live_Server processes only — Node, Cloud Functions, Lambdas, cron jobs, webhook receiversYes — can fire events with any developerUserId, any anonymousId, or any crossdeckCustomerId

Server-side ingest must use a secret key. The reason is structural, not just policy:

// Wrong — publishable key on the server. Will 403 on every request.
const crossdeck = new CrossdeckServer({
  secretKey: "cd_pub_live_xxxxxxxxxxxx",  // constructor throws — must start with cd_sk_
});

// Right — secret key from your secret manager.
const crossdeck = new CrossdeckServer({
  secretKey: process.env.CROSSDECK_SECRET_KEY!,
});

The Node SDK validates the key prefix at construction time — passing a cd_pub_* key throws CrossdeckError({ code: "invalid_secret_key" }) at new CrossdeckServer({...}), so misconfiguration surfaces at boot instead of at first event. The same key prefix determines environment: cd_sk_test_* writes to your sandbox project, cd_sk_live_* writes to production. There's no environment option on the server SDK — the key is the source of truth.

Secret keys belong in a secret manager, not in your repo.

Google Cloud Secret Manager, AWS Secrets Manager, HashiCorp Vault, GitHub Actions encrypted secrets — anywhere that isn't a .env committed to git. Crossdeck's dashboard lists every secret key by its prefix + last-rotated date so you can audit usage; rotating is two clicks and a redeploy.

Identity linking — keeping the timeline continuous

Every event in Crossdeck attributes to exactly one customer (crossdeckCustomerId, abbreviated cdcust_*) — the canonical identity behind which all rails, events, errors, and entitlements coalesce. The only question that matters for server-side ingest is: which cdcust does this event belong to?

The Crossdeck backend resolves this from one of three hints you can attach to each event:

FieldSet byWhen to use
developerUserIdYour auth systemYou've already identified the user — JWT, session, signed query param, Stripe metadata
anonymousIdThe web SDK (auto-minted on first boot, format anon_*)The same visitor's browser fired identify() earlier — you want to maintain their timeline before login
crossdeckCustomerIdReturned by Crossdeck after identify() / webhook reconciliationYou stored the cdcust on your user record and want to skip the resolution step

Server-side, you'll hit one of three modes depending on what the server already knows about the customer.

Mode A — known userId: the server resolved the user already

Your auth middleware extracted the user from a JWT / session cookie. You have their stable application user ID. Fire the event with developerUserId:

crossdeck.track({
  name: "checkout_started",
  developerUserId: req.user.id,        // e.g. "user_847"
  properties: { cartTotal: 4200 },
});

Crossdeck attributes to that customer immediately. If the userId is brand new (no prior alias), this is the first identification — Crossdeck mints a fresh cdcust. If a previous client-side identify(userId, anonymousId) already aliased this user, the event lands on the existing cdcust and merges with their browser timeline.

Mode B — known anonymousId from the client

The user isn't logged in yet (no userId), but the browser SDK minted an anonymousId on first page load. You can forward it to the server via cookie or header, then re-use it for server-side events that the same visitor caused:

// Client-side: the SDK exposes it via getState()
const anonId = Crossdeck.getState().anonymousId;
// → "anon_mp10knb…"

// Forward to server (e.g. as a cookie set on every page response,
// or in a fetch header for an XHR request):
fetch("/api/abandon-cart", {
  method: "POST",
  headers: { "X-Anon-Id": anonId },
});

// Server-side handler:
const anonId = req.headers["x-anon-id"];
crossdeck.track({
  name: "server_abandon_detected",
  anonymousId: anonId,
  properties: { cartId: req.body.cartId },
});

The event lands on the same cdcust the browser's events are landing on — the timeline stays unbroken. Critical for funnels that span "anonymous visitor browses → server detects abandon → user signs up later," because Crossdeck back-attributes everything once the userId arrives.

Mode C — both: the server fires identify() and pins the alias

The strongest pattern. When the server learns the user's userId (e.g. they completed signup, the email-click resolved their identity), fire identify() first with both the userId and the anonymousId the browser established earlier. Crossdeck aliases the two — all past anonymous events get back-attributed to the new cdcust:

await crossdeck.identify(
  "user_847",           // developer userId — the one from your auth system
  "anon_mp10knb…",     // the anonymousId the browser minted earlier
  { email: "[email protected]" },
);

// All subsequent events on EITHER hint land on the same cdcust.
crossdeck.track({
  name: "signup_completed",
  developerUserId: "user_847",
});

Unlike track(), identify() is a direct HTTP call (it's a transactional aliasing operation, not telemetry — see Identify users for the full model). It returns once the alias is durable. The browser SDK's next call automatically picks up the new cdcust via the entitlement cache populate-and-subscribe pattern.

The wrong way — events without identity

Firing track() from the server with no developerUserId / anonymousId / crossdeckCustomerId hint is the most common server-side mistake. The events don't fail: they land in your project's events stream, they bump dashboard totals, they're queryable in raw SQL. But they never attribute to a customer — they're orphan events. From the customer-360 view, the user simply didn't do that action.

The Node SDK protects against this for events triggered from a captureError() path (which has no per-request context) by auto-filling with a processAnonymousId minted per-process. But for explicit track() calls, the responsibility is yours — always attach one of the three hints. The raw HTTP endpoint rejects events with zero identity hints at validation (400 invalid_param_value with code missing_identity_hint).

"Exactly one" hint per event is enforced for clarity, not correctness.

The endpoint validates that you supply exactly one identity field per event. Supplying multiple isn't dangerous — the backend would resolve them deterministically — but a single hint per event keeps your code readable when you read events back six months later in a support investigation. If you need to alias two identifiers, that's identify(userId, anonymousId)'s job, not track()'s.

Idempotency

Server-side telemetry has more replay opportunities than client-side. Webhooks redeliver. Lambda handlers retry. Queue workers re-process messages after a crash. Without idempotency, every retry doubles a metric. Crossdeck's ingest endpoint dedupes on two layers:

Node SDK — automatic

The SDK generates an Idempotency-Key per batch (format batch_*) and reuses it across retries of the same batch. Every event also gets an auto-generated eventId (format evt_*) before enqueue. You can override per-event when you need replay safety to span the source event ID:

crossdeck.track({
  name: "purchase_completed",
  developerUserId: pi.metadata.userId,
  // Use Stripe's event.id as the upstream replay key.
  // If Stripe redelivers this webhook hours later, we re-fire with the
  // same eventId; Crossdeck's per-event dedup absorbs the duplicate.
  eventId: `stripe_${event.id}`,
  properties: { amount: pi.amount, currency: pi.currency },
});

For the ingest() method (sync bulk-import variant — no queue, no batching), the SDK auto-generates a batch Idempotency-Key unless you override it via options.idempotencyKey. This is the right hook for bulk-replay-this-batch-of-events flows: pin the key to something stable across re-runs.

Raw HTTP — manual

Generate a UUID per logical batch, send it in the Idempotency-Key header, and reuse the same key on every retry of that batch. Generate an eventId per event and reuse it on retries too. The combination ensures replays at either the batch or the event level are absorbed:

// Pseudocode for a Go webhook receiver retrying on transient 5xx.
batchKey := uuid.New().String()
for attempt := 1; attempt <= 5; attempt++ {
    resp := postEvents(events, batchKey)
    if resp.StatusCode < 500 { break }
    time.Sleep(jitteredBackoff(attempt))
}

The pattern that matters: derive your idempotency key from the upstream event ID when you have one (Stripe event.id, SQS message ID, Kafka offset). That way the replay safety extends all the way up to the source — you can replay an entire day of Stripe webhooks through your receiver and Crossdeck dedupes every single one.

Webhook receiver pattern

Webhook receivers are the canonical server-side telemetry path. Stripe / Apple / Google Play fire a signed webhook → your endpoint verifies the signature → you switch on the event type → you fire one or more Crossdeck events → you return 2xx.

Full Express pattern (Stripe)

import express from "express";
import Stripe from "stripe";
import { CrossdeckServer } from "@cross-deck/node";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const crossdeck = new CrossdeckServer({
  secretKey: process.env.CROSSDECK_SECRET_KEY!,
});

const app = express();

// Stripe sends raw bytes — mount express.raw for THIS route specifically.
app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const sig = req.headers["stripe-signature"] as string;

    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET!,
      );
    } catch (err) {
      return res.status(400).send(`Webhook signature failed: ${err}`);
    }

    switch (event.type) {
      case "payment_intent.succeeded": {
        const pi = event.data.object as Stripe.PaymentIntent;
        crossdeck.track({
          name: "purchase_completed",
          developerUserId: pi.metadata.userId,
          eventId: `stripe_${event.id}`,
          properties: {
            amount: pi.amount,
            currency: pi.currency,
            stripePaymentIntentId: pi.id,
          },
        });
        break;
      }

      case "customer.subscription.deleted": {
        const sub = event.data.object as Stripe.Subscription;
        crossdeck.track({
          name: "subscription_cancelled",
          developerUserId: sub.metadata.userId,
          eventId: `stripe_${event.id}`,
          properties: { stripeSubscriptionId: sub.id },
        });
        break;
      }
    }

    // Flush before returning — important on serverless because the
    // process may be frozen the moment we 200. On a long-running
    // Express server you could rely on the batched queue + interval flush.
    await crossdeck.flush();
    res.status(200).send();
  },
);

What's load-bearing in this code

One Crossdeck event per logical state change.

It's tempting to fan out one Stripe webhook to four Crossdeck events ("payment_initiated," "payment_pending," "payment_succeeded," "payment_completed"). Resist. A Stripe payment_intent.succeeded is one state change; emit one event. The dashboard's funnel UI joins events into journeys later — having one canonical event per transition makes the funnels readable.

Server-side identify

The Node SDK exposes identify(userId, anonymousId, traits) for server-driven aliasing. Same conceptual purpose as the web SDK's Crossdeck.identify(userId), but with explicit control over the anonymousId being aliased — because on the server you have to provide it; nothing's auto-stored.

await crossdeck.identify(
  "user_847",                   // stable application user ID
  "anon_mp10knb…",             // the anonymousId from the browser SDK
  {
    email: "[email protected]",
    traits: {
      plan: "free",
      signupSource: "email",
    },
  },
);

When server-side identify is the right call

If the only identification path is "the user signs in on the browser, then we know who they are" — you don't need server-side identify() at all. The browser SDK handles that case end-to-end. Server-side identify is for the cases where the server learns about the user before the browser has a chance to.

Unlike client-side identify(), server-side identify() returns a Promise<AliasResult> — it's a direct HTTP call, not queued. The AliasResult tells you the resolved crossdeckCustomerId, which identifiers got linked, and whether a background merge is pending (when the same user surfaces under multiple rails before reconciliation). Store the crossdeckCustomerId on your user record if you want to skip the resolution step on subsequent events — use crossdeckCustomerId as the identity hint instead of developerUserId.

Marketing email click attribution — the full pattern

The classic example of "the server needs to fire events before the client has a chance to." A re-engagement email goes out from your ESP; the user clicks a link 36 hours later from their inbox; the link goes through your redirect handler; you need to attribute the click to a campaign and identify the user — before their browser even hits the destination page.

Email link shape

https://app.example.com/r?u=eyJ1IjoidXNlcl84NDcifQ.signature&c=may15-re-engage&dest=/dashboard

Three params: u (signed user ID — use a JWT or HMAC over { userId, exp }), c (campaign slug), dest (the path to redirect to). Sign the u param so users can't tamper with someone else's userId just by editing the URL.

Redirect handler

app.get("/r", async (req, res) => {
  const { u, c, dest } = req.query;

  let userId: string | null = null;
  try {
    const payload = jwt.verify(u as string, process.env.EMAIL_LINK_SECRET!);
    userId = (payload as { u: string }).u;
  } catch {
    // Bad / expired signature — log it but still redirect, so user isn't blocked.
    logger.warn("email link signature invalid");
  }

  if (userId) {
    // Identify FIRST so the click event lands on the right cdcust.
    // We don't have an anonymousId yet — the user isn't on our site —
    // but identifying with userId alone is fine; the next browser-side
    // identify() call from the destination page will alias them naturally.
    await crossdeck.identify(userId, mintEphemeralAnonId(), {
      // You may not have an email here without a DB lookup — skip if so.
    });

    crossdeck.track({
      name: "email_link_clicked",
      developerUserId: userId,
      eventId: `emailclick_${u}`,  // dedupe on the signed token — same click = same event
      properties: {
        campaign: c as string,
        destination: dest as string,
        userAgent: req.headers["user-agent"],
      },
    });

    // Critical: flush before the 302 — the platform may freeze us.
    await crossdeck.flush();
  }

  res.redirect(302, (dest as string) ?? "/");
});

By the time the user's browser hits /dashboard and the web SDK boots, the email_link_clicked event is already on the customer's timeline. The browser's subsequent page.viewed + identify() calls land on the same cdcust. Funnels that ask "of users who clicked the may15-re-engage email, how many viewed pricing within 24h" become trivially queryable.

Sign the user ID.

If your u param is just a plain user ID without a signature, anyone can craft a URL that fires email_link_clicked on someone else's timeline. Use JWT or HMAC; verify before identifying. The signed-token approach also lets you dedupe with eventId: \`emailclick_${u}\` — the same email click can't fire twice even if the user opens the link twice from different devices.

Cron & scheduled work

Cron jobs are the second-most-common server-side telemetry source after webhook receivers. The patterns differ slightly depending on the runtime.

Cloud Functions (v2)

import { onSchedule } from "firebase-functions/v2/scheduler";
import { CrossdeckServer } from "@cross-deck/node";

// Initialise at MODULE LOAD, not inside the handler — so the boot
// heartbeat doesn't fire on every invocation and the entitlement cache
// is reused across warm invocations.
const crossdeck = new CrossdeckServer({
  secretKey: process.env.CROSSDECK_SECRET_KEY!,
  serviceName: "renewal-cron",
});

export const processRenewals = onSchedule("every 1 hours", async () => {
  const dueRenewals = await findRenewalsDueWithinNextHour();

  for (const r of dueRenewals) {
    crossdeck.track({
      name: "subscription_renewal_processed",
      developerUserId: r.userId,
      eventId: `renewal_${r.subscriptionId}_${r.cycleId}`,
      properties: {
        subscriptionId: r.subscriptionId,
        amount: r.amount,
        currency: r.currency,
      },
    });
  }

  // Drain before return. The Cloud Function platform freezes the
  // container the moment this async function resolves. flush-on-exit
  // handlers can be preempted by the platform's SIGKILL — explicit
  // flush is the safety net.
  await crossdeck.flush();
});

AWS Lambda

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

// Module scope = init once per container, reused across warm invocations.
const crossdeck = new CrossdeckServer({
  secretKey: process.env.CROSSDECK_SECRET_KEY!,
  serviceName: process.env.AWS_LAMBDA_FUNCTION_NAME, // auto-detected too
});

export const handler = async (event) => {
  for (const record of event.Records) {
    crossdeck.track({
      name: "queue_message_processed",
      developerUserId: extractUserId(record),
      eventId: record.messageId, // SQS message IDs are unique per delivery
      properties: { queue: event.queueArn, body: record.body },
    });
  }

  await crossdeck.flush();
  return { statusCode: 200 };
};

Why module-scope init matters

Cold-start cost is real on serverless. new CrossdeckServer(...) runs synchronous setup (validating the key prefix, building the runtime info object, installing exit handlers) plus an async background boot heartbeat. Doing this once per container — at module load — amortises the cost across every warm invocation. Doing it per-invocation in the handler body adds ~5-15ms to every cold path and makes the boot heartbeat fire dozens of times per minute on hot platforms.

The container-local entitlement cache (60s TTL by default) is also reset on every new instance — so module-scope init also means hot invocations skip the entitlement-fetch round-trip when the cache is warm.

Flush-on-exit

The single biggest source of "missing events" on serverless: the function returns, the platform freezes/kills the container, and the events that were sitting in the in-memory queue at that moment evaporate. The Node SDK ships protection against this by default.

What the SDK does automatically

At construction time (unless flushOnExit: false), the SDK installs three handlers:

Each handler awaits eventQueue.flush() with a bounded timeout (flushOnExitTimeoutMs, default 2000ms). Two seconds is enough to flush a handful of events over a healthy network without blocking shutdown past the platform's own SIGKILL grace period (typically 5–10 seconds after SIGTERM).

When the automatic handlers aren't enough

Some serverless platforms don't fire beforeExit reliably. Cloud Functions can freeze the process the instant the handler's returned promise resolves; AWS Lambda can hibernate the container without a clean shutdown signal at all. The defensive pattern is to await crossdeck.flush() explicitly at the end of every handler:

export const handler = async (event) => {
  crossdeck.track({ name: "work_started" , developerUserId: event.userId });
  await doWork(event);
  crossdeck.track({ name: "work_finished", developerUserId: event.userId });

  await crossdeck.flush();   // drain before return
  return { ok: true };
};

flush() is idempotent and a no-op when the queue is empty, so calling it at every exit point costs nothing on warm-path invocations where the interval-driven flush already drained the queue.

If flush() hangs

Almost always a network issue — your Lambda VPC has no NAT/egress to api.cross-deck.com, or a captive portal in a dev environment is intercepting the request. The SDK's per-request timeout (timeoutMs, default 15s) caps it eventually, but in serverless that's longer than your handler should run. Set a shorter timeoutMs for cron/Lambda workloads (5s is reasonable) so a hung network surfaces quickly instead of consuming your whole runtime budget.

Batching & backpressure

The Node SDK batches events server-side before sending. Defaults are tuned for steady-state workloads — bursty loads might want tuning.

OptionDefaultWhat it controls
eventFlushBatchSize20Max events buffered before forced flush. Parity with web SDK.
eventFlushIntervalMs1500Idle ms after last track() before flushing. Combines with batch size — whichever fires first.
flushOnExittrueInstall beforeExit/SIGTERM/SIGINT drain handlers.
flushOnExitTimeoutMs2000Max time the exit handlers block shutdown waiting for the queue.
timeoutMs15_000Per-request abort timeout. Drop to 5s for serverless cold paths.

Server-side wire limits

The ingest endpoint enforces hard limits per request, regardless of SDK config:

Backpressure events

The SDK emits two operational events you can subscribe to for monitoring:

crossdeck.on("queue.flush_succeeded", ({ batchSize, durationMs }) => {
  metrics.histogram("crossdeck.flush.batch_size", batchSize);
  metrics.histogram("crossdeck.flush.latency_ms", durationMs);
});

crossdeck.on("queue.flush_failed", ({ error, attempt, nextRetryMs }) => {
  logger.warn("crossdeck flush failed", { error, attempt, nextRetryMs });
});

Useful for catching upstream degradation (your project's rate limit got tightened, the backend is having a bad day, your secret key was rotated and the new env hasn't deployed yet) before it shows up as missing events in dashboards.

Anti-patterns

(a) Firing the same event from both client AND server

"Just to be safe, we fire purchase_completed from the client AND from the Stripe webhook." Now you have two events per purchase. Funnels double-count. Revenue rolls up wrong. The "right" side to fire from is whichever has the most authoritative signal — for a payment, that's the webhook. Pick one and only one. If you need a client-side "user clicked Pay" event, name it differently (checkout_submitted) so it doesn't collide.

(b) Using a publishable key on the server

Crossdeck enforces origin allowlisting on publishable keys. Your server doesn't send Origin, so the request 403s with origin_not_allowed. The Node SDK actually throws at construction time when you pass a cd_pub_* key — but if you're calling raw HTTP, you'll see the failure as a 403 with no events landing.

(c) Calling identify() with email instead of stable userId

Emails change. People update their address, merge accounts, switch to a corporate domain. Your userId (or its equivalent — Firebase Auth's uid, Stripe's customer.id, your internal user-table PK) is stable for the lifetime of the user. Use the stable ID for identify()'s first argument; attach email as a trait, not as the identity.

// Wrong — email-as-userId. Now their analytics break when they change emails.
await crossdeck.identify(user.email, anonId);

// Right — stable userId; email as a trait.
await crossdeck.identify(user.id, anonId, { email: user.email });

(d) Skipping idempotency on webhook replays

Stripe redelivers webhooks on any non-2xx response (and on slow 2xx). If your handler doesn't set eventId or Idempotency-Key derived from the upstream event ID, every redelivery double-counts. Use eventId: \`stripe_${event.id}\` as the standard pattern — replay-safe at the source.

(e) Fire-and-forget without await or flush() on serverless

crossdeck.track() is sync void — it returns the instant the event is enqueued, not when it lands. On long-running Express servers that's fine: the queue drains on the interval timer. On Lambda / Cloud Functions, the platform can freeze your process the moment your handler resolves — and the queue evaporates. Always await crossdeck.flush() before returning from a serverless handler.

Troubleshooting

Events sent, nothing appearing in the dashboard

Three common causes:

Events appearing but not attributing to a customer

The event has no identity hint, or the hint references an identifier Crossdeck doesn't know yet. Check that every event includes exactly one of developerUserId / anonymousId / crossdeckCustomerId. If you're firing from a webhook receiver, make sure your userId is actually stored in Stripe's metadata when you create payment intents — many bugs trace back to "we forgot to attach metadata at PI creation, and now the webhook has no way to identify the user."

Identity linking not working — client + server events on different customers

The most common cause: the anonymousId the server attaches doesn't match the one the browser stored. Confirm with Crossdeck.getState().anonymousId in the browser console; compare to what your server actually sees in the forwarded cookie/header. The web SDK's anonymousId format is anon_* — if your server is sending something else (a UUID, a session ID), the alias never matches.

Second common cause: userId format mismatch. If your client sends identify("847") and your server sends identify("user_847"), those are two different identities in Crossdeck. Normalise on one shape end-to-end.

Hitting rate limits (429 rate_limit_error)

Default project quota is generous but finite (~1000 events/sec sustained, with burst capacity). Hitting it usually means a backfill or replay job is flooding the endpoint. Two paths:

flush() hangs on serverless exit

Almost always a network egress issue. Your Lambda is in a VPC with no NAT gateway, or your Cloud Function has a firewall rule blocking outbound HTTPS. The SDK's per-request timeout caps it eventually (15s default), but that's longer than most serverless handler budgets. Set timeoutMs: 5000 when constructing the SDK on serverless so a hung network surfaces fast, and check your VPC config.

"cd_pub_ key is not a valid secret key"

Constructor-time error from the Node SDK when the supplied key doesn't start with cd_sk_. You've likely re-used your browser publishable key in a server env. Generate a secret key from the dashboard's API keys page, store it in your secret manager, and redeploy.


Last updated May 15, 2026. Code-truth references: sdks/node/src/crossdeck-server.ts, sdks/node/src/http.ts, sdks/node/src/event-queue.ts, backend/src/api/v1-events.ts, backend/src/api/v1-auth.ts.