Scope note: ClickStream does not publish a dedicated Node server SDK. The Next.js adapter (
@clickstreamhq/next, developer preview) is the only packaged server-side consumer — every other backend talks to the same REST endpoint it wraps. This article is that endpoint, verified against the collector's route code.
Why a Server-Side Personalization API at All?
The browser library, @clickstreamhq/signals, reads visitor context from page JavaScript — after the page has rendered. That's the right place for progressive UI, but three jobs happen before any page JavaScript runs: server-rendered HTML, redirects, and API responses your backend computes for other systems. If a Rails controller, a Django view, or a Go handler wants to know "is this request from a high-intent human or a crawler?" before it writes a byte of response, it needs a server-side personalization API — a read it can make during request handling, with the visitor's cookie as the key.
ClickStream exposes exactly one such read: GET /v1/signals/:visitorId. It returns the same VisitorContext the browser library sees — bot classification, behavioral class, identity status, and the 11-field score snapshot — as plain JSON. The read is a snapshot lookup against a live session store, not a scoring pass: scores were already computed at event-ingestion time by the 26-model pipeline (p95 < 3 ms per event in CI benchmarks), so your request is fetching a precomputed answer. Budget roughly 50–150 ms for the HTTPS round trip.
Step 1: Mint a Server Key (cs_srv_live_*)
ClickStream has four key families, and using the right one is not optional here:
| Key prefix | Property type | Origin gate |
|---|---|---|
cs_live_* | Website (production) | Enforced — reads need a matching Origin/Referer |
cs_test_* | Website (non-production) | Enforced — same rules, advisory prefix for log scrubbers |
cs_mob_live_* | Mobile app | Exempt — native apps send no Origin |
cs_srv_live_* | Server | Exempt — backends send no Origin |
Your backend sends no Origin or Referer header, and the collector enforces browser provenance on website keys: a domain-gated cs_live_* key used from a server is rejected with 403 origin_required before the request touches any visitor data. Create a Server property in the dashboard to mint a dedicated cs_srv_live_* key — it skips domain gating entirely and rides its own, tighter per-key rate-limit bucket instead. The full mechanics of why website keys are safe to publish and server keys are not are in API Keys and Domain Gating.
The corollary cuts both ways: a server key must never ship to a client. Because it's provenance-exempt, anyone holding it can read visitor context from anywhere — treat it like a database credential. Keep it in your secret manager, pass it via the X-API-Key header (never the ?key= query string, which leaks into access logs), and keep the browser on its domain-gated cs_live_* key.
Step 2: Read the Visitor Cookie from the Incoming Request
The lookup key is the visitor ID the ClickStream pixel already stores in the _cs_vid first-party cookie. Your backend doesn't generate anything — it forwards what the browser sends:
_cs_vid— the visitor UUID, and the snapshot key for/v1/signals/:visitorId. No cookie means the pixel hasn't run for this visitor: skip the read and serve your default experience._cs_sid— the current session ID. Pass it as?sessionId=whenever present. It's required on the Hobby plan (400 session_id_requiredwithout it), and it matters on paid plans too: a session-hinted read hits the one Durable Object partition that owns the session and meters one Signals Coverage unit, while an un-hinted read fans out across partitions and meters one unit per partition read.
This only works if the pixel is installed on your site — the server read is the read side of data the browser SDK writes. The Signals overview covers the write side.
Step 3: Call GET /v1/signals/:visitorId — in Node, Python, or Go
Each example does the same four things: read _cs_vid from the request, add the _cs_sid hint, call the endpoint with a hard timeout, and return null on any failure so callers fall through to the default experience. https://t.example.com stands in for your first-party tracking domain.
Node (built-in fetch, no packages)
// Node 18+ — plain fetch, no ClickStream package required.
async function getVisitorContext(req) {
const cookies = req.headers.cookie || '';
const visitorId = /(?:^|;\s*)_cs_vid=([^;]+)/.exec(cookies)?.[1];
if (!visitorId) return null; // pixel never ran -> default experience
const url = new URL(`/v1/signals/${encodeURIComponent(visitorId)}`,
'https://t.example.com');
const sessionId = /(?:^|;\s*)_cs_sid=([^;]+)/.exec(cookies)?.[1];
if (sessionId) url.searchParams.set('sessionId', sessionId);
try {
const res = await fetch(url, {
headers: { 'X-API-Key': process.env.CLICKSTREAM_API_KEY },
signal: AbortSignal.timeout(500), // hard budget: slow -> default
});
if (!res.ok) return null; // 4xx/5xx -> default experience
return await res.json(); // VisitorContext
} catch {
return null; // timeout / network -> default experience
}
}
Python (httpx)
import os
import httpx
def get_visitor_context(request):
visitor_id = request.cookies.get("_cs_vid")
if not visitor_id:
return None # pixel never ran -> default experience
params = {}
session_id = request.cookies.get("_cs_sid")
if session_id:
params["sessionId"] = session_id
try:
res = httpx.get(
f"https://t.example.com/v1/signals/{visitor_id}",
params=params,
headers={"X-API-Key": os.environ["CLICKSTREAM_API_KEY"]},
timeout=0.5, # hard budget: slow -> default
)
if res.status_code != 200:
return None # 4xx/5xx -> default experience
return res.json() # VisitorContext
except httpx.HTTPError:
return None # timeout / network -> default experience
Go (net/http)
// imports: context, encoding/json, net/http, net/url, os, time
func getVisitorContext(r *http.Request) map[string]any {
vid, err := r.Cookie("_cs_vid")
if err != nil || vid.Value == "" {
return nil // pixel never ran -> default experience
}
u := "https://t.example.com/v1/signals/" + url.PathEscape(vid.Value)
if sid, err := r.Cookie("_cs_sid"); err == nil && sid.Value != "" {
u += "?sessionId=" + url.QueryEscape(sid.Value)
}
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
req.Header.Set("X-API-Key", os.Getenv("CLICKSTREAM_API_KEY"))
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil // timeout / network -> default experience
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil // 4xx/5xx -> default experience
}
var visitor map[string]any
if json.NewDecoder(res.Body).Decode(&visitor) != nil {
return nil
}
return visitor
}
The Response, Verified Against the Route Code
A successful read returns the VisitorContext shape — the same serializer feeds the REST route and the browser stream, so the two transports can't drift:
{
"bot": { "isBot": false, "score": 4 },
"behavioralClass": "human",
"identity": {
"status": "anonymous",
"visitorId": "vis_9f2c…",
"clickstreamId": "vis_9f2c…",
"mergedClickstreamIds": [],
"isReturning": true,
"hasIdentifiedThisSession": false
},
"scores": {
"intent": 74, "frustration": 12, "engagement": 61, "value": 38,
"churn": 9, "abandonment": 22, "conversionReadiness": 68,
"sessionMomentum": 31, "confusion": 8,
"emotionalState": "decisive", "decisionStage": "deciding"
},
"session": { "sessionId": "ses_41d0…", "durationMs": 412000, "pagesInSession": 6 },
"device": { "type": "desktop", "browser": "Chrome", "os": "macOS", "isMobile": false },
"locale": null,
"snapshotAt": "2026-07-28T14:02:11.000Z",
"snapshotVersion": "1785592931000",
"ageMs": 1840,
"stale": false,
"transport": "rest",
"coverageMode": "full"
}
(Values illustrative; the shape is the contract.) bot.category and bot.name appear when the network-level classifier matched — e.g. "search_crawler" / "Googlebot". scores is the public 11-field snapshot: nine scalar scores plus the categorical emotionalState and decisionStage, covered in Beyond the Score. Dashboard-side analytics like LTV bands live in the dashboard, not in this API. Server-side you don't get the browser library's helper functions, so mirror their canonical thresholds in your own code: high intent is scores.intent >= 70, frustrated is scores.frustration >= 60, and human-only actions should check both !bot.isBot and behavioralClass === 'human'.
Fresh Visitors Are Not Errors
A visitor the live session store hasn't scored yet — first event still propagating, or a session that aged out — returns 200 with a pending placeholder, never a 404: all scores zero, behavioralClass: "suspicious", stale: true, pending: true, and a machine-readable reason: "visitor_initializing". The placeholder is deliberately conservative so score-gated actions fail closed while the page itself renders normally. If your Signals Coverage budget for the billing period is exhausted, the same placeholder arrives with reason: "signals_coverage_limit_reached" and coverageMode: "degraded" — check reason if you need to tell the two apart. Every response also carries X-ClickStream-Signals-Coverage-Period/-Limit/-Used/-Mode headers so you can watch the budget from your own logs.
Error Codes You Will Actually See
| HTTP | error | Meaning |
|---|---|---|
| 400 | invalid_visitor_id | Path param empty or over 256 chars |
| 400 | invalid_session_id | sessionId over 128 chars |
| 400 | session_id_required | Hobby-plan read without ?sessionId= |
| 401 | missing_api_key / invalid_api_key | No X-API-Key header, or key not found |
| 403 | origin_required | Domain-gated browser key used server-side — mint a cs_srv_live_* key |
| 403 | domain_not_allowed | Browser key with an Origin matching no configured domain |
| 429 | rate_limit_exceeded | signals-read bucket exhausted; honor Retry-After |
| 503 | service_unavailable | Transient auth-store blip; Retry-After: 5 |
Signals reads live in their own signals-read rate bucket, separate from event ingestion, and responses carry X-RateLimit-Remaining so you can alert before hitting 429s.
Caching: Request-Scoped First, Short TTL Second
The snapshot describes a live session, so it goes stale in seconds, not hours. Three rules keep caching honest:
- Memoize per request. One inbound page request should trigger at most one Signals read, however many components consult the result. This is exactly what the Next.js adapter's middleware prefetch does — fetch once, hand the context down — and it's worth copying in any framework.
- Cross-request, cache per
(visitorId, sessionId)with a TTL of a few seconds. The browser client polls at a 2-second default for the same reason. The response'ssnapshotVersionandageMstell you how fresh what you're holding is. - Never cache at the CDN or across visitors. The response is keyed to one visitor and authenticated by your server key; a shared cache turns personalization into cross-visitor data leakage.
And a scope guardrail worth writing into the code review checklist: use scores to change emphasis — which banner, which help prompt, which support queue gets priority — never to change the offer. Everyone sees the same prices. Score-gated pricing is the fastest way to turn a personalization API into a trust problem.
Fail Open: the Timeout Is the Design
Every example above wraps the call in a ~500 ms budget and returns null on any failure. That's not defensive boilerplate — it's the contract. A personalization read must never be the reason a page didn't render: if ClickStream is slow, unreachable, rate-limited, or mid-deploy, your handler serves the default experience and nobody notices. The server-side rule mirrors the browser-side one — same philosophy, catalogued failure mode by failure mode, in Fail-Open Personalization Patterns. Two server-specific additions:
- Don't retry inline. A 429 or 503 during page render should fall through to the default, not block the response for a second attempt. Retry-with-backoff belongs in background jobs, not request handlers.
- Treat the placeholder as a null-equivalent. A
pending: truecontext passes no score gate (everything is zero), soif (visitor && !visitor.bot.isBot && visitor.scores.intent >= 70)behaves correctly without special-casing — but logreasonwhen you seesignals_coverage_limit_reached, because that state persists for the billing period.
The Bottom Line
The whole integration is four moves, in any language:
- Mint a
cs_srv_live_*server key — browser keys get403 origin_requiredserver-side, and server keys never ship to clients. - Read
_cs_vid(and_cs_sid) from the incoming request's cookies — no cookie, no read, default experience. - Call
GET /v1/signals/:visitorId?sessionId=…withX-API-Key— one round trip, ~50–150 ms, JSON out. - Fail open on a hard timeout —
nullmeans "serve the default," and the default is always safe.
If you're on Next.js, the adapter packages these exact steps as middleware plus getServerVisitor(). Everyone else: the three functions above are the entire integration — no SDK, no vendor lock, just a cookie and an HTTPS GET.
A server-side personalization API earns its place in the request path only if it can disappear from it. Budget the round trip, gate on real fields, and let the timeout render the default.