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:
- Alert when a human visitor crosses a score threshold on a key page.
- Send high-friction sessions to an internal support queue.
- Mirror labeled events into your warehouse or queue.
- Route automation and QA traffic into a test-coverage dashboard.
- Watch answer-engine and search-crawler coverage in real time.
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:
| Surface | Audience | Scope | Status |
|---|---|---|---|
| Per-visitor realtime stream | Browser/page code and React/Next hydration. | One current visitor/session resolved from first-party cookies. | GET /v1/signals/:visitorId/stream, via subscribeVisitor() |
| Signals Feed | Trusted 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.
| Tier | Read stream |
|---|---|
| Hobby | No |
| Growth | No |
| Scale | Yes |
| Network | Yes |
| Enterprise | Yes |
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:
| Filter | Use it for |
|---|---|
all | Full labeled feed. |
humans_only | Human-only operational alerts and support workflows. |
non_human | Crawler, monitor, preview, automation, and review traffic. |
bots_only | Any bot-classed traffic. |
ai_agents | Answer-engine coverage workflows. |
search_crawlers | Search 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
}
| Field | Type | Notes |
|---|---|---|
type | "event" | Always "event" for data frames. Heartbeat frames use "ping" / "pong"; the "hello" frame fires once on connect. |
ts | number | Server-side timestamp in milliseconds. |
visitorId | string | First-party visitor id. Stable across sessions for the same browser. |
sessionId | string | SDK session id. Resets after 30 minutes of inactivity. |
eventType | string | One of pageview, click, scroll, form, custom, identify. |
page | string | Current URL path, PII-scrubbed before transmit. |
bot.isBot | boolean | Composite verdict from network, registry, and behavioral signals. |
bot.score | number | 0-100 automation likelihood. Higher means less person-like. |
bot.category | string? | Present when traffic maps to a known category such as search_crawler, ai_agent, monitoring, or automation. |
bot.name | string? | 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. |
scores | object | null | Snapshot identical to the Signals API surface. null on unscored events. |
hasIdentified | boolean | true 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
- Hello frame. Right after the 101 handshake completes, the server sends
{"type":"hello","ts":...,"message":"connected to signals feed"}. - Heartbeat. The server sends
{"type":"ping"}every 30 seconds. Echo{"type":"pong"}. The connection is dropped after 60 seconds of silence. - Duration cap. Each subscriber is force-closed after 60 minutes with close code
4008and a final{"type":"duration_limit"}frame. Reconnect with backoff. - Subscriber cap. A single tenant may open up to 10 concurrent subscribers. The 11th handshake returns HTTP 429.
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
- Signals API: poll the per-visitor snapshot from page JavaScript.
- Signals coverage proof: prove coverage, lane separation, and answer-engine gaps.
- Traffic classification: understand human and non-human lanes.
- API keys: how public site keys collect events.