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.

Read This First

Two constraints decide the shape of your subscriber. Both are hard and both are shipped:

  1. A connection never outlives the token that opened it. The lifetime is chosen at mint with lifetimeSeconds, clamped by your plan's ceiling, and signed into the token — so the token's expiry is the connection's deadline. The server closes with WebSocket code 4003. Your subscriber must re-mint a token and reconnect on every close — this is the steady state, not an error path.
  2. Minting requires a logged-in dashboard session (there is no API-key mint path today). An unattended subscriber must be able to present a valid dashboard session cookie each time it re-mints. Requesting a longer lifetime reduces how often that happens; it does not remove the requirement. See Authentication below.

If you have code written against an earlier version of this page: close code 4008 and a duration_limit frame do not exist on this feed. 4008 belongs to the per-visitor stream and the dashboard live stream. A reconnect handler keyed on 4008 will never fire here. Every close on this feed is 4003; duration_limit appears only as a reason inside the scope_expired frame, and a client that ignores reason entirely still behaves correctly.

Stream Surfaces

ClickStream has two real-time stream concepts. They are different routes, different auth, and different close codes.

SurfaceRouteAuthScope
Per-visitor realtime streamGET /v1/signals/:visitorId/stream?sessionId=...The public site key the SDK already uses; subscribeVisitor() attaches it.One visitor + one session.
Signals Feed (this page)GET /signals/stream (WebSocket upgrade)A short-lived csst_ stream token in the WebSocket subprotocol.Every property named in the token scope.

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 at open, caps at five minutes, and closes after two idle minutes. Its close codes are 4008 (session_limit frame, duration cap) and 4000 (stream_idle frame) — subscribeVisitor() treats both as "stop and fall back to polling". Realtime per-visitor access requires Scale and above, or a private key carrying the signals:stream scope; lower tiers get the polling path automatically.

Per-visitor frames carry the same VisitorContext shape as the REST Signals API, plus stream metadata. The context object below is abridged — the full shape is on the Signals API page:

