Engineering

A Server-Side Personalization API for Any Backend

There is no server SDK to install. Read the _cs_vid cookie from the incoming request, call GET /v1/signals/:visitorId with a cs_srv_live_* key, and fail open to your default experience — from Node, Python, Go, or anything that speaks HTTPS.

July 2026 • 9 min read • Works from any backend over REST

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 prefixProperty typeOrigin 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 appExempt — native apps send no Origin
cs_srv_live_*ServerExempt — 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:

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

HTTPerrorMeaning
400invalid_visitor_idPath param empty or over 256 chars
400invalid_session_idsessionId over 128 chars
400session_id_requiredHobby-plan read without ?sessionId=
401missing_api_key / invalid_api_keyNo X-API-Key header, or key not found
403origin_requiredDomain-gated browser key used server-side — mint a cs_srv_live_* key
403domain_not_allowedBrowser key with an Origin matching no configured domain
429rate_limit_exceededsignals-read bucket exhausted; honor Retry-After
503service_unavailableTransient 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:

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:

The Bottom Line

The whole integration is four moves, in any language:

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.

Read a Visitor from Your Backend

Install the pixel, mint a server key, and make one GET request from the runtime you already have. If the read times out, your page renders anyway — that's the point.

Start free