Signals API

Signals is the part of ClickStream your site can read while a visitor is still on the page. It answers questions like:

The Signals client reads a VisitorContext snapshot from your first-party tracking domain. The same shape is used by browser page code, React hooks, Next.js helpers, the REST endpoint, and the real-time feed.

Machine-readable signal catalog

Do not hard-code a private field list. Authenticated tools can discover the canonical, versioned contract at:

GET https://einstein.clickstream.com/api/signals/catalog
GET https://einstein.clickstream.com/api/signals/catalog?siteId=<property-id>

The global response describes every cataloged signal's source, type, freshness, confidence kind, sensitivity, supported platforms, retention class, surfaces, tier matrix, conditions, and introduction version. Supplying an in-scope property id adds its property type, normalized ingestion platform, and current server-authoritative state: available, restricted, masked, stale, or unavailable. Platform support is resolved before tier access, so browser-only performance timings are unavailable for mobile/server properties and website/mobile campaign signals are unavailable for server properties. A restricted sensitive signal still requires purpose/consent and password reauthentication at the record endpoint; catalog discovery never bypasses those controls.

Raw-event export manifests include signalCatalogVersion, so downstream jobs can pin their interpretation to the same contract. Additive catalog changes bump the version; removals require a documented deprecation period.

For traffic that does not run JavaScript, pair the browser SDK with Edge capture. That is how search crawlers, answer agents, previews, monitoring checks, and automation runs get measured before HTML is returned.

Real-Time Signals Loop

The browser client reads snapshots with getVisitor(), getVisitorOrNull(), onVisitor(), and waitFor(). For applications that need page behavior to react inside the same visitor session, subscribeVisitor() opens a cost-bounded visitor stream and falls back to polling when the stream cannot be held open.

The loop has six pieces:

PieceContractStatus
Per-visitor realtime streamSubscribe to one visitor/session and receive updated VisitorContext frames as accepted events change scores.subscribeVisitor() / onVisitorRealtime()
Event-to-signal acknowledgementTrack an event, flush it immediately, then read a fresh or safe fallback snapshot.window.clickstream.trackEventNow() + getVisitorOrNull()
Freshness metadataEvery snapshot exposes when it was scored, which event advanced it, and whether the client reused stale-but-safe data.snapshotVersion, sourceEventId, ageMs, stale, transport
Effects/apply helpersConvert a visitor context into DOM-safe effects with dedupe and fail-open defaults.@clickstreamhq/signals/effects
Pre-paint Next usageResolve a snapshot or safe fallback before first paint on selected routes with visitor + session cookies.@clickstreamhq/next

Per-Visitor Realtime Stream

The stream is scoped to the current browser visitor and session, not the tenant-wide Signals Feed. It requires sessionId, attaches to one Durable Object partition, caps at five minutes, closes after two idle minutes, and reserves 300 Signals Coverage units when opened. Access is gated on the matched property's own plan — Scale, Network, or Enterprise — or on a private key carrying the signals:stream scope; everything else stays on polling.

Rejections arrive in-band, not as an HTTP error. When the plan gate, the Signals Coverage budget, or partition stream capacity (10 visitor streams per partition) blocks a stream, the server still completes the 101 upgrade, sends one { "type": "fallback", ... } frame carrying error/reason (plan_upgrade_required, signals_coverage_limit_reached, or stream_capacity_reached), and closes with 1000. subscribeVisitor() reads that frame and switches to polling automatically.

import { configure, subscribeVisitor } from '@clickstreamhq/signals';

configure({
  apiKey: 'cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
  endpoint: 'https://t.example.com',
});

const sub = subscribeVisitor(
  (visitor) => {
    if (visitor.stale) return;
    if (!visitor.bot.isBot && visitor.scores.conversionReadiness >= 70) {
      showHighIntentAction();
    }
  },
  {
    fallbackToPolling: true,
    onError(error) {
      console.warn('Signals stream unavailable; keeping default UI', error);
    },
  },
);

// sub.unsubscribe();

Stream frames from the edge look like:

type VisitorStreamFrame = {
  type: 'visitor';
  /** 'init' for the snapshot sent at open, 'event' for later updates. */
  reason: 'init' | 'event';
  ts: number;
  visitorId: string;
  sessionId: string;
  context: VisitorContext;
  snapshotVersion: string;
  sourceEventId?: string;
};

The stream also sends hello (with maxDurationMs and idleTimeoutMs), waiting, ping, and — before the server closes it — session_limit or stream_idle.

