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:

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.

TierReadWrite
Free
Builder
Scale
Network
Custom

Authentication

The WebSocket handshake accepts the API key three ways, in order of precedence:

  1. Sec-WebSocket-Protocol (recommended for browsers):

    Sec-WebSocket-Protocol: clickstream-v1, cs_live_xxxx
    

    The first entry is always clickstream-v1. The second is your API key.

  2. ?key= query param (convenient for shell tools):

    wss://t.example.com/signals/stream?key=cs_live_xxxx
    

    Note: query-string keys can appear in CDN access logs and browser history. Prefer the subprotocol form for long-lived production deployments.

  3. X-API-Key header (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
}
FieldTypeNotes
type"event"Always "event" for data frames. Heartbeat frames use "ping" / "pong"; the "hello" frame fires once on connect.
tsnumberServer-side timestamp (ms since epoch) when the collector received the event.
visitorIdstringFirst-party _cs_uid cookie value. Stable across sessions for the same browser.
sessionIdstringSDK session id. Resets after 30 min of inactivity.
eventTypestringOne of pageview, click, scroll, form, custom, identify.
pagestringCurrent URL path (window.location.pathname), PII-scrubbed by the SDK before transmit.
bot.isBotbooleanComposite: bot.score ≥ 50 AND humanConfidence < 40.
bot.scorenumber0–100. Higher = more bot-like.
bot.categorystring?Present when the UA matches a known bot registry entry (search_crawler, ai_agent, …).
bot.namestring?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.
scoresobject | null11-field snapshot identical to the Signals API reference surface. null on unscored events.
hasIdentifiedbooleantrue 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

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