Node.js server SDK

@clickstreamhq/node is the supported path for backend event ingestion on Node.js 20+. It implements the collector's server-only HMAC contract and keeps batching, replay safety, memory, and request cost bounded.

Install and create a client

npm install @clickstreamhq/node

Create a Server property in Einstein, copy its cs_srv_live_* key into your secret manager, and create one client for the process:

import { createClickStreamServer } from '@clickstreamhq/node';

export const clickstream = createClickStreamServer({
  apiKey: process.env.CLICKSTREAM_SERVER_API_KEY!,

  // Match the selected property's Require consent setting.
  requireConsent: true,
  consent: {
    analytics: true,
    identityResolution: true,
    thirdPartySharing: false,
  },
});

The key is both the bearer credential and HMAC secret. Do not expose it to a browser, mobile app, log, query string, or client-visible environment variable. The collector requires the key in the X-API-Key header for signed ingestion — a query-string credential never satisfies the server contract.

endpoint is optional and defaults to https://feynman.clickstream.com, which is the right value for a Server property (it has no tracking domain of its own). If you set it, it must be a bare origin — no path, query, fragment, or credentials — and it must use HTTPS. Plain HTTP is accepted only for loopback development (localhost, 127.0.0.1, or [::1]). Anything else throws ClickStreamConfigurationError at construction.

Track a server event

const result = await clickstream.track({
  type: 'custom',
  name: 'subscription_renewed',
  category: 'billing',
  value: 199,
  visitorId: 'visitor_01JXYZ',
  sessionId: 'billing_job_2026_08_02',
  page: { path: 'billing/renewal', title: 'Renewal' },
});

if (!result.queued) {
  console.log(result.reason); // consent denied or required
}

visitorId and sessionId are required so the event reaches the correct person/session projections — each is a non-empty string of at most 128 characters, and so is _eid when you supply your own. page is optional; the SDK supplies a server-safe logical page (path: 'server', title: the event type) and always forces device.clientPlatform: 'server'. It never invents a browser Origin or browser viewport. A single serialized event over 256 KiB throws ClickStreamConfigurationError instead of being queued.

Supported event types match POST /v1/events: pageview, click, scroll, form, custom, identify, and _consent_transition.

Idempotency and durable jobs

The SDK generates _eid once when an event enters its queue. It serializes a batch once, derives a request Idempotency-Key from those exact bytes, and reuses both across every network attempt. Each attempt gets a fresh timestamp and HMAC, so a timeout can be replayed safely without duplicating accepted or billable events.

Generated IDs use the collector's elp1_<two hex digits>_<unique suffix> format. The affinity bytes are deterministic for sessionId, so a normal server job/session batch reaches one exact-ledger partition instead of paying for up to 25 partition calls.

The internal queue is intentionally memory-only. For work that must survive a process crash, use your database or queue as the durable source of truth:

for (const row of await outbox.pending()) {
  const tracked = await clickstream.track({
    type: 'custom',
    name: row.eventName,
    visitorId: row.visitorId,
    sessionId: row.jobId,
    _eid: row.id, // stable across process restarts
    label: JSON.stringify(row.properties),
  });

  if (tracked.queued && tracked.flush) {
    await reconcileAndCheckpoint(tracked.flush);
  }
}

const tail = await clickstream.flush();
await reconcileAndCheckpoint(tail);

Only checkpoint a source record after collector accounting confirms it as accepted or duplicate. 202 responses can include accepted, duplicate, consent/posture-dropped, and rejected records in the same batch; the SDK preserves each number and exposes durable receiptIds plus the collector's per-event errors. It only acknowledges a success when requested === received and accepted + duplicates + dropped + rejected === received; an unreconciled response is replayed idempotently and remains queued if it still cannot reconcile.

Batching, retry, and cost bounds

Defaults are chosen for low request overhead without unbounded memory:

OptionDefaultBound / behavior
batchSize251–25; never exceeds the collector cap
maxQueueSize500hard event-count ceiling; no oldest-event eviction
maxQueueBytes16 MiBhard serialized-byte ceiling; configurable to 64 MiB
flushIntervalMs5,000set 0 to disable background flush
maxRetries3additional attempts after the first request
initialBackoffMs250exponential base
maxBackoffMs5,000caps exponential and Retry-After delay
jitterRatio0.2bounded ±20% jitter
requestTimeoutMs15,000per-attempt timeout

