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:
| Option | Default | Bound / behavior |
|---|---|---|
batchSize | 25 | 1–25; never exceeds the collector cap |
maxQueueSize | 500 | hard event-count ceiling; no oldest-event eviction |
maxQueueBytes | 16 MiB | hard serialized-byte ceiling; configurable to 64 MiB |
flushIntervalMs | 5,000 | set 0 to disable background flush |
maxRetries | 3 | additional attempts after the first request |
initialBackoffMs | 250 | exponential base |
maxBackoffMs | 5,000 | caps exponential and Retry-After delay |
jitterRatio | 0.2 | bounded ±20% jitter |
requestTimeoutMs | 15,000 | per-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.