If you write your own WebSocket client instead of using subscribeVisitor(), handle the real close codes: 4008 after a {"type":"session_limit"} frame at the five-minute duration cap, and 4000 after {"type":"stream_idle"} at the two-minute idle cap. Both mean "reopen when the page needs it again"; subscribeVisitor() treats them as a signal to fall back to polling rather than reconnect in a loop. These are not the same codes as the tenant-wide Signals Feed, which closes with 4003.

subscribeVisitor() options: visitorId, sessionId, fallbackToPolling (default true), reconnect (default true), maxReconnects (default 2), reconnectDelayMs (default 1000), maxReconnectDelayMs (default 10000), onStatus, and onError.

Event-To-Signal Flush

When a UI event should immediately influence page behavior, flush the event before reading. That avoids a race where the app tracks a click, then reads the previous snapshot before scoring catches up.

await window.clickstream?.trackEventNow({
  name: 'pricing_compare_opened',
  category: 'intent',
  value: 1,
});

const visitor = await getVisitorOrNull();
if (visitor && !visitor.stale && !visitor.bot.isBot && visitor.scores.intent >= 70) {
  showComparisonConcierge();
}

Freshness Metadata

Freshness tells the app whether a decision used the latest accepted event, a recent snapshot, or a safe stale fallback.

visitor.transport;       // 'rest' | 'stream' | 'cache' | 'stale'
visitor.snapshotVersion; // usually the producing event timestamp
visitor.sourceEventId;   // client-generated event id when supplied
visitor.ageMs;           // approximate client-observed snapshot age
visitor.stale;           // true when reusing stale-but-safe data
visitor.pending;         // true while the first scored event is catching up

The REST response also carries a top-level coverageMode alongside the VisitorContext fields:

response.coverageMode;   // 'full' | 'degraded'

coverageMode reports whether the account still has Signals Coverage budget for this billing period. 'full' is the normal path; 'degraded' means the included coverage is exhausted (and overage billing is not enabled), so ClickStream returns an initializing placeholder snapshot (pending: true, stale: true, zeroed scores) instead of a live read. The same signal is mirrored on the X-ClickStream-Signals-Coverage-Mode response header. Treat 'degraded' like stale — keep the default experience and do not fire human-only actions.

if (visitor.stale) {
  keepDefaultExperience();
}

Effects And Apply Helpers

Effects are named decisions that sit between raw scores and UI mutation. They make app behavior testable because you can assert "which effect was chosen" without coupling tests to every score threshold.

import { applySignals, effects } from '@clickstreamhq/signals/effects';

applySignals([
  { when: { conversionReadinessMin: 70, isBot: false }, once: true, run: effects.show('[data-offer]') },
  { when: { frustrationMin: 60 }, run: effects.addClass('[data-help]', 'is-visible') },
], { realtime: true });

Effects are intentionally small DOM mutations. Keep default UI in place and let rules enhance it only when a fresh enough snapshot matches.

Testing Pattern

