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:
scoresis the same 11-field snapshot the/v1/signals/:visitorIdREST endpoint returns, so one typedef covers both surfaces. It can benullwhen scoring was skipped for an event. TheemotionalStateanddecisionStagestrings are categorical — see Beyond the Score for what they mean.botcarriescategoryandnamefields when the visitor matches one of ClickStream's 158 named bots across 11 categories —"category": "ai_agent", "name": "..."for the 38 recognized AI agents, for example.behavioralClassbuckets each event ashuman,suspicious,likely_bot, orbot— the same four buckets the dashboard's Traffic Quality page uses.eventTypeis what the SDK reported:pageview,click,scroll,form,custom, oridentify;hasIdentifiedflips totrueonce the visitor has calledidentify()this session.localeisnullfor events from older SDKs that carried no locale fields.
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:
all(default) — everythinghumans_only—behavioralClass === "human"and not a bot: the activation railnon_human— the complement: bots plus suspicious trafficbots_only— only events wherebot.isBotis trueai_agents— only recognized AI agents, useful for measuring answer-engine activitysearch_crawlers— only recognized search crawlers
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:
- Close code
4008(duration cap): every connection is hard-capped at 60 minutes; aduration_limitframe precedes the close. Reconnect immediately — no backoff needed, this is scheduled maintenance, not failure. - Close code
1008, or a reason mentioning 401/token: yourcsst_token expired (they live about an hour). Mint a fresh one from/api/signals/stream-tokenbefore reconnecting. - Heartbeat timeout: if your process stalls and misses pongs for 60 seconds, the server closes the connection. The fix is in the example: answer
pingframes promptly, outside any heavy processing path. - Anything else: wait a few seconds (the example defaults to 5, via
CS_RECONNECT_SECS) and redial.
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:
- Ops dashboards. Pipe
humans_onlyinto a live wall: current pages, intent and frustration distributions, identified-visitor counts. ThebehavioralClassfield gives you a live traffic-quality split for free. - Alerting. Watch for
scores.frustration >= 60(the same threshold as theisFrustrated()helper) clustering on a checkout path, or a sudden surge on thebots_onlyrail, and page a human. - Warehouse ingestion. JSONL on stdout is already a loader-friendly format — ship it to object storage and load on your schedule. Because the feed has no replay, treat it as the low-latency lane and pair it with the batch exports (CSV on Growth and above, Parquet raw export) as the durable source of record that backfills any gap.
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:
- Not writable. The feed is read-only and no write surface exists. The server ignores every client message except heartbeat frames and closes any connection that sends an oversized payload. Ingestion happens exclusively through the pixel and SDK.
- Not a queue. No buffering, no replay, no offsets. If you need every event durably, that's what the batch exports are for.
- Not a consent bypass. Events from opted-out visitors are dropped upstream, before the feed ever sees them — every frame you receive has already passed consent filtering.
- Not reachable with a public key. Browser API keys can write events; only a dashboard-minted stream token can read the stream. The two credentials are deliberately non-interchangeable.
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.