{
  "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 per-visitor stream also sends hello, waiting, and ping frames. Use subscribeVisitor() from @clickstreamhq/signals for page-level realtime updates, or onVisitor() for the polling fallback.

Everything below this line is about the Signals Feed.

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

The gate is evaluated per property, in three places:

Authentication

The feed does not accept public cs_live_/cs_test_ API keys. It requires a short-lived csst_ stream token.

From the dashboard: Sites → select the property → Install tab → Stream token card → Mint token. The card shows the token, the wss endpoint, and a connect snippet.

From code: GET /api/signals/stream-token on the dashboard, authenticated with a dashboard session cookie:

curl -s -H "Cookie: <your dashboard session cookie>" \
  "https://einstein.clickstream.com/api/signals/stream-token?siteId=site_acd434d3&lifetimeSeconds=1800"
{
  "streamToken": "csst_...",
  "expiresInSeconds": 1800,
  "lifetimeSeconds": 1800,
  "maxLifetimeSeconds": 1800,
  "allowedSiteIds": ["site_acd434d3"],
  "streamUrl": "wss://feynman.clickstream.com/signals/stream"
}

There is no API-key or service-account path to mint a stream token. /api/signals/stream-token authenticates with a dashboard session, so a long-running subscriber either holds a valid dashboard session cookie it can replay, or is re-seeded with a fresh token by something that does. A longer requested lifetime makes that re-mint less frequent; it does not remove it. Plan for this before you build on the feed.

Be clear-eyed about what that cookie is. A dashboard session authenticates every dashboard API — billing, exports, deletion, credential minting — not just this one endpoint. It is not a scoped read token, and a scoped mint credential does not exist today. If you park one on a long-running box, scope it to a dedicated low-privilege dashboard user, rotate it, and treat it exactly as you would an account password.

Passing the token

The token rides the WebSocket subprotocol — never the URL — so it stays out of proxy and CDN access logs:

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

The route also accepts ?token= for diagnostics only.

The token is verified before the upgrade, so an expired token never opens a WebSocket that later closes — auth problems are answered with an HTTP status and a JSON body, not a close code. Note that most WebSocket clients (browsers, Node's global WebSocket) do not expose a failed handshake's status or body: they surface it as an error event followed by close code 1006. Use curl -i against the same URL when you need to see which error you actually hit.

Mint errors (GET /api/signals/stream-token)

StatusBody errorMeaning
401UnauthorizedNo valid dashboard session.
404No API credentials foundThe account has no collector API key/client id yet.
400Invalid property scopeA siteId/siteIds value is not [a-zA-Z0-9_-]{1,128}.
404Selected property not foundA requested property is not an owned property in this tenant. Shared sites cannot be scoped into a token.
403Signals Feed streaming is available on Scale and above.Requested properties are not entitled. Body also carries upgradeRequired: "scale" and ineligibleSiteIds.
403No currently eligible properties are available for Signals Feed.No siteId was given and none of your properties qualify.
503Signals Feed is not configured / Signals Feed token service unavailableServer-side; retry with backoff.
503Signals Feed token service returned no tokenThe collector answered 200 but carried no token. Retryable.
500Failed to mint stream tokenUnhandled server error in the mint route. Not a client problem — report it rather than looping on it.

Handshake errors (GET /signals/stream)

All of these are plain JSON returned before the WebSocket upgrade completes.

StatuserrorMeaning
426upgrade_requiredThe request was not a WebSocket upgrade.
400invalid_filterfilter is not one of the six allowed values.
401stream_token_requiredMissing, malformed, wrong-scope, or expired token. The reason field is one of missing_token, malformed_token, bad_signature, wrong_scope, expired, missing_secret.
401invalid_api_keyThe key the token was minted for no longer exists.
403stream_token_client_mismatchToken client id does not match the key's client id.
403domain_not_allowedA browser-originated subscribe from an origin that is neither the ClickStream dashboard nor a registered site domain, using a key without signals:read.
403site_scope_requiredLegacy token with no property scope. Mint a new one.
403site_scope_forbiddenThe subscribing origin is outside the token's scope.
403plan_upgrade_requiredNo property in the token is currently entitled. Body carries currentPlan.
403invalid_or_expired_site_scopeThe scope was empty or already expired when the socket reached the feed.
429too_many_subscribersThe account already holds the maximum concurrent subscribers. Close one, or wait for one to end.
503service_unavailable / feed_unavailableAuth store or feed unavailable; retry with backoff.

Property Scope

A stream token is always scoped to one or more properties, and the feed only delivers events whose siteId is in that scope.

If you are not seeing events you expect, check allowedSiteIds first. A property missing from that list is the most common cause, and it is silent by design — the feed simply never sends those frames.

Pick A Server-Side Filter

You can ask the stream to send all events, or only the lane your subscriber needs. Filtering happens on the server, so a filtered subscriber does not pay to parse traffic it will discard.

wss://feynman.clickstream.com/signals/stream?filter=humans_only
FilterServer-side predicateUse it for
alleverything in scopeFull labeled feed.
humans_onlybot.isBot === false and behavioralClass === "human"Human-only operational alerts and support workflows.
non_humanbot.isBot === true or behavioralClass !== "human"Crawler, monitor, preview, automation, and review traffic.
bots_onlybot.isBot === trueAny bot-classed traffic.
ai_agentsbot.category === "ai_agent"Answer-engine coverage workflows.
search_crawlersbot.category === "search_crawler"Search coverage workflows.

If omitted, the feed behaves like all. Any other value fails the handshake with 400 invalid_filter — the connection is never opened, so a typo shows up immediately rather than as an empty stream.

Copy-Paste: Backend Subscriber

Node 22+ (global fetch and WebSocket, no dependencies). This is the complete pattern: mint, connect, heartbeat, and re-mint when the connection ends.

// subscribe.mjs — node 22+
//
//   CS_DASHBOARD_COOKIE  Cookie header for a logged-in Scale+ dashboard session.
//   CS_SITE_ID           Property id to scope the token to.
//   CS_FILTER            all | humans_only | non_human | bots_only | ai_agents | search_crawlers
//
//   CS_DASHBOARD_COOKIE='...' CS_SITE_ID=site_acd434d3 node subscribe.mjs | jq .

const DASHBOARD = process.env.CS_DASHBOARD || 'https://einstein.clickstream.com';
const COOKIE = process.env.CS_DASHBOARD_COOKIE;
const SITE_ID = process.env.CS_SITE_ID;
const FILTER = process.env.CS_FILTER || 'humans_only';

if (!COOKIE || !SITE_ID) {
  throw new Error('Set CS_DASHBOARD_COOKIE and CS_SITE_ID');
}

let backoffMs = 1_000;

async function mintToken() {
  const url = `${DASHBOARD}/api/signals/stream-token?siteId=${encodeURIComponent(SITE_ID)}`;
  const res = await fetch(url, { headers: { cookie: COOKIE } });
  if (!res.ok) {
    throw new Error(`stream-token mint failed: ${res.status} ${await res.text()}`);
  }
  // { streamToken, expiresInSeconds, lifetimeSeconds, maxLifetimeSeconds,
  //   allowedSiteIds, streamUrl }
  return res.json();
}

async function connect() {
  const { streamToken, streamUrl, expiresInSeconds, allowedSiteIds } = await mintToken();
  console.error(
    `[feed] token ttl=${expiresInSeconds}s scope=${allowedSiteIds.join(',')}`,
  );

  const ws = new WebSocket(
    `${streamUrl}?filter=${encodeURIComponent(FILTER)}`,
    ['clickstream-v1', streamToken],
  );

  ws.addEventListener('open', () => {
    backoffMs = 1_000;
  });

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

    // The server pings every 30s and closes the socket after 60s without a
    // pong. This reply is not optional.
    if (msg.type === 'ping') {
      ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
      return;
    }

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

    // Best-effort warning that the 4003 close is about to happen. It is NOT
    // guaranteed to arrive — the close is the authoritative signal. msg.reason
    // is scope_expired | duration_limit | revalidation_unavailable |
    // scope_revoked; log it, but do not branch your reconnect on it.
    if (msg.type === 'scope_expired') {
      console.error(`[feed] closing (${msg.reason}); reconnect imminent`);
      return;
    }

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

    process.stdout.write(JSON.stringify({
      kind: 'human_threshold_crossed',
      siteId: msg.siteId,
      visitorId: msg.visitorId,
      sessionId: msg.sessionId,
      page: msg.page,
      intent: msg.scores.intent,
      decisionStage: msg.scores.decisionStage,
    }) + '\n');
  });

  ws.addEventListener('close', (event) => {
    // 4003 is the expected steady-state close: the token that opened this
    // socket is spent. Re-mint and reconnect right away; this repeats for as
    // long as the subscriber runs.
    if (event.code === 4003) {
      console.error('[feed] connection ended (4003) — re-minting');
      start(0);
      return;
    }

    // 1000 "Heartbeat timeout" means the pong reply above never arrived.
    // Anything else is unexpected: back off.
    console.error(`[feed] closed code=${event.code} reason=${event.reason || '(none)'}`);
    const wait = backoffMs;
    backoffMs = Math.min(backoffMs * 2, 30_000);
    start(wait + Math.floor(Math.random() * 250));
  });

  ws.addEventListener('error', () => {
    // A close event always follows; reconnect logic lives there only.
  });
}

