Signals Feed

The Signals Feed is a real-time stream of labeled ClickStream events for server-side tools: queues, warehouses, internal alerts, QA coverage dashboards, and answer-engine monitoring.

Every event is already labeled with the visitor lane, automation likelihood, device, page, and behavior scores. The feed is meant for trusted backend subscribers, not for JavaScript copied into a public website.

Use it when polling one visitor at a time is not enough:

For page-specific UI decisions, use the Signals API. For tenant-wide event processing, use this feed.

Stream Surfaces

ClickStream has two real-time stream concepts:

SurfaceAudienceScopeStatus
Per-visitor realtime streamBrowser/page code and React/Next hydration.One current visitor/session resolved from first-party cookies.GET /v1/signals/:visitorId/stream, via subscribeVisitor()
Signals FeedTrusted backend subscribers.Tenant-wide labeled event stream.This page documents the production target for server-side subscribers.

The per-visitor stream belongs in page code because it is scoped to the active visitor and requires sessionId. It opens exactly one live-session partition, reserves 300 Signals Coverage units, closes after five minutes or two idle minutes, and falls back to polling in the client. The Signals Feed on this page is not for public browser JavaScript; it can expose operational activity across a tenant and therefore requires a short-lived stream token.

Per-visitor frames carry the same VisitorContext shape as the REST Signals API, plus stream metadata:

{
  "type": "visitor",
  "reason": "event",
  "ts": 1779897912140,
  "visitorId": "vis_3f9a...",
  "sessionId": "sess_2b1e...",
  "sourceEventId": "evt_01HY...",
  "snapshotVersion": "1779897912000",
  "context": {
    "identity": { "visitorId": "vis_3f9a...", "clickstreamId": "cs_9b1..." },
    "bot": { "isBot": false, "score": 8 },
    "behavioralClass": "human",
    "scores": { "intent": 82, "frustration": 15 },
    "snapshotAt": "2026-05-27T16:05:12.000Z",
    "snapshotVersion": "1779897912000",
    "ageMs": 140,
    "stale": false,
    "transport": "stream"
  }
}

The stream also sends hello, waiting, and ping frames. Use subscribeVisitor() from @clickstreamhq/signals for page-level realtime updates, or onVisitor() for the polling fallback.

Plan Gate

The Signals Feed is a Scale tier and above feature. Hobby and Growth still get page-side Signals snapshots, but the live tenant-wide stream is for teams wiring events into operational systems.

TierRead stream
HobbyNo
GrowthNo
ScaleYes
NetworkYes
EnterpriseYes

Authentication

The feed does not accept public cs_live_/cs_test_ API keys. It requires a short-lived csst_ stream token minted by the dashboard for an authenticated Scale+ session:

curl -s -H "Cookie: <your dashboard session cookie>" \
  https://einstein.clickstream.com/api/signals/stream-token
{ "streamToken": "csst_...", "expiresInSeconds": 3600 }

Pass the token via the WebSocket subprotocol handshake:

Sec-WebSocket-Protocol: clickstream-v1, csst_...

The token rides the subprotocol — never the URL — so it stays out of proxy and CDN access logs. The route also accepts ?token= for diagnostics only. Tokens expire after about 60 minutes; when a reconnect fails with HTTP 401 (or close code 1008), mint a fresh one.

The token is scoped to your tenant, the feed permission, and an expiration window. Invalid or expired tokens return HTTP 401 before the WebSocket upgrade completes. Wrong-tier accounts return HTTP 403. Do not embed a site API key in a WebSocket URL — public site keys are for event collection and browser-side per-visitor Signals only.

A runnable JSONL subscriber lives at examples/signals-feed-subscriber/subscribe.mjs in the ClickStream repo (Node 22+, native WebSocket). It handles ping/pong, the 4008 duration cap with immediate reconnect, and token-expiry detection.

Pick A Server-Side Filter

You can ask the stream to send all events, or only the lane your subscriber needs.

wss://t.example.com/signals/stream?filter=humans_only

For examples that need a concrete test site context, use your first-party tracking endpoint:

wss://t.example.com/signals/stream?filter=humans_only

The subscriber must still use a short-lived stream token minted for the correct tenant.

Allowed filters:

FilterUse it for
allFull labeled feed.
humans_onlyHuman-only operational alerts and support workflows.
non_humanCrawler, monitor, preview, automation, and review traffic.
bots_onlyAny bot-classed traffic.
ai_agentsAnswer-engine coverage workflows.
search_crawlersSearch coverage workflows.

If omitted, the feed behaves like all.

Copy-Paste: Backend Subscriber

This example assumes your server has already received a csst_ stream token from /api/signals/stream-token (see Authentication).

