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.
Read This First
Two constraints decide the shape of your subscriber. Both are hard and both are shipped:
- 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 code4003. Your subscriber must re-mint a token and reconnect on every close — this is the steady state, not an error path. - 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.
| Surface | Route | Auth | Scope |
|---|---|---|---|
| Per-visitor realtime stream | GET /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.
| Tier | Read stream |
|---|---|
| Hobby | No |
| Growth | No |
| Scale | Yes |
| Network | Yes |
| Enterprise | Yes |
The gate is evaluated per property, in three places:
- At mint. Only properties whose own plan includes Signals Feed can go into a token's scope.
- At connect. Every property id in the token is re-checked against the current collector config; a downgraded, suspended, or removed property is dropped from the scope. If nothing survives, the handshake fails with
403 plan_upgrade_required. - At ingest. Events are fanned out to the feed only for properties whose own plan includes Signals Feed. A Growth property inside a Scale account never reaches the feed, even if some other property in that account is Scale.
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"
}
lifetimeSecondsis a request, not a grant. The server validates it and clamps it to the ceiling for the scoped properties. Omit it and you get the default: 300 seconds.- The accepted range is 30 to 3,600 seconds, and the per-tier ceiling is 1,800 s on Scale and 3,600 s on Network and Enterprise. When a token scopes several properties, the lowest ceiling across them wins.
- Anything the server cannot read as a whole number inside the allowed range — text, a negative, a fraction, an absurd value — quietly yields the default. It never yields the maximum, and it never fails your request.
expiresInSecondsis what you actually got, andmaxLifetimeSecondsis the ceiling that applied. If they differ, you were clamped. Read these fields rather than assuming your request was honoured.- Mint immediately before you connect — the server closes the socket at the token's expiry, so a token minted well before connecting buys a correspondingly shorter connection.
allowedSiteIdsis the scope the server actually granted, which may be narrower than what you asked for. Trust this field, not your request.streamUrlis the endpoint to connect to. Use it rather than assembling a URL by hand.
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)
| Status | Body error | Meaning |
|---|---|---|
| 401 | Unauthorized | No valid dashboard session. |
| 404 | No API credentials found | The account has no collector API key/client id yet. |
| 400 | Invalid property scope | A siteId/siteIds value is not [a-zA-Z0-9_-]{1,128}. |
| 404 | Selected property not found | A requested property is not an owned property in this tenant. Shared sites cannot be scoped into a token. |
| 403 | Signals Feed streaming is available on Scale and above. | Requested properties are not entitled. Body also carries upgradeRequired: "scale" and ineligibleSiteIds. |
| 403 | No currently eligible properties are available for Signals Feed. | No siteId was given and none of your properties qualify. |
| 503 | Signals Feed is not configured / Signals Feed token service unavailable | Server-side; retry with backoff. |
| 503 | Signals Feed token service returned no token | The collector answered 200 but carried no token. Retryable. |
| 500 | Failed to mint stream token | Unhandled 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.
| Status | error | Meaning |
|---|---|---|
| 426 | upgrade_required | The request was not a WebSocket upgrade. |
| 400 | invalid_filter | filter is not one of the six allowed values. |
| 401 | stream_token_required | Missing, malformed, wrong-scope, or expired token. The reason field is one of missing_token, malformed_token, bad_signature, wrong_scope, expired, missing_secret. |
| 401 | invalid_api_key | The key the token was minted for no longer exists. |
| 403 | stream_token_client_mismatch | Token client id does not match the key's client id. |
| 403 | domain_not_allowed | A browser-originated subscribe from an origin that is neither the ClickStream dashboard nor a registered site domain, using a key without signals:read. |
| 403 | site_scope_required | Legacy token with no property scope. Mint a new one. |
| 403 | site_scope_forbidden | The subscribing origin is outside the token's scope. |
| 403 | plan_upgrade_required | No property in the token is currently entitled. Body carries currentPlan. |
| 403 | invalid_or_expired_site_scope | The scope was empty or already expired when the socket reached the feed. |
| 429 | too_many_subscribers | The account already holds the maximum concurrent subscribers. Close one, or wait for one to end. |
| 503 | service_unavailable / feed_unavailable | Auth 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.
?siteId=site_a&siteId=site_bor?siteIds=site_a,site_bselects properties explicitly.- With no
siteId, the token covers every property you own in this tenant that is currently entitled. Sites shared with you by another account are never included — the token is signed for your collector tenant. - A token carries at most 100 property ids. Accounts with more entitled properties must pass
siteIdexplicitly. - The
helloframe echoes the granted scope, so a subscriber can log exactly what it is receiving.
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
| Filter | Server-side predicate | Use it for |
|---|---|---|
all | everything in scope | Full labeled feed. |
humans_only | bot.isBot === false and behavioralClass === "human" | Human-only operational alerts and support workflows. |
non_human | bot.isBot === true or behavioralClass !== "human" | Crawler, monitor, preview, automation, and review traffic. |
bots_only | bot.isBot === true | Any bot-classed traffic. |
ai_agents | bot.category === "ai_agent" | Answer-engine coverage workflows. |
search_crawlers | bot.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
4003— expected, once per connection lifetime. Re-mint a token and reconnect immediately. Do not back off; do not treat it as an error. A reused token will fail, because the token that opened the socket is the one that just expired. The precedingscope_expiredframe carries areason(scope_expired,duration_limit,revalidation_unavailable, orscope_revoked) that says which bound was hit; the close code is4003in every case, so key your handler on the code and log the reason.1000with reasonHeartbeat timeout— your subscriber stopped answeringpingwithin 60 seconds. Fix the pong reply rather than the reconnect loop.1009Message too large— the subscriber sent a frame over 100,000 bytes. The feed is read-only; send nothing butpong.1006— the handshake itself failed. Your client cannot see the HTTP status behind it, so treat1006as "back off and re-mint", and reach forcurl -iwhen a subscriber is stuck in a1006loop: a401 expiredis fixed by re-minting, while403 plan_upgrade_requiredor429 too_many_subscriberswill keep failing until the account, scope, or connection count changes.- Everything else — exponential backoff with jitter.
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"
}
}
| Field | Type | Notes |
|---|---|---|
type | "event" | Always "event" for data frames. Other frame types: hello, ping, pong, scope_expired. |
ts | number | Milliseconds, set by the feed when it received the batch from the collector. This is not the browser event time. |
siteId | string | The property whose ingest produced this frame. Always one of the token's allowedSiteIds. |
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 | URL path the event fired on, 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 (see the taxonomy above). |
bot.name | string? | Human-readable non-human agent label when known. |
bot.classification | string? | Six-state assessment: verified_bot, likely_bot, suspicious, known_human, likely_human, unknown. Present on events the current classifier labelled. |
bot.confidence | number? | 0-100 confidence in classification. Not a probability of personhood. |
bot.evidence | string[]? | Bounded machine-readable reason codes. Never contains raw UA, IP, or header values. |
bot.ruleVersion, bot.assessedAt | string?, number? | Classifier version and assessment timestamp, for drift analysis. |
bot.verdictSource | string? | automatic, operator_override, behavioral_composite, or legacy_cache. |
bot.automatedAssessment | object? | 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. |
scores | object | null | 11-field snapshot, same shape as the Signals API. null when scoring was skipped for the event. |
scores.sessionMomentum | number | -100 to 100. Every other numeric score is 0 to 100. |
hasIdentified | boolean | true 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. |
locale | object | Language/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
- Hello frame. Right after the 101 handshake completes, the server sends
{"type":"hello","ts":...,"message":"connected to signals feed","filter":"...","allowedSiteIds":[...],"expiresAt":"<ISO 8601>","lifetimeSeconds":N}.expiresAtis when this connection will be closed andlifetimeSecondsis how long it has from now — both already account for every clamp, so a client timer built on them agrees with the server. - Heartbeat. The server sends
{"type":"ping","timestamp":...}every 30 seconds. Reply{"type":"pong"}. After 60 seconds with no pong the connection closes with code1000, reasonHeartbeat timeout. - Duration cap. Each subscriber is force-closed with close code
4003, reasonSite scope expired, at whichever comes first: the stream token's expiry, or an absolute ceiling of 60 minutes from connect that no request can raise. Because the granted lifetime is signed into the token, a token minted well before connecting shortens the connection accordingly. scope_expiredframe. When a bound is detected on a heartbeat tick, the server sends{"type":"scope_expired","reason":"..."}immediately before closing. When it is detected while broadcasting an event, the socket closes with4003and no preceding frame. Treat the frame as a courtesy and the close code as the contract.- Timing. The scope check runs on each heartbeat tick and on each broadcast. A socket that is receiving events is closed at the first broadcast after expiry; an idle socket can survive until the next tick. Do not assume the close lands exactly on the granted lifetime.
- Revocation. How fast a revocation lands depends on the lifetime you asked for, and not in the direction most people guess. A connection granted more than five minutes re-reads live client config about once a minute and closes on a downgrade, suspension, property removal, or key rotation — worst case roughly 90 seconds. A connection at or below the five-minute default is not re-checked in flight at all: it is already bounded by its own token, so revocation lands when that token expires and the worst case is its full remaining lifetime. Either way the check fails closed — if the re-read cannot complete for five minutes, the socket is closed with
reason: "revalidation_unavailable"rather than continuing on an authorization nobody could verify. - Subscriber cap. A single account may hold up to 10 concurrent subscribers across all of its properties and all of its tokens — the cap is per account, not per token or per property. The 11th handshake returns HTTP 429 with the JSON body
{"error":"too_many_subscribers"}. Longer-lived connections hold their slot longer, so this cap binds sooner the longer your streams run. - Client to server. The feed is read-only. Send
{"type":"pong"}(and{"type":"ping"}if you want apongback). Anything else is ignored, and any frame over 100,000 bytes closes the connection with code1009.
Limits at a glance
| Limit | Value |
|---|---|
| Stream token lifetime — default | 300 seconds |
| Stream token lifetime — accepted range | 30 s minimum; 1,800 s ceiling on Scale, 3,600 s on Network and Enterprise |
| Requested lifetime | a hint — validated, clamped to the lowest ceiling across the scoped properties, and reported back as expiresInSeconds / maxLifetimeSeconds |
| Maximum connection lifetime | the 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 / timeout | ping every 30s / close after 60s without a pong |
| Concurrent subscribers | 10 per account, across all properties and tokens |
| Properties per token | 100 |
| Client-to-server frame size | 100,000 bytes |
| Replay after disconnect | none |
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:
- Events that arrive while no subscriber is connected are dropped, not queued.
- Events that arrive during the gap between a
4003close and your next successful connect are lost. With a tight reconnect that gap is small, but it is never zero. - A subscriber whose socket stops accepting sends is closed with code
1011, reasonSend failed. Read fast, and buffer on your side rather than in the socket.
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
- 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.