function start(delayMs = 0) {
  setTimeout(() => {
    connect().catch((err) => {
      console.error(`[feed] connect failed: ${err.message}`);
      const wait = backoffMs;
      backoffMs = Math.min(backoffMs * 2, 30_000);
      start(wait);
    });
  }, delayMs);
}

start();

Reconnect rules

Gaps are unavoidable: the feed keeps no buffer, so events that occur between the close and the next successful connect are lost. See Backpressure And Loss below.

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.

Reuse the connect/reconnect wrapper above with CS_FILTER=non_human, and swap the event handler for this one:

if (msg.type !== 'event') return;
if (msg.bot?.category !== 'automation') return;

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

bot.category comes from a fixed taxonomy: search_crawler, seo_tool, ai_agent, social_preview, monitoring, scraper, scanner, automation, stealth_bot, kiosk, unknown_bot. It is present only when the traffic matched a known pattern.

Wire Format

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

{
  "type": "event",
  "ts": 1713797640000,
  "siteId": "site_acd434d3",
  "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,
  "locale": {
    "language": "en-US",
    "languages": ["en"],
    "primaryLanguage": "en",
    "translatedTo": null,
    "timezone": "America/New_York",
    "country": "US",
    "languageGeoMismatch": false,
    "timezoneGeoMismatch": false,
    "hourCycle": "h12"
  }
}
FieldTypeNotes
type"event"Always "event" for data frames. Other frame types: hello, ping, pong, scope_expired.
tsnumberMilliseconds, set by the feed when it received the batch from the collector. This is not the browser event time.
siteIdstringThe property whose ingest produced this frame. Always one of the token's allowedSiteIds.
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.
pagestringURL path the event fired on, 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 (see the taxonomy above).
bot.namestring?Human-readable non-human agent label when known.
bot.classificationstring?Six-state assessment: verified_bot, likely_bot, suspicious, known_human, likely_human, unknown. Present on events the current classifier labelled.
bot.confidencenumber?0-100 confidence in classification. Not a probability of personhood.
bot.evidencestring[]?Bounded machine-readable reason codes. Never contains raw UA, IP, or header values.
bot.ruleVersion, bot.assessedAtstring?, number?Classifier version and assessment timestamp, for drift analysis.
bot.verdictSourcestring?automatic, operator_override, behavioral_composite, or legacy_cache.
bot.automatedAssessmentobject?The original rule output when an override or behavioral composite changed the effective verdict.
behavioralClass"human" | "suspicious" | "likely_bot" | "bot"Dashboard-matching bucket. Human-only subscribers should filter on this.
device.type"desktop" | "mobile" | "tablet" | "unknown"Parsed from the reported user agent. unknown when the event carried no UA.
scoresobject | null11-field snapshot, same shape as the Signals API. null when scoring was skipped for the event.
scores.sessionMomentumnumber-100 to 100. Every other numeric score is 0 to 100.
hasIdentifiedbooleantrue on every frame in the same delivery batch as an identify event for that session. It is computed per batch, so it does not stay true on later batches in the same session — track identification on your side if you need it to stick.
localeobjectLanguage/timezone intelligence, same struct as VisitorContext.locale. Built on every frame; individual fields are null when the event carried no locale data. locale.language is the full tag (en-US); locale.languages holds up to four deduped base tags (["en"]).