const STREAM_TOKEN = process.env.CS_STREAM_TOKEN;
const ENDPOINT = process.env.CS_ENDPOINT || 'wss://t.example.com';

if (!STREAM_TOKEN?.startsWith('csst_')) {
  throw new Error('Set CS_STREAM_TOKEN to a csst_ token from /api/signals/stream-token');
}

const ws = new WebSocket(`${ENDPOINT}/signals/stream?filter=humans_only`, [
  'clickstream-v1',
  STREAM_TOKEN,
]);

ws.addEventListener('message', (event) => {
  const msg = JSON.parse(event.data);

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

  if (msg.type !== 'event') return;
  if ((msg.scores?.intent ?? 0) < 70) return;

  console.log(JSON.stringify({
    kind: 'human_threshold_crossed',
    visitorId: msg.visitorId,
    sessionId: msg.sessionId,
    page: msg.page,
    intent: msg.scores.intent,
    decisionStage: msg.scores.decisionStage,
  }));
});

ws.addEventListener('close', (event) => {
  console.error(`Signals Feed closed: ${event.code} ${event.reason}`);
});

Copy-Paste: Automation QA Subscriber

Use this to prove scripted account setup, settings, and search journeys are exercising the pages you expect, without mixing test runs into human analytics.

const ws = new WebSocket('wss://t.example.com/signals/stream?filter=non_human', [
  'clickstream-v1',
  process.env.CS_STREAM_TOKEN,
]);

ws.addEventListener('message', (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === 'ping') return ws.send(JSON.stringify({ type: 'pong' }));
  if (msg.type !== 'event') return;
  if (msg.bot?.category !== 'automation') return;

  process.stdout.write(JSON.stringify({
    kind: 'automation_coverage',
    page: msg.page,
    eventType: msg.eventType,
    sessionId: msg.sessionId,
    botScore: msg.bot.score,
    ts: msg.ts,
  }) + '\n');
});

Wire Format

Each WebSocket frame carries exactly one JSON object. Event frames look like this:

{
  "type": "event",
  "ts": 1713797640000,
  "visitorId": "vis_3f9a...",
  "sessionId": "sess_2b1e...",
  "eventType": "click",
  "page": "/docs/getting-started",
  "bot": { "isBot": false, "score": 8 },
  "behavioralClass": "human",
  "device": { "type": "desktop", "browser": "Chrome", "os": "macOS", "isMobile": false },
  "scores": {
    "intent": 82,
    "frustration": 15,
    "engagement": 67,
    "value": 44,
    "churn": 12,
    "abandonment": 18,
    "conversionReadiness": 55,
    "sessionMomentum": 42,
    "confusion": 9,
    "emotionalState": "engaged",
    "decisionStage": "evaluating"
  },
  "hasIdentified": false
}
FieldTypeNotes
type"event"Always "event" for data frames. Heartbeat frames use "ping" / "pong"; the "hello" frame fires once on connect.
tsnumberServer-side timestamp in milliseconds.
visitorIdstringFirst-party visitor id. Stable across sessions for the same browser.
sessionIdstringSDK session id. Resets after 30 minutes of inactivity.
eventTypestringOne of pageview, click, scroll, form, custom, identify.
pagestringCurrent URL path, PII-scrubbed before transmit.
bot.isBotbooleanComposite verdict from network, registry, and behavioral signals.
bot.scorenumber0-100 automation likelihood. Higher means less person-like.
bot.categorystring?Present when traffic maps to a known category such as search_crawler, ai_agent, monitoring, or automation.
bot.namestring?Human-readable non-human agent label when known.
behavioralClass"human" | "suspicious" | "likely_bot" | "bot"Dashboard-matching bucket. Human-only subscribers should filter on this.
device.type"desktop" | "mobile" | "tablet"Parsed from the reported user agent.
scoresobject | nullSnapshot identical to the Signals API surface. null on unscored events.
hasIdentifiedbooleantrue once the visitor has called tracker.identify() in this session.

Consent filtering happens upstream. Events from visitors who have opted out are dropped before reaching the Signals Feed.

Connection Lifecycle

Quick Health Check

Verify the route is live without opening a full WebSocket:

curl -sS "https://t.example.com/signals/stream"

Expected response:

{
  "error": "upgrade_required",
  "message": "WebSocket upgrade required at /signals/stream"
}

Expired or missing stream tokens return plain JSON before the upgrade.

Backpressure And Loss

The Signals Feed is an at-most-once stream. Subscribers that cannot keep up may be disconnected, and there is no built-in replay across disconnects.

If you need durable delivery, connect the feed to a queue on your side and bound your own buffer with backpressure.

See Also