Network errors, 408, 425, 429, and 5xx retry. Other 4xx responses are terminal because retrying an invalid or unauthorized body only adds spend. A terminal error contains the full normalized batch and response details. A retryable failure stays queued; when the queue is full and cannot flush, the next producer receives ClickStreamBackpressureError (carrying queuedEvents, queuedBytes, maximumQueueBytes) rather than silent data loss or unbounded memory growth. One serialized event and one collector response are each capped at 256 KiB, so count limits cannot conceal an oversized-record memory path.

Every delivery failure surfaces as ClickStreamDeliveryError with status, idempotencyKey, retryable, the normalized events, and responseBody. events and responseBody are non-enumerable, so they stay available to your code without being copied into a JSON-serialized log line.

A monthly budget stop currently reads like an outage

Know this before you write alerting. When the account exceeds its pageview budget the collector answers 429 usage_limit_exceeded with Retry-After: 3600. This client classifies 429 as retryable and clamps any Retry-After to maxBackoffMs (default 5 000 ms), so it burns maxRetries + 1 attempts in roughly fifteen seconds and then throws:

ClickStreamDeliveryError: Collector remained unavailable after 4 attempts (HTTP 429)

Nothing is down and nothing is lost — the events remain queued and the batch is idempotent — but the message names availability, not billing. Distinguish the two yourself:

import { ClickStreamDeliveryError } from '@clickstreamhq/node';

try {
  await clickstream.flush();
} catch (error) {
  if (error instanceof ClickStreamDeliveryError && error.status === 429) {
    // Budget stop or rate limit. The 429 body carries
    // error: 'usage_limit_exceeded', billingPeriod, used, included,
    // safetyCeiling — read error.responseBody before paging anyone.
  }
  throw error;
}

The collector returns a full usage statement on the success path (a billing block on the 202 and X-ClickStream-* headers). DeliveryReceipt does not carry them — it exposes idempotencyKey, status, attempts, requested, received, accepted, duplicates, dropped, droppedByConsent, droppedByPosture, rejected, errors, and receiptIds. If you need live budget state today, read it from the raw 202 yourself.

Async streams and process shutdown

Use stream() for a queue, cursor, file, or other AsyncIterable. Iteration pauses while each full batch is delivered, propagating backpressure upstream:

for await (const result of clickstream.stream(readEvents())) {
  if (result.kind === 'delivery') {
    console.log(result.report.accepted, result.report.duplicates);
  } else {
    console.log('not queued:', result.inputIndex, result.result.reason);
  }
}

At a worker/job boundary, await flush(). During graceful process shutdown, await close(); it stops the background timer and resolves only after all retained events have been acknowledged, or rejects with the retained delivery error.

try {
  const report = await clickstream.close();
  console.log(report);
} catch (error) {
  // Fail the job so its durable source can replay with the same _eid values.
  process.exitCode = 1;
}

Consent parity and minimization

consent.analytics: false is never queued. When requireConsent is true, missing analytics consent is also denied. The collector independently applies the selected property's current policy, so SDK configuration cannot weaken the server boundary.

When identityResolution: false, the package strips hashed/raw contact values, customer/account IDs, MAIDs, social IDs, and raw form-value/file fields while preserving ordinary analytics and permitted first-party user/order identifiers. Third-party denial removes the MD5 compatibility key and advertising click IDs. GPC forces both identity resolution and third-party sharing off even when a caller supplies true.

setConsent() redacts records already waiting in memory before delivery. Analytics denial clears them. Its { discarded, redacted } result makes both privacy actions observable.

Signal discovery

The canonical signal catalog is available without maintaining another server glossary:

import {
  SIGNAL_CATALOG,
  signalAvailabilityForTier,
} from '@clickstreamhq/node/signal-catalog';

const available = SIGNAL_CATALOG.filter((definition) =>
  signalAvailabilityForTier(definition, 'network', { platform: 'server' }) !== 'unavailable'
);

Browser-only performance signals correctly resolve as unavailable for a server property. See Signals for read APIs and API keys for the raw signed-request contract.