Engineering

Consume the Signals Feed: Real-Time Visitor Data Over WebSocket

The Signals Feed streams every labeled event — bot classification, behavioral class, and an 11-field score snapshot — to your own infrastructure. Here is the whole subscriber, in under 60 lines of Node.

July 2026 • 9 min read • Scale plan and above

The Signals Feed is read-only — no write surface exists on this endpoint, by design. Everything in this tutorial is walked through against the sample subscriber that ships in the ClickStream platform repo at examples/signals-feed-subscriber/subscribe.mjs, and the feed requires the Scale plan or above.

A Real-Time Visitor Data Feed, Not Another Poll Loop

The Signals REST endpoint answers a question about one visitor: what does this person look like right now? The Signals Feed answers the other question: what is happening on my site, right now, across every visitor? It's a WebSocket at /signals/stream on the ClickStream collector that pushes one JSON object per frame for every event the edge pipeline processes — already labeled with bot classification, a behavioral class, device summary, and the same 11-field score snapshot the REST endpoint returns, distilled from ClickStream's 26 behavioral models (scored per event under a CI-enforced p95 < 3 ms benchmark).

Because each frame is one complete JSON object, the subscriber side stays trivial: no streaming JSON parser, no framing protocol. Append a newline to each frame and you have JSONL, which every log shipper, warehouse loader, and jq pipeline on earth already speaks. (There's also an opt-in msgpack binary mode via ?binary=1; the JSON default is what the example uses.)

One property to internalize before you build on it: the feed is a real-time tap, not a queue. Events that arrive while nobody is subscribed are dropped, deliberately — buffering would create an unbounded memory footprint for clients with no subscribers. Reconnecting subscribers see traffic from the moment of reconnection onward. We'll come back to what that means for warehouse ingestion.

Auth: A Stream Token, Not Your Public API Key

The feed does not accept the public cs_live_/cs_test_ API keys that power the browser pixel. That's deliberate: a public key ships in your website's source code, and a key that anyone can read from view-source must never be able to read your tenant-wide event stream. (The same philosophy behind scoped keys and domain gating.)

Instead, subscribing requires a short-lived csst_ stream token minted by the dashboard — which means a logged-in dashboard session on a Scale plan or above:

curl -s -H "Cookie: <your einstein session cookie>" \
  "https://einstein.clickstream.com/api/signals/stream-token?lifetimeSeconds=1800"

# → {
#     "streamToken": "csst_...",
#     "expiresInSeconds": 1800,
#     "streamUrl": "wss://feynman.clickstream.com/signals/stream"
#   }

Token lifetime is configurable and server-clamped: the default is 300 seconds, and lifetimeSeconds requests are clamped to the ceiling of your plan (up to 1,800s on Scale; up to 3,600s on Network and custom plans, which is also the absolute maximum; 30s is the floor). The connection lifetime is the token lifetime — so always read expiresInSeconds from the response rather than assuming you got what you asked for, and treat it as your reconnect schedule. On connect, the token rides the WebSocket subprotocol handshake — Sec-WebSocket-Protocol: clickstream-v1, <token> — rather than the URL, so it never lands in proxy or CDN access logs. (A ?token= query parameter exists for diagnostics, but the subprotocol is the intended path.) Hobby and Growth accounts get a 403 plan_upgrade_required; a token minted for one tenant cannot subscribe as another.

The Whole Subscriber

The platform repo ships a complete subscriber at examples/signals-feed-subscriber/subscribe.mjs. It needs Node 22+ (for native WebSocket) and zero dependencies. Here it is, minus the header comments — under 60 lines:

const STREAM_TOKEN = process.env.CS_STREAM_TOKEN;
if (!STREAM_TOKEN || !STREAM_TOKEN.startsWith('csst_')) {
  console.error('[signals-feed] set CS_STREAM_TOKEN to a csst_ stream token.');
  process.exit(1);
}
const COLLECTOR = (process.env.CS_COLLECTOR || 'wss://feynman.clickstream.com').replace(/\/$/, '');
const FILTER = process.env.CS_FILTER || 'all';
const RECONNECT_SECS = Math.max(1, Number(process.env.CS_RECONNECT_SECS || 5));

function connect() {
  const url = `${COLLECTOR}/signals/stream?filter=${encodeURIComponent(FILTER)}`;
  // Token rides the WebSocket subprotocol — never the URL, so it stays out
  // of proxy/CDN access logs.
  const ws = new WebSocket(url, ['clickstream-v1', STREAM_TOKEN]);

  ws.addEventListener('open', () => {
    console.error(`[signals-feed] connected to ${COLLECTOR} (filter=${FILTER})`);
  });

  ws.addEventListener('message', (event) => {
    let msg;
    try {
      msg = JSON.parse(event.data);
    } catch {
      return;
    }

    if (msg.type === 'ping') {
      ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
      return;
    }

    if (msg.type === 'hello') {
      console.error(`[signals-feed] hello: ${msg.message}`);
      return;
    }

    if (msg.type === 'duration_limit') {
      console.error('[signals-feed] server duration cap — will reconnect');
      return;
    }

    if (msg.type === 'event') {
      process.stdout.write(JSON.stringify(msg) + '\n');
    }
  });

  ws.addEventListener('close', (event) => {
    console.error(`[signals-feed] closed code=${event.code} reason=${event.reason || '(none)'}`);
    if (event.code === 1008 || /401|token/i.test(event.reason || '')) {
      console.error('[signals-feed] token rejected or expired — mint a fresh CS_STREAM_TOKEN');
    }
    const isDurationCap = event.code === 4008;
    const wait = isDurationCap ? 0 : RECONNECT_SECS * 1000;
    setTimeout(connect, wait);
  });

  ws.addEventListener('error', (err) => {
    console.error('[signals-feed] error:', err?.message || err);
  });
}

connect();

Events go to stdout as JSONL; diagnostics go to stderr. That split is what makes it composable:

# stream everything
CS_STREAM_TOKEN=csst_xxxx node subscribe.mjs

# humans only, high intent only
CS_STREAM_TOKEN=csst_xxxx CS_FILTER=humans_only node subscribe.mjs \
  | jq 'select(.scores.intent >= 70)'

The >= 70 threshold is the same high-intent convention the browser-side isHighIntent() helper uses, so your stream filters and your page code agree on what "high intent" means.

Message Shapes on the Wire

A subscriber sees exactly four frame types:

Frame type When it arrives What your code does
hello Once, immediately after connecting Log it — confirms the connection and echoes your active filter
ping Every 30 seconds Reply with a pong frame within 60 seconds, or the server closes the connection
event On every labeled event matching your filter The payload — hand it to your pipeline
duration_limit At the 60-minute connection cap, just before close code 4008 Reconnect immediately — this is routine, not an error

An event frame looks like this (a hypothetical visitor, real shape):

{
  "type": "event",
  "ts": 1753632000000,
  "visitorId": "v_8f2k1...",
  "sessionId": "s_a91x3...",
  "eventType": "pageview",
  "page": "/pricing",
  "bot": { "isBot": false, "score": 5 },
  "behavioralClass": "human",
  "device": { "type": "desktop", "browser": "Chrome", "os": "macOS", "isMobile": false },
  "scores": {
    "intent": 74, "frustration": 12, "engagement": 61, "value": 48,
    "churn": 9, "abandonment": 15, "conversionReadiness": 66,
    "sessionMomentum": 22, "confusion": 8,
    "emotionalState": "focused", "decisionStage": "comparing"
  },
  "hasIdentified": false,
  "locale": {
    "language": "en-US", "languages": ["en-US", "en"], "primaryLanguage": "en",
    "translatedTo": null, "timezone": "America/New_York", "country": "US",
    "languageGeoMismatch": false, "timezoneGeoMismatch": false, "hourCycle": "h12"
  }
}

Details worth knowing:

Filters: Choose Your Traffic Rail

The ?filter= query parameter selects a traffic class server-side, so activation, bot analysis, and crawler measurement each get their own rail instead of re-filtering the firehose:

An unrecognized filter value is rejected with a 400 before the upgrade completes, not silently coerced.

Reconnection Is Part of the Protocol

The example's reconnect logic is small because the server tells you what happened. Four cases cover everything:

Two limits to plan around: each client is capped at 10 concurrent subscribers (an eleventh gets HTTP 429) — a deliberately conservative cap that can be raised on request — and, because there is no replay, each reconnect gap is a real gap in what you observed.

What to Build on It

The feed exists so you can react to traffic in seconds without polling. Three patterns come up constantly:

Whatever you build, keep it fail-open: a subscriber outage should degrade your dashboard, never your site. The fail-open patterns post covers the discipline in depth.

What the Feed Is Not

Boundaries stated plainly, because they're load-bearing:

The Bottom Line

A real-time visitor data feed sounds like infrastructure you'd budget a sprint for. The actual contract is small: one WebSocket, four frame types, a token that rotates hourly, and a reconnect loop the server actively cooperates with. The shipped example covers all of it in under 60 lines with zero dependencies — everything past that is your pipeline, not ClickStream's protocol.

Poll loops tell you what your traffic looked like. A feed tells you what it looks like — present tense is the whole product.

Put Your Traffic on Tap

Install the pixel, upgrade to Scale, and point the 60-line subscriber at your own stream. If the socket drops, your site doesn't — that's the design.

Start free