Consent filtering happens upstream: events whose consent.analytics is false — and, on properties configured to require explicit consent, events that never affirmed it — are dropped before scoring, storage, and feed fan-out. They never reach a subscriber.

Binary frames

Frames are JSON by default. A subscriber that can set headers on the WebSocket handshake may send X-Supports-Binary: 1, and the feed will send MessagePack frames instead (hello, ping, scope_expired, and event frames alike). Browser WebSocket and Node's global WebSocket cannot set handshake headers, so this is only reachable from clients that expose them.

Connection Lifecycle

Limits at a glance

LimitValue
Stream token lifetime — default300 seconds
Stream token lifetime — accepted range30 s minimum; 1,800 s ceiling on Scale, 3,600 s on Network and Enterprise
Requested lifetimea hint — validated, clamped to the lowest ceiling across the scoped properties, and reported back as expiresInSeconds / maxLifetimeSeconds
Maximum connection lifetimethe token's expiry, or 60 minutes from connect, whichever is sooner
Revocation window~90 s on connections longer than 5 minutes (re-checked ~every 60 s); otherwise the remaining token lifetime
Heartbeat interval / timeoutping every 30s / close after 60s without a pong
Concurrent subscribers10 per account, across all properties and tokens
Properties per token100
Client-to-server frame size100,000 bytes
Replay after disconnectnone

Quick Health Check

Verify the route is live without opening a full WebSocket:

curl -sS -i "https://feynman.clickstream.com/signals/stream"

Expected: HTTP 426, with this body after the headers:

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

This check needs no token: the upgrade check runs before token verification, so a 426 here proves routing only. Token, scope, and plan problems surface as the JSON bodies in the Handshake errors table above.

Backpressure And Loss

The Signals Feed is an at-most-once stream. There is no durable buffer and no replay:

If you need durable delivery, connect the feed to a queue on your side and bound your own buffer with backpressure. If you need a complete historical record, use raw exports rather than the feed.

See Also