Use your own first-party domain (for example, example.com with https://t.example.com) whenever an example or test needs a customer site domain:

configure({
  apiKey: process.env.NEXT_PUBLIC_CLICKSTREAM_KEY!,
  endpoint: 'https://t.example.com',
});

For browser-style tests, load a page on https://example.com, install the SDK against https://t.example.com, trigger one event with window.clickstream.trackEventNow(), then assert the Signals read or realtime subscription fails open when credentials, billing coverage, or WebSockets are unavailable.

The Big Idea: Lanes

A lane is a safe bucket for deciding what to do next. ClickStream labels traffic; your app decides how to treat each lane.

LaneWhat it meansGood use
Human interactionA real visitor is interacting with the site.Show human-only UI, route support, personalize application flows, measure human usage.
AI/search coverageA crawler or answer agent is reading pages.Serve structured content, measure which pages are visible to discovery systems.
Preview/accessibilityA link preview or renderer is asking for page metadata.Keep cards, titles, images, and accessible summaries healthy.
Monitoring/site healthA scheduled check is testing availability.Keep uptime signals separate from human usage.
Automation/QABrowser automation or test traffic is exercising flows.Measure test coverage, flag broken journeys, avoid counting tests as people.
Review/securityScanner-like or scraper-like traffic needs review.Send to security logs or investigation queues without mixing it into human metrics.

Do not discard non-human lanes. They are useful evidence, but they should not trigger human-only actions.

Install

pnpm add @clickstreamhq/sdk @clickstreamhq/signals

Signals reads the visitor/session created by the ClickStream browser pixel. Install the pixel first, then configure Signals near your site entry point:

import { installClickstreamPixel } from '@clickstreamhq/sdk';
import { configure } from '@clickstreamhq/signals';

installClickstreamPixel({
  apiKey: 'cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
  endpoint: 'https://t.example.com',
  replay: true,
});

configure({
  apiKey: 'cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
  endpoint: 'https://t.example.com',
});

endpoint is your first-party tracking domain from DNS setup. When you omit endpoint, the client falls back to the shared production collector at https://feynman.clickstream.com — that works out of the box for quick tests, but production sites should set endpoint to their verified first-party tracking domain so Signals reads share the same origin and cookies as event ingestion.

If you already installed the script tag directly in HTML, only configure Signals:

<script
  src="https://t.example.com/sdk/v2.js"
  data-key="cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  async
></script>
import { configure } from '@clickstreamhq/signals';

configure({
  apiKey: 'cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
  endpoint: 'https://t.example.com',
});

Signals resolves the visitor id in this order:

  1. window.clickstream.getVisitorId() — the SDK bridge. Required on CNAME proxy sites where _cs_vid is an HttpOnly server cookie that page JavaScript cannot read.
  2. _cs_vid cookie — the SDK's visitor UUID.
  3. _cs_vid in localStorage — the SDK's dual-storage fallback when cookies are blocked or deleted.
  4. _cs_uid cookie — last resort for legacy @clickstreamhq/sdk/core installs. On full-SDK sites _cs_uid holds the cs_* cross-site profile id, which is not a snapshot key, so it never wins over _cs_vid.

The session id comes from the bridge or the _cs_sid cookie. If no visitor id can be resolved yet, getVisitor() throws and getVisitorOrNull() returns null.

Copy-Paste: Pick A Lane In Page Code

import { configure, getVisitorOrNull } from '@clickstreamhq/signals';

configure({
  apiKey: 'cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
  endpoint: 'https://t.example.com',
});

const visitor = await getVisitorOrNull();

if (!visitor) {
  showDefaultExperience();
} else if (!visitor.bot.isBot && visitor.behavioralClass === 'human') {
  if (visitor.scores.intent >= 70 || visitor.scores.conversionReadiness >= 70) {
    showHumanOnlyAction();
  } else {
    showDefaultExperience();
  }
} else if (
  visitor.bot.category === 'ai_agent' ||
  visitor.bot.category === 'search_crawler'
) {
  renderStructuredContentHints();
} else if (visitor.bot.category === 'automation') {
  markQaRun(visitor);
} else {
  showAccessibleStaticFallback();
}

Keep the fallback boring and accessible. If Signals cannot load yet, your page should still work normally.

Application Patterns

Signals should change the lane, message, timing, or structure of an experience. It should not become a hard dependency for the page to render.

Good uses:

Avoid:

Fail-open wrapper

Use this pattern when a business-critical page depends on Signals but should never break because of it:

import { getVisitorOrNull } from '@clickstreamhq/signals';

export async function chooseExperience() {
  const ctx = await getVisitorOrNull();

  if (!ctx) {
    return { lane: 'default', variant: 'normal' };
  }

  if (ctx.bot.isBot) {
    return {
      lane: ctx.bot.category ?? 'non_human',
      variant: ctx.bot.category === 'ai_agent' ? 'structured' : 'static_accessible',
    };
  }

  if (ctx.scores.frustration >= 60) return { lane: 'human', variant: 'help_first' };
  if (ctx.scores.conversionReadiness >= 70) return { lane: 'human', variant: 'high_intent' };
  return { lane: 'human', variant: 'normal' };
}

Keep the decision explainable

When Signals changes UI, store the reason in your own analytics or component state:

const ctx = await getVisitorOrNull();
const decision = {
  variant: 'default',
  reason: 'signals_unavailable',
};

if (ctx?.bot.isBot && ctx.bot.category === 'ai_agent') {
  decision.variant = 'structured_facts';
  decision.reason = 'answer_engine_lane';
} else if (ctx && !ctx.bot.isBot && ctx.scores.frustration >= 60) {
  decision.variant = 'help_prompt';
  decision.reason = 'human_frustration_60_plus';
}

window.clickstream?.trackEvent({
  name: 'experience_variant_selected',
  category: 'signals',
  label: decision.reason,
});

That makes later QA much easier: the dashboard shows the traffic lane, while your app logs which branch your code actually took.

Human-Only Action View

Use this when only real people should see a UI variant or support prompt.

import { configure, waitFor } from '@clickstreamhq/signals';

configure({
  apiKey: 'cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
  endpoint: 'https://t.example.com',
});

try {
  const visitor = await waitFor(
    {
      isBot: false,
      behavioralClass: 'human',
      conversionReadinessMin: 70,
    },
    60_000,
  );

  if (!sessionStorage.getItem('cs_action_seen')) {
    sessionStorage.setItem('cs_action_seen', '1');
    document.dispatchEvent(new CustomEvent('clickstream:show-human-action', { detail: visitor }));
  }
} catch {
  // No matching signal in time. Leave the normal page experience alone.
}

AI/Search Answer-Engine View

Most search and answer-engine requests do not execute page JavaScript, so install Edge capture for full coverage. Page code can still help when a machine-readable browser does run JavaScript:

import { useVisitor } from '@clickstreamhq/react';

export function StructuredFaqForMachineReaders({ faqs }) {
  const { ctx } = useVisitor();
  const shouldExposeMachineReadableView =
    ctx?.bot.isBot &&
    (ctx.bot.category === 'ai_agent' || ctx.bot.category === 'search_crawler');

  if (!shouldExposeMachineReadableView) return null;

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{
        __html: JSON.stringify({
          '@context': 'https://schema.org',
          '@type': 'FAQPage',
          mainEntity: faqs.map((faq) => ({
            '@type': 'Question',
            name: faq.question,
            acceptedAnswer: { '@type': 'Answer', text: faq.answer },
          })),
        }),
      }}
    />
  );
}

