Signals Feed
wss://t.example.com/signals/stream?key=<api_key>
The Signals Feed is a real-time WebSocket stream of labeled events for every visitor your ClickStream tenant sees. Subscribers receive one JSON frame per scored event the collector ingests, in wall-clock order. Typical uses:
- Pipe high-intent, frustrated, and likely-bot events into your own alerting or CRM webhook layer.
- Hydrate a server-side audience store in real time rather than polling
/v1/signals/:visitorId. - Feed a data product that aggregates labeled events across every site your organization operates.
Plan gate
The Signals Feed is a Scale tier (or above) feature. Free and Builder plans get 403 plan_upgrade_required. See the Pricing page for the full feature matrix.
| Tier | Read | Write |
|---|---|---|
| Free | ✗ | ✗ |
| Builder | ✗ | ✗ |
| Scale | ✓ | ✗ |
| Network | ✓ | ✓ |
| Custom | ✓ | ✓ |
Authentication
The WebSocket handshake accepts the API key three ways, in order of precedence:
-
Sec-WebSocket-Protocol(recommended for browsers):Sec-WebSocket-Protocol: clickstream-v1, cs_live_xxxxThe first entry is always
clickstream-v1. The second is your API key. -
?key=query param (convenient for shell tools):wss://t.example.com/signals/stream?key=cs_live_xxxxNote: query-string keys can appear in CDN access logs and browser history. Prefer the subprotocol form for long-lived production deployments.
-
X-API-Keyheader (for wscat / websocat / Node clients):wscat -H "X-API-Key: cs_live_xxxx" -c wss://t.example.com/signals/stream
Invalid keys return HTTP 401 before the upgrade completes; sub-Scale keys return HTTP 403. Both are plain JSON responses, so a well-behaved subscriber can log the error and exit cleanly instead of retrying a hopeless handshake.
Wire format
Each WebSocket frame carries exactly one JSON object. This is a superset of line-delimited JSONL — append "\n" in your subscriber if you want to pipe frames to a JSONL file on disk.
{
"type": "event",
"ts": 1713797640000,
"visitorId": "vis_3f9a...",
"sessionId": "sess_2b1e...",
"eventType": "click",
"page": "/pricing",
"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": 0.42,
"confusion": 9,
"emotionalState": "positive",
"decisionStage": "consideration"
},
"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 (ms since epoch) when the collector received the event. |
visitorId | string | First-party _cs_uid cookie value. Stable across sessions for the same browser. |
sessionId | string | SDK session id. Resets after 30 min of inactivity. |
eventType | string | One of pageview, click, scroll, form, custom, identify. |
page | string | Current URL path (window.location.pathname), PII-scrubbed by the SDK before transmit. |
bot.isBot | boolean | Composite: bot.score ≥ 50 AND humanConfidence < 40. |
bot.score | number | 0–100. Higher = more bot-like. |
bot.category | string? | Present when the UA matches a known bot registry entry (search_crawler, ai_agent, …). |
bot.name | string? | Human-readable name (Googlebot, ChatGPT, …). |
behavioralClass | "human" | "suspicious" | "likely_bot" | "bot" | Dashboard-matching bucket. Subscribers that only want humans should filter on this. |
device.type | "desktop" | "mobile" | "tablet" | Parsed from the SDK's reported user agent. |
scores | object | null | 11-field snapshot identical to the Signals API reference 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 haven't granted analytics consent, or who have opted out, are dropped at the collector before reaching the Signals Feed. Everything you receive is consent-ok.
Connection lifecycle
- Hello frame. Right after the 101 handshake completes, the server sends
{"type":"hello","ts":...,"message":"connected to signals feed"}. Use it to confirm the upgrade worked before you start parsing event frames. - Heartbeat. The server sends a
{"type":"ping"}frame every 30 seconds. Echo it back as{"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 in your client to resume — there is no missed-events guarantee across the gap. A production subscriber should retry with exponential backoff. - Subscriber cap. A single tenant may open up to 10 concurrent subscribers. Above that the 11th handshake returns HTTP 429
Too many subscribers.
Example subscriber
A complete Node subscriber for piping the feed to stdout as JSONL:
# Node 22+ has a native WebSocket class — no npm deps needed.
node examples/signals-feed-subscriber/subscribe.mjs
// examples/signals-feed-subscriber/subscribe.mjs
const API_KEY = process.env.CS_API_KEY;
if (!API_KEY) {
console.error('set CS_API_KEY to a Scale+ tier key before running this script');
process.exit(1);
}
const url = `wss://t.example.com/signals/stream?key=${encodeURIComponent(API_KEY)}`;
const ws = new WebSocket(url);
ws.addEventListener('open', () => {
console.error('[signals-feed] connected');
});
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] duration limit — reconnect');
return;
}
// Only "event" frames go to stdout as JSONL
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}`);
process.exit(event.code === 4008 ? 0 : 1);
});
ws.addEventListener('error', (err) => {
console.error('[signals-feed] error', err);
});
Pipe the stream:
CS_API_KEY=cs_live_xxxx node subscribe.mjs \
| jq 'select(.behavioralClass == "human" and .scores.intent >= 70)' \
> high-intent-humans.jsonl
Quick health check
Verify the route is live for your tenant without opening a full WebSocket:
# No upgrade → the route returns a JSON hint, not a 101.
curl -sS "https://t.example.com/signals/stream?key=$CS_API_KEY"
# → {"error":"upgrade_required","message":"WebSocket upgrade required at /v1/signals/stream"}
# Wrong tier → plain JSON 403 BEFORE the upgrade.
curl -sS -H "Upgrade: websocket" "https://t.example.com/signals/stream?key=$CS_FREE_KEY"
# → {"error":"plan_upgrade_required","currentPlan":"free", ...}
Backpressure and loss
The Signals Feed is an at-most-once firehose. Subscribers that can't keep up (slow reader, saturated downstream) will see the server close their connection with a generic 1011 code and drop subsequent events. There is no built-in buffering or replay.
If you need durable delivery for downstream analytics, connect the feed to a queue (Cloudflare Queues, NATS, Kafka) at the subscriber side and bound your own buffer with backpressure. Durable replay across disconnects is on the Network-tier roadmap.
See also
@clickstream/signalspage-code API — poll the per-visitor snapshot from inside your own page JavaScript.- Event schema reference — the raw shape of events before scoring and labeling.
- API keys — how the tier is resolved from the key.
- Rate limits — per-tier caps and overage behavior.