API keys + auth

ClickStream uses API keys scoped to a single tenant (clientId). Public website keys collect events and read per-visitor Signals snapshots from approved domains. Tenant-wide live streams use short-lived, dashboard-minted stream tokens instead, so a key copied out of website source cannot read live visitor activity.

Key format

cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx      ← production website key
cs_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx      ← test / non-production website key
cs_mob_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx      ← dedicated Mobile app key
cs_srv_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx      ← dedicated Server key

The cs_live_ / cs_test_ prefix is advisory — tools that scrub secrets from logs (pre-commit hooks, CI masking, sentry filters) pattern-match on it so production keys don't accidentally end up in error tickets. The collector treats both prefixes identically at runtime.

Key types and the provenance gate

Each key carries a keyType that decides whether the browser Origin/Referer gate applies. The key type is set by the property type you create in the dashboard:

Property typeKeykeyTypeOrigin gate
Websiteshared account key (cs_live_*)browser (the absent default)Enforced — browser writes and Signals reads need a matching Origin/Referer.
Mobile appdedicated cs_mob_live_*mobileExempt — native apps post directly with no Origin.
Serverdedicated cs_srv_live_*serverExempt — backends post directly with no Origin; event writes require a timestamped HMAC signature.

A key with no keyType is treated as browser. Mobile and server keys are minted on a dedicated property (no first-party domain, no DNS) and get a tighter per-key rate-limit bucket. Because they are domain-less, the collector skips domain validation entirely and keys the provenance exemption off the key type.

The exemption is gated on key posture, not on the user agent. A library UA like OkHttp or CFNetwork is expected under a mobile/server key, but the same UA under a website key still forces a bot classification — a copied website key cannot escape detection by spoofing a native UA. See Mobile apps.

Only one key type is safe in a browser

The website key (cs_live_* / cs_test_*) is the only credential you may put in client-side JavaScript. It is designed to be public: it lives in your page's data-key attribute, anyone can read it from view-source, and the collector protects it with the Origin/Referer gate rather than with secrecy.

cs_mob_live_* and cs_srv_live_* keys are the opposite. They are provenance-exempt, so possession alone is sufficient to write events — treat them exactly like a database password:

Stream tokens (csst_*) are short-lived and minted only for an authenticated dashboard session; they are not an alternative to a key.

Key metadata

Every key carries metadata, including permission labels and a plan tier. You don't configure the storage layout yourself — the dashboard mints the account key during signup and a dedicated key when you create a mobile/server property, then shows it on the Sites and Settings screens. For reference, the key metadata stored by the collector looks like this:

{
  "clientId": "your-client-id",
  "name": "Your Company",
  "plan": "builder",
  "rateLimit": 1000,
  "createdAt": "2026-04-01T00:00:00Z",
  "sites": [
    {
      "siteId": "main",
      "name": "Main Site",
      "domains": ["example.com", "*.example.com"]
    }
  ]
}

plan stores the internal tier id, not the public label. The two differ, and internal ids leak into API responses — a 429 over your monthly pageview allowance returns "upgradeRequired": "builder", which is the tier the Pricing page calls Growth:

Internal idPublic label
freeHobby
builderGrowth
scaleScale
networkNetwork
customEnterprise

Dashboard-minted keys omit permissions entirely. An absent or empty array means unrestricted — the scope check below only applies to keys that were deliberately minted with a non-empty list.

Every authenticated collector response carries the full rate-limit state (X-RateLimit-Limit, -Burst-Limit, -Policy, -Scope, -Unit, -Remaining, -Burst-Remaining, -Reset) so you can budget without querying the dashboard. See Rate limits.

Permission scopes

LabelWhere the collector enforces it
events:writeRequired on POST /v1/events and POST /v1/edge/pageview when the key carries a non-empty permissions array. Missing it returns 403 insufficient_permission.
live:readRequired on GET /v1/live/sessions — a key without it gets 403 live_read_forbidden. The live-session WebSocket (GET /live/ws) additionally requires a stream token, and accepts either this scope or a trusted ClickStream dashboard origin.
signals:readSame shape for the tenant-wide Signals Feed WebSocket at GET /signals/stream: without it the connection must come from a trusted dashboard origin.
signals:streamGrants the per-visitor realtime WebSocket (GET /v1/signals/:visitorId/stream) regardless of tier. Without it, realtime access is decided by the matched property's plan (Scale and above).
events:readAccepted and stored, but no collector endpoint reads it today.

Tier-based feature flags (see the Pricing page) gate Signals independently of these labels: Hobby keys can read /v1/signals/:visitorId for page-code snapshots, while only Scale+ accounts can mint Signals Feed stream tokens.

Passing the key