In the dashboard, compare the pages humans use with the pages crawlers and answer agents reach. Gaps are content-coverage work.

Real Product Examples

1. Documentation or content site

Goal: make sure humans get the normal reading experience, while answer engines get structured facts they can quote accurately.

const ctx = await getVisitorOrNull();
const discovery =
  ctx?.bot.category === 'ai_agent' ||
  ctx?.bot.category === 'search_crawler';

if (discovery) {
  injectJsonLdForCurrentArticle();
  exposeCanonicalSummaryBlock();
}

Pair this with Edge capture, because most search and answer-engine requests do not execute JavaScript.

2. SaaS onboarding

Goal: help people who are stuck without giving test automation the same prompts.

const ctx = await getVisitorOrNull();
const shouldOpenHelp =
  ctx &&
  !ctx.bot.isBot &&
  ctx.behavioralClass === 'human' &&
  ctx.scores.frustration >= 60 &&
  ctx.session.pagesInSession >= 2;

if (shouldOpenHelp && !sessionStorage.getItem('cs_help_seen')) {
  sessionStorage.setItem('cs_help_seen', '1');
  openSetupHelp();
}

3. Checkout or lead form

Goal: reduce friction for humans and avoid firing revenue workflows for non-human traffic.

const ctx = await getVisitorOrNull();
const isHuman = !!ctx && !ctx.bot.isBot && ctx.behavioralClass === 'human';

if (isHuman && ctx.scores.conversionReadiness >= 70) {
  showShorterForm();
}

if (isHuman && ctx.identity.status !== 'anonymous') {
  enableSalesHandoff();
}

4. Internal QA coverage

Goal: let QA automation prove flows still work without polluting human analytics.

const ctx = await getVisitorOrNull();

if (
  ctx?.bot.category === 'automation' ||
  ctx?.bot.category === 'stealth_bot' ||
  ctx?.behavioralClass === 'suspicious' ||
  ctx?.behavioralClass === 'likely_bot'
) {
  window.dispatchEvent(new CustomEvent('clickstream:qa-lane', {
    detail: {
      page: location.pathname,
      sessionId: ctx.session.sessionId,
      automationScore: ctx.bot.score,
    },
  }));
}

Automation And QA View

Automation is not automatically malicious. Treat automation, stealth, suspicious, and likely_bot traffic as separate lanes so QA runs, monitoring checks, and scripted reviews do not distort human usage data. Human-only actions should require both !ctx.bot.isBot and ctx.behavioralClass === 'human'.

import { configure, onVisitor } from '@clickstreamhq/signals';

configure({
  apiKey: 'cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
  endpoint: 'https://t.example.com',
});

