# Server-side event ingestion

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

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

- **Server-side > client-side** whenever the event is authoritative (payment captured, subscription renewed, admin granted a feature) or fires before/without a browser session (cron jobs, ESP redirects, webhook receivers, SSR'd routes).

- **Use the Node SDK by default.** `new CrossdeckServer({ secretKey })` + `crossdeck.track(event)` gives you a durable queue, batched HTTP, exponential-backoff retries, per-batch `Idempotency-Key`, and process-exit drain — all without you writing the plumbing.

- **Raw HTTP works for any backend.** Go, Rust, PHP, Ruby, Elixir, or a runtime you don't fully control: `POST /v1/events` with a `cd_sk_*` secret-key bearer token. You handle batching, retry, and idempotency yourself — strongly prefer the Node SDK if you can.

- **Idempotency keys are mandatory** for any path where the same logical event could fire twice — Stripe webhook replays, retried Lambda handlers, queue redelivery. The Node SDK auto-generates one per batch; raw-HTTP callers should set `eventId` per event and re-use Stripe's `event.id` as the upstream key.

- **Identity linking** is the part nobody gets right by accident. Every server event needs *exactly one* of `developerUserId` / `anonymousId` / `crossdeckCustomerId` — and to keep a continuous timeline across client + server, you need to reuse the SAME `anonymousId` the browser minted (forward it via cookie or header) or call `identify()` first to alias the user's `userId` to it.

- **Flush-on-exit prevents serverless data loss.** The Node SDK installs `beforeExit` + `SIGTERM` + `SIGINT` handlers by default. On Lambda / Cloud Functions cold paths, `await crossdeck.flush()` explicitly before `return` — the platform can SIGKILL you before the handlers fire.

This is a patterns doc, not the API reference.

The [Node SDK reference](https://cross-deck.com/docs/node-sdk/) 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= &c= `), 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](#email-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)](#anti-patterns) 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](#cron) 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:

- **Durable in-memory queue.** Events are buffered locally and flushed in batches of up to 20 (configurable via `eventFlushBatchSize`) every 2s of idle time (`eventFlushIntervalMs`), whichever fires first. A burst of 100 `track()` calls produces ~5 HTTP requests, not 100.

- **Per-batch `Idempotency-Key` header.** Every flush generates a fresh batch ID; the same ID is reused on retry so the server dedupes if the first attempt actually succeeded but the response got lost.

- **Exponential-backoff retry with full jitter.** Failed flushes retry on the next interval (with the same batch and same key, so server-side dedup still applies). Honours `Retry-After` from the server when present.

- **Flush-on-exit.** `process.on('beforeExit')` + `SIGTERM` + `SIGINT` handlers drain the queue, bounded by `flushOnExitTimeoutMs` (default 2s). Critical for Cloud Functions, Lambdas, and short-lived CLI processes.

- **Runtime enrichment.** `serviceName`, `serviceVersion`, `appVersion`, and process metadata attach to every event automatically. On Cloud Functions / Lambda the SDK reads `K_SERVICE` / `AWS_LAMBDA_FUNCTION_NAME` with no config.

- **Boot heartbeat.** The first instantiation phones home in the background, so the dashboard's "Verify install" surface flips LIVE without you calling `await crossdeck.heartbeat()` manually. Fire-and-forget; never blocks.

For the full set of init options, return shapes, and method signatures, see the [Node SDK reference](https://cross-deck.com/docs/node-sdk/). 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: my-go-service@1.4.2         # 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](#identity)). **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: ops-script@1.0.0" \
  -d '{
    "events": [
      {
        "eventId": "evt_'$(uuidgen)'",
        "name": "support_ticket_resolved",
        "timestamp": '$(date +%s000)',
        "developerUserId": "user_847",
        "properties": { "ticketId": "T-12345", "resolutionMinutes": 42 }
      }
    ]
  }'
```

### Wire limits

Limit Value Behaviour on breach

Events per batch 1–100 400 invalid_param_value with the first failing index
Per-event properties size (JSON-serialised) 8 KB 400 invalid_param_value on the offending event
Request body size 1 MB 413 from the platform; chunk into smaller batches
developerUserId length 1–256 chars 400 invalid_param_value
Per-project rate limit Token-bucket, default 100 events/sec sustained, bursting to 1,000 429 with Retry-After

### Responses

Success is **202 Accepted** with `{ "object": "list", "received": , "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:

- `401 authentication_error` — secret key missing, malformed, or revoked.

- `400 invalid_param_value` — batch shape rejected. The response body identifies the bad event index and field.

- `403 permission_error` with code `origin_not_allowed` — you sent a publishable key from a server context with no `Origin` match. Use a secret key. See [Authentication](#auth).

- `429 rate_limit_error` — your project exceeded its bucket. Honour `Retry-After`.

- `5xx` — backend transient. Retry with the same `Idempotency-Key`.

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 type Prefix Where it goes Can ingest events with arbitrary identity?

Publishable cd_pub_test_ / cd_pub_live_ Browser bundles, mobile apps, anywhere the bundle ships to a user device No — limited to the device's own anonymousId ; origin-allowlisted on web keys
Secret cd_sk_test_ / cd_sk_live_ Server processes only — Node, Cloud Functions, Lambdas, cron jobs, webhook receivers Yes — 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:

- A publishable key authenticates a *device*, not a developer. It's safe to put in a browser bundle because it can't impersonate other users — the wire is origin-locked, and an attacker stealing the key only impersonates themselves.

- A secret key authenticates a *developer*. It can fire events on behalf of any user in your project, because that's what server-side ingest needs — your webhook receiver knows the customer's `userId` from Stripe metadata, and it needs to fire `purchase_completed` on that user's timeline.

- If you put a publishable key on your server, Crossdeck enforces the *same* origin allowlist a browser would — and your server doesn't send an `Origin` header, so the request `403`s with `origin_not_allowed`. This is the failure mode you'll hit if you accidentally re-use the browser key on the backend.

- Secret keys are stored hashed at rest in Crossdeck — we never see the plaintext after creation. Treat them like Stripe restricted keys: rotate via the dashboard, never check them into git, source them from your secret manager.

```
// 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:

Field Set by When to use

developerUserId Your auth system You've already identified the user — JWT, session, signed query param, Stripe metadata
anonymousId The 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
crossdeckCustomerId Returned by Crossdeck after identify() / webhook reconciliation You 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: "jules@acme.com" },
);

// 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](https://cross-deck.com/docs/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:

- **Per-batch `Idempotency-Key` header.** The HTTP layer's standard idempotency contract — same key + same body within ~24h returns the original response without re-applying writes. Used by the SDK's batched flush path; same key gets re-sent on retry.

- **Per-event `eventId`.** The endpoint deduplicates events on the tuple `(projectId, eventId)` via Firestore's `create-only` writes — even if you split the same event across two different batches, only one row lands. Recommended for every server-side event so retries are safe even when the batch grouping changes.

### 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

- **Raw body parsing on the webhook route.** Stripe's signature is computed over the raw bytes — if Express's `json()` middleware has already reparsed the body, the HMAC won't match. Same rule applies to [Crossdeck's own webhook signature verification](https://cross-deck.com/docs/node-sdk/#webhooks) — use `express.raw()` on signed-webhook routes.

- **Verify before tracking.** Never call `crossdeck.track()` from an unverified webhook handler — an attacker who hits your endpoint without a valid signature could otherwise inject events with arbitrary `userId`. Verify first; track on the verified path only.

- **`eventId` pinned to the upstream event ID.** Stripe redelivers on slow 2xx and on any 5xx response. The same `event.id` across redeliveries means Crossdeck dedupes — your `purchase_completed` count is replay-safe at the source.

- **`await crossdeck.flush()` before 200.** Serverless platforms freeze the process the instant the handler returns. Without an explicit flush, your durable queue may have events buffered that never make it out. On long-running servers (Express on a VM, Fargate task) the interval-driven flush is enough — but adding the explicit `flush()` in the webhook path costs nothing and removes a category of bug.

- **Don't fire-and-forget on serverless.** Returning 200 before the SDK has flushed loses events on cold paths. The trade-off is ~50-200ms of added latency on the webhook 200 response — well within Stripe's 30s ack budget, and the events actually land.

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: "jules@acme.com",
    traits: {
      plan: "free",
      signupSource: "email",
    },
  },
);
```

### When server-side identify is the right call

- **Email-click attribution** — the click hits your server before the browser SDK boots. The server-side `identify()` + `track("email_link_clicked")` combo lands both the alias and the event before the user even sees the destination page.

- **OAuth callback / SSO completion** — the auth provider redirects the user back to `/auth/callback`; the server resolves the user; `identify()` immediately so the post-redirect render lands events on the right `cdcust`.

- **Server-side signup** — a backend endpoint creates the user record. Fire `identify()` there rather than waiting for the client to call it after redirect.

- **Account merging** — your application merges two accounts; use `aliasIdentity()` on the server to link them in Crossdeck too.

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 ` — 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:

- `process.on("beforeExit")` — Node's normal event-loop-exhausted exit. Fires when there's nothing left to do; the SDK gets one chance to flush before the runtime exits.

- `process.on("SIGTERM")` — the platform's "please shut down" signal. Cloud Run, ECS, Kubernetes, and bare metal orchestration use this for graceful shutdown.

- `process.on("SIGINT")` — Ctrl+C in dev. So your local test runs don't lose events when you cancel them.

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.

Option Default What it controls

eventFlushBatchSize 20 Max events buffered before forced flush. Parity with web SDK.
eventFlushIntervalMs 1500 Idle ms after last track() before flushing. Combines with batch size — whichever fires first.
flushOnExit true Install beforeExit /SIGTERM/SIGINT drain handlers.
flushOnExitTimeoutMs 2000 Max time the exit handlers block shutdown waiting for the queue.
timeoutMs 15_000 Per-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:

- **1–100 events per batch.** The SDK won't put more than 20 per batch by default; if you raise `eventFlushBatchSize` beyond 100 the server-side validator rejects the batch.

- **8 KB per event's `properties`** (JSON-serialised). Larger payloads reject at validation with `properties_too_large`.

- **~1 MB total request body** (Cloud Functions default — Crossdeck doesn't override).

- **Per-project rate limit** via token-bucket — defaults are generous (100 events/sec sustained, bursting to 1,000), but you'll see `429` with `Retry-After` if you exceed it. Raise the limit by opening a dashboard support ticket.

### 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 `403`s 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:

- **Wrong environment.** Your secret key is `cd_sk_test_*` but you're looking at the production dashboard, or vice versa. Check the key prefix and the dashboard env toggle.

- **Wrong project.** Your secret key belongs to a different project than the one you're viewing. The dashboard's "Verify install" panel surfaces the key fingerprint Crossdeck saw — check it against the prefix in your env vars.

- **Auth rejected silently.** If you're using raw HTTP and not checking the response, a 401 will look identical to success from your code's perspective. Add error handling, or use the Node SDK which throws.

### 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 (~100 events/sec sustained, bursting to 1,000). Hitting it usually means a backfill or replay job is flooding the endpoint. Two paths:

- **Slow down.** Honour `Retry-After` in the 429 response — the Node SDK does this automatically.

- **Raise the limit.** Open a support ticket from the dashboard; tell us the project ID and the workload shape. We adjust the bucket parameters from a Firestore doc — no deploy needed.

### 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.

## Related

- [Node SDK reference](https://cross-deck.com/docs/node-sdk/) — every init option, method signature, return shape, and error code for `@cross-deck/node`.

- [Identify users](https://cross-deck.com/docs/identify-users/) — the full identity model: anonymous IDs, developer IDs, customer IDs, aliasing, the merge worker.

- [Web SDK reference](https://cross-deck.com/docs/web-sdk/) — the browser-side counterpart. Same identity model, complementary primitives.

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

- [Webhook verification](https://cross-deck.com/docs/node-sdk/#webhooks) (Node SDK § Webhooks) — Crossdeck-issued webhooks have signed bodies; `verifyWebhookSignature()` is the safe-by-default helper.

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`.