Collector API calls accept a website or mobile key two ways, in order of precedence:

  1. X-API-Key header — use this everywhere you can:
    X-API-Key: cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    
  2. ?key= query param, because navigator.sendBeacon() cannot set headers:
    POST https://t.example.com/v1/events?key=cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    

Separately, the SDK loader URL accepts ?key= as a configuration fallback for the data-key attribute (https://t.example.com/sdk/v2.js?key=cs_live_...). That is not authentication: serving the SDK file is not key-gated. Enforcement happens at ingestion — an invalid key or an unregistered domain fails at POST /v1/events, not when the script loads.

Tenant-wide WebSocket live surfaces do not accept public site keys. They use short-lived stream tokens sent as Sec-WebSocket-Protocol: clickstream-v1, <stream_token> (GET /live/ws also reads ?token= for diagnostics). A missing or invalid token returns 401 stream_token_required. The per-visitor browser stream at /v1/signals/:visitorId/stream is different: it uses the public site key, the active sessionId, domain gating, plan gating, and Signals Coverage metering.

Server-key event writes accept the key only in X-API-Key and require all of these headers:

Node.js applications should use @clickstreamhq/node, which implements this byte-level contract and preserves the body and idempotency key across bounded retries. The raw protocol below is for other server runtimes.

X-API-Key: cs_srv_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
Idempotency-Key: billing-renewal-01JXYZ
X-CS-Timestamp: 1785686400
X-CS-Signature: v1=<64-lowercase-hex-hmac>

Build the HMAC-SHA256 input as the exact byte sequence v1\n<unix-seconds>\nPOST\n/v1/events\n<trimmed Idempotency-Key or empty>\n<exact wire body>, using the server API key as the HMAC secret. The timestamp must be within five minutes of the collector. If the request is gzipped, sign the compressed bytes. Persist and reuse the same Idempotency-Key and exact body on a network retry; changing either invalidates the signature, while an exact replay is deduplicated by the durable event ledger.

Query strings can end up in CDN access logs, browser history, and referrer headers. Keep opaque server keys in a secret manager and never place them in a URL.

Domain gating

If a website key has configured domains (ClientConfig.sites[].domains), browser-facing event writes and Signals snapshot reads must carry an Origin or Referer header matching one of them. The 403 domain_not_allowed response is what you see when the origin doesn't match.

A registered domain already covers every subdomain. example.com matches example.com, www.example.com, and app.example.com with no wildcard involved. Explicit *.example.com entries are still accepted for back-compat, but they add nothing — the collector strips the leading *. and matches on the same base host either way. A domain registered as www.example.com also covers the apex example.com and its sibling subdomains, because tracking follows the root domain regardless of which host was registered.

The boundary is a leading dot, so notexample.com never matches example.com.

Mobile (cs_mob_live_*) and server (cs_srv_live_*) keys skip domain gating — they have no configured domains and are provenance-exempt, so native apps and backends send no Origin header at all. Server event writes use the signed-request contract above in place of browser provenance.

Signals Feed and live-session WebSockets require short-lived stream tokens, which are minted from an authenticated dashboard session — there is no API-key path to a stream token. If a browser-style Origin is present, it must be a trusted ClickStream dashboard origin unless the key behind the token carries the matching private read scope (live:read / signals:read).

Creating a key

There is one account-wide website key, minted during signup. Every website property you create shares it, and the property's registered domains are what separate them. You can copy it from Sites or Settings.

Creating a Mobile app or Server property mints a new dedicated key for that property on the spot — no support ticket. That is the only self-serve way to get an additional key.

There is no API for minting keys: every /api/sites/* route on the dashboard requires a signed-in browser session, so key creation is a dashboard action. Store what you get in your deployment's secret manager (Vercel environment variable, GitHub Actions secret, 1Password, Doppler, etc.).

Rotating a key

Rotating the account-wide website key is support-assisted:

  1. Email support@clickstream.com with the account that needs rotation.
  2. Deploy the new key to every surface that uses it (SDK config, server env vars, CI secrets).
  3. Allow time for cached page loads to pick up the new key — a browser holding a cached SDK bundle keeps sending the old one.
  4. Ask support to revoke the old key once traffic has drained.

A mobile or server property key has its own owner-only rotation path that mints the replacement, registers it, and revokes the old key. Because these keys are provenance-exempt, rotation is the only remedy when one leaks — restricting domains does nothing.

Caching notes: the collector caches key config for 2 minutes per isolate, and caches a failed lookup for 30 seconds (so a newly created key can 401 for up to 30 seconds if you called with it too early).

Rotation takes effect globally within about 3 minutes of the config write: up to ~60 s for Cloudflare KV edge propagation, plus the 2-minute per-isolate config cache on top of it. Plan the overlap window accordingly — do not revoke the old key on the assumption that the new one is live everywhere at 2 minutes. Account suspension propagates faster (~60 s typical, ≤90 s worst case) because cached configs older than 30 seconds are re-read from KV before being served.

Error taxonomy

Every error returns a JSON body with an error code and a human-readable message. Some carry extra fields, noted below.

Auth-layer errors

These are raised by the auth middleware before your request reaches any endpoint handler.

HTTPerrorRetry?Meaning
401missing_api_keyNoNo X-API-Key header and no ?key= query param.
401invalid_api_keyNoKey not found. A brand-new key can return this for up to 30s at an edge location that already cached the miss.
401server_signature_requiredNoA server-key event write omitted X-API-Key, X-CS-Timestamp, or X-CS-Signature. Carries WWW-Authenticate: ClickStream-HMAC realm="events", version="v1".
401server_signature_expiredNoX-CS-Timestamp is outside the five-minute replay window. Re-sign the unchanged request with the current time.
401invalid_server_signatureNoThe signature format is invalid, or it does not match the timestamp, method, path, Idempotency-Key, and exact wire body.
403account_suspendedNoThe account, or the specific property this request resolved to, is suspended or blocked.
403insufficient_permissionNoThe key was minted with an explicit permissions list that omits events:write, and the request is an ingest write.
403origin_requiredNoThe key has configured domains and a browser-facing call arrived with no Origin or Referer. Applies to POST /v1/events, POST /v1/replay, and GET /v1/signals/*.
403domain_not_allowedNoOrigin/Referer hostname matches no configured domain. Loopback hosts (localhost, 127.0.0.1, ::1, *.localhost) are always allowed.
403site_scope_ambiguousNoA write to /v1/events, /v1/edge, /v1/replay, or /v1/consent was allowed through the domain gate (typically from localhost) but matched no registered property, on a key covering more than one property — so ClickStream cannot attribute it. Single-property keys bind automatically and never see this.
429rate_limit_exceededYesRequest-rate bucket exhausted. Retry-After and the full X-RateLimit-* set carry the cooldown. Body also has retryAfter (seconds).
503service_unavailableYesKey-config read failed. Transient; Retry-After: 5.

Endpoint errors you also have to handle

Auth is not the only place a well-formed request fails. These come from the endpoint handlers:

HTTPerrorEndpointMeaning
400idempotency_requiredPOST /v1/events, POST /v1/edge/pageviewProduction refuses events with no stable id. See Event schema and Rate limits.
400validation_errorPOST /v1/events, POST /v1/edge/pageviewSchema rejection. A single event (and edge capture) returns details; a batch where every event failed returns errors with per-index issues. A batch where only some failed is a 202, not a 400.
400prohibited_payment_dataPOST /v1/events, POST /v1/edge/pageviewPayment-instrument material was detected. The whole request is refused before any storage; do not retry the same payload.
402signals_coverage_limit_reachedGET /v1/signals/:visitorId/streamSignals Coverage exhausted for the period. Fall back to polling.
403plan_upgrade_requiredGET /v1/signals/:visitorId/stream, GET /signals/streamThe matched property's plan lacks the feature. Body carries currentPlan as an internal tier id.
403live_read_forbiddenGET /v1/live/sessions, GET /live/wsKey lacks live:read and the request is not from a trusted dashboard origin.
401stream_token_requiredGET /live/ws, GET /signals/streamNo valid csst_ stream token in the Sec-WebSocket-Protocol header. Body carries reason.
409idempotency_conflictPOST /v1/eventsAn event id was reused with different content.
413payload_too_large / batch_too_largePOST /v1/eventsSee the request limits on Event schema.
426upgrade_requiredGET /v1/signals/:visitorId/stream, GET /signals/streamThe request was plain HTTP on a WebSocket-only route.
429usage_limit_exceededPOST /v1/events, POST /v1/edge/pageviewMonthly pageview budget reached. This is a billing stop, not an outageRetry-After: 3600, X-ClickStream-Billing-Mode: blocked, and accepted: 0. Retrying does not help until the period rolls or the plan changes.
429accepted_event_safety_ceiling_reachedPOST /v1/eventsThe non-pageview accepted-event safety ceiling was reached. Same shape as above, with X-ClickStream-Budget-Type: accepted-events.
503usage_metering_unavailablePOST /v1/events, POST /v1/edge/pageviewUsage counters could not be read, so nothing was accepted rather than accepting unmetered traffic. Retryable; Retry-After: 5.
503storage_unavailablePOST /v1/eventsNothing was accepted. Retry the complete batch with the same event ids.
503projection_pending / usage_projection_pendingPOST /v1/events, POST /v1/edge/pageviewYour events are durable; a downstream projection did not confirm. Retry the same event ids — idempotency makes it a no-op for anything already accepted.

X-Request-ID is set on every response so you can correlate a client error to a specific collector log line in Logpush. Send your own X-Request-ID and it is echoed back.

See also