const sub = onVisitor((visitor) => {
  const automationLike =
    visitor.bot.category === 'automation' ||
    visitor.bot.category === 'stealth_bot' ||
    visitor.behavioralClass === 'suspicious' ||
    visitor.behavioralClass === 'likely_bot';
  if (!automationLike) return;

  document.documentElement.dataset.clickstreamLane = 'automation_qa';
  window.dispatchEvent(new CustomEvent('clickstream:automation-qa', {
    detail: {
      page: location.pathname,
      sessionId: visitor.session.sessionId,
      score: visitor.bot.score,
    },
  }));
});

// Later, when your component or test harness unmounts:
// sub.unsubscribe();

Practical Ideas

Public API

import {
  configure,
  getVisitor,
  getVisitorOrNull,
  onVisitor,
  waitFor,
  isBot,
  botCategory,
  behavioralClass,
  isHighIntent,
  isFrustrated,
  isIdentified,
} from '@clickstreamhq/signals';

configure(options)

Call once before reading signals.

configure({
  apiKey: 'cs_live_...',
  endpoint: 'https://t.example.com',
  pollIntervalMs: 2000,
});

getVisitor(): Promise<VisitorContext>

Reads the freshest snapshot for the current visitor id (resolved via the order shown in Install). It throws when:

try {
  const ctx = await getVisitor();
  console.log(ctx.scores.intent);
  console.log(ctx.behavioralClass);
  console.log(ctx.identity.clickstreamId);
} catch {
  // Treat as "no usable signal yet" in page UI.
}

getVisitorOrNull(): Promise<VisitorContext | null>

Reads one snapshot and returns null instead of throwing. Use this first for page personalization so your site always falls back to the normal experience.

const ctx = await getVisitorOrNull();
if (!ctx) showDefaultExperience();

onVisitor(callback): Subscription

Polls immediately, then at pollIntervalMs, and calls your callback whenever a snapshot arrives.

const sub = onVisitor((ctx) => {
  if (!ctx.bot.isBot && ctx.scores.frustration >= 60) {
    openSupportChat();
  }
});

sub.unsubscribe();

waitFor(criteria, timeoutMs?)

Resolves once every provided criterion is true. You can pass timeoutMs as the second argument, or inside the criteria object.

const ctx = await waitFor(
  { intentMin: 70, isBot: false },
  60_000,
);

Criteria:

type WaitForCriteria = {
  intentMin?: number;
  engagementMin?: number;
  frustrationMin?: number;
  churnMin?: number;
  abandonmentMin?: number;
  conversionReadinessMin?: number;
  behavioralClass?: 'human' | 'suspicious' | 'likely_bot' | 'bot';
  isBot?: boolean;
  identified?: boolean;
  emotionalState?: 'neutral' | 'curious' | 'engaged' | 'frustrated'
    | 'confused' | 'excited' | 'decisive' | 'hesitant';
  decisionStage?: 'browsing' | 'evaluating' | 'comparing'
    | 'deciding' | 'purchasing';
  timeoutMs?: number;
};

identified: true matches either a promoted non-anonymous identity status or a successful identify() call in the current session (hasIdentifiedThisSession).

Helpers

Helpers accept an already-fetched VisitorContext, or fetch the current visitor when called with no context.

const ctx = await getVisitor();

isBot(ctx);
botCategory(ctx);
behavioralClass(ctx);
isHighIntent(ctx, 70);
isFrustrated(ctx, 60);
isIdentified(ctx);

// Also valid, each performs one Signals read:
await isBot();
await isHighIntent(70);

VisitorContext

interface VisitorContext {
  bot: {
    isBot: boolean;
    score: number;
    category?: 'search_crawler' | 'seo_tool' | 'ai_agent' | 'social_preview'
      | 'monitoring' | 'scraper' | 'scanner' | 'automation'
      | 'stealth_bot' | 'kiosk' | 'unknown_bot';
    name?: string;
  };
  behavioralClass: 'human' | 'suspicious' | 'likely_bot' | 'bot';
  identity: {
    status: 'anonymous' | 'customer_identified' | 'signal_identified' | 'merged';
    personId?: string;
    visitorId: string;
    clickstreamId: string;
    observedClickstreamId?: string;
    mergedClickstreamIds: string[];
    isReturning: boolean;
    hasIdentifiedThisSession: boolean;
  };
  scores: {
    intent: number;
    frustration: number;
    engagement: number;
    value: number;
    churn: number;
    abandonment: number;
    conversionReadiness: number;
    sessionMomentum: number; // -100..100
    confusion: number;
    emotionalState: 'neutral' | 'curious' | 'engaged' | 'frustrated'
      | 'confused' | 'excited' | 'decisive' | 'hesitant';
    decisionStage: 'browsing' | 'evaluating' | 'comparing'
      | 'deciding' | 'purchasing';
  };
  session: {
    sessionId: string;
    durationMs: number;
    pagesInSession: number;
  };
  device: {
    type: 'desktop' | 'mobile' | 'tablet' | 'unknown';
    browser: string;
    os: string;
    isMobile: boolean;
  };
  // Language/locale intelligence. Optional + nullable: older servers omit it,
  // and pre-locale snapshots serialize null until the first locale-carrying
  // event refreshes them.
  locale?: LocaleContext | null;
  snapshotAt: string;                             // ISO-8601 timestamp when produced
  snapshotVersion?: string;                       // usually the producing event timestamp
  sourceEventId?: string;                         // client-generated event id, when available
  ageMs?: number;                                 // approximate client-observed snapshot age
  stale?: boolean;                                // true when reusing an older snapshot
  pending?: boolean;                              // true while the first scored event catches up
  transport?: 'rest' | 'stream' | 'cache' | 'stale';
}

LocaleContext is its own exported type from @clickstreamhq/signals:

interface LocaleContext {
  language: string | null;             // raw navigator.language (full BCP-47, e.g. 'es-MX')
  languages: string[];                 // ordered browser preference list as base tags (deduped, max 4)
  primaryLanguage: string | null;      // resolved primary language (base tag, e.g. 'es')
  translatedTo: string | null;         // language the visitor actively translated the page to
  timezone: string | null;             // IANA timezone (e.g. 'America/New_York')
  country: string | null;              // IP-geo country (ISO-3166 alpha-2)
  languageGeoMismatch: boolean | null; // primary language not expected for the geo country
  timezoneGeoMismatch: boolean | null; // device timezone disagrees with the geo country
  hourCycle: string | null;            // Intl hourCycle ('h12' | 'h23' | ...)
}

null on any locale field means unknown — ClickStream never guesses. The whole locale object is null until the first locale-carrying event refreshes the snapshot, and older servers omit it entirely.

Identity fields

Use the identity fields this way:

FieldMeaningUse it for
identity.visitorIdThe live visitor id used by /v1/signals/:visitorId.Reading the current browser/app session.
identity.clickstreamIdThe canonical ClickStream profile id shown in People.Stable profile links, CRM notes, support context.
identity.observedClickstreamIdPresent when the current browser/session id has been merged into another canonical profile.Debugging why a browser id now resolves somewhere else.
identity.mergedClickstreamIdsOlder ClickStream IDs that now resolve to the canonical clickstreamId.Search, support, and audit trails across historical IDs.
identity.statusanonymous, customer_identified, signal_identified, or merged.Deciding whether identity-aware workflows are available.

For new code, store identity.clickstreamId as the stable profile key and keep identity.visitorId for the live request/session. If a user has used multiple devices or browsers, aliases may appear in mergedClickstreamIds.

REST Endpoint

The client library calls this endpoint for you:

GET https://t.example.com/v1/signals/:visitorId?sessionId=:sessionId
X-API-Key: cs_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Accept: application/json

It returns the same VisitorContext shape shown above, plus a top-level coverageMode field ('full' | 'degraded') and a matching X-ClickStream-Signals-Coverage-Mode response header. When coverage for the billing period is exhausted, coverageMode is 'degraded' and the body is an initializing placeholder snapshot (pending: true, stale: true) rather than a live read — fall back to your default experience or enable overage billing.

For native mobile apps and non-browser clients, authenticate with the dashboard-issued mobile key (cs_mob_live_*) and provide both the persisted visitor id and the current session id. Mobile and server keys are provenance-exempt, so no Origin header is sent or required:

GET https://t.example.com/v1/signals/cs_abc_123?sessionId=cs_sess_456
X-API-Key: cs_mob_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Accept: application/json

See Mobile apps for the direct event ingestion pattern and the @clickstreamhq/react-native package that wires this read for you.

Plan Gate

GET /v1/signals/:visitorId is available on Hobby so personal and low-volume sites can use session-scoped Signals snapshots directly in page code. Hobby uses the same visitor snapshot shape, with the limits shown on the Pricing page.

The official client includes the current sessionId automatically. Hobby keys must send that session hint so ClickStream can read one live session partition instead of scanning every partition. Paid keys keep the wider fallback for older integrations.

Hobby and Growth should use one-shot reads or coarse cached checks. The client coalesces repeated calls and reuses the last good snapshot during short throttles so your page can fail open. Sustained polling and the Signals Feed WebSocket are Scale tier and above.

See Also