Beacon

Client-side signal collection for AI agent detection: an npm SDK that sends browser, performance, interaction, and fingerprint signals to Checkpoint on evidence, not a timer

The Checkpoint Beacon (@kya-os/checkpoint-beacon) is a client-side SDK. It collects browser, performance, interaction, and fingerprint signals from a page and sends them to Checkpoint for AI agent detection.

It sends a page view at construction, then sends again only when the evidence changes enough to matter: the first interaction, evidence doubling, the first untrusted input, a stale posture token, or the page being hidden or unloaded. It retries failed sends, queues events while offline, and sends from a Web Worker when it can.

The Beacon collects signals. Classification happens server-side, so the client never gets a detection class. View results in the dashboard. To classify inside a request, use server-side Middleware or the Gateway.

Prerequisites

  • A Checkpoint Project ID, the same one your pixel uses (its data-project-id). Use the Project ID exactly as the dashboard shows it, and use that same value in every beacon on the page. Beacons share a running beacon only when their ids match exactly: see More than one beacon on a page. The constructor throws Project ID is required for Checkpoint Beacon without one. Credentials shows where to find it.
  • Browser-only code. The constructor reads navigator and window, so it isn't safe during server-side rendering. Construct it in a client-side effect or a plain browser script. The Beacon has no server-side component.

Installation

npm install @kya-os/checkpoint-beacon

Full TypeScript definitions ship with the package.

Script tag

The package also ships a classic script build, dist/beacon.min.js. It exposes the CheckpointBeacon class on window (the class itself, not a namespace object) and is built for exactly the browser support floor. The constructor sends the first pageview:

<script src="https://cdn.jsdelivr.net/npm/@kya-os/checkpoint-beacon/dist/beacon.min.js"></script>
<script>
  const beacon = new CheckpointBeacon({ projectId: '<project UUID>' });
</script>

That URL has no version, so it tracks the latest release. Pin a version to control when you upgrade. If your policy requires Subresource Integrity, also add an integrity attribute for that exact file:

<script
  src="https://cdn.jsdelivr.net/npm/@kya-os/checkpoint-beacon@1.4.3/dist/beacon.min.js"
  integrity="sha384-<hash of that exact file>"
  crossorigin="anonymous"
></script>

1.4.3 is the current release. The hash is specific to the file you pin, so compute it from that exact file rather than copying one from a doc:

curl -s https://cdn.jsdelivr.net/npm/@kya-os/checkpoint-beacon@1.4.3/dist/beacon.min.js | openssl dgst -sha384 -binary | openssl base64 -A

The lazy signals chunk loads from the same CDN directory with no extra configuration, checked against a hash built into beacon.min.js (Integrity check on the lazy chunk). The script build strips console output, so debug and logLevel only log in the npm build.

Quick start

Construct the beacon

import { CheckpointBeacon } from '@kya-os/checkpoint-beacon';

const beacon = new CheckpointBeacon({
  projectId: '<project UUID>',
});

Construction sends one pageview, the arrival event, and wires everything else (How it works). There is no start() method.

Record events

beacon.trackEvent('checkout_started', { cartValue: 99.99, itemCount: 3 });

trackEvent() sends your own events, with JSON-serializable metadata. A client-side route change needs beacon.collect('pageview') only before 1.4.3. From 1.4.3 the beacon sends those itself (see triggers.navigation).

Clean up

Call beacon.destroy() when your app tears the beacon down, for example in a React effect cleanup.

Unload needs no code: the beacon already listens for visibilitychange and pagehide. Wiring trackPageUnload() yourself is safe too. Both routes share the same "nothing changed since the last send" check, so you get one send, not two.

window.addEventListener('pagehide', () => beacon.trackPageUnload());

Verify

Load a page and open the browser's Network tab. Look for a POST to https://kya.vouched.id/api/v1/beacon. A 202 means ingest accepted the batch. It doesn't mean the session is classified yet. If nothing shows in the dashboard, see No detections in the dashboard.

Configuration

projectId is the only required option. Every other option has a default. A typical setup adds a few:

const beacon = new CheckpointBeacon({
  projectId: '<project UUID>',
  environment: 'production',
  tags: { version: '2.0.0', region: 'us-east' },
  anonymizeIp: true,
  scrubQueryParams: ['token', 'email'],
});

Configuration options

OptionTypeDefaultDescription
projectIdRequiredstringNo default

Project ID for tracking (same as pixel data-project-id)

identityBeaconIdentityNo default

Who the visitor is, when the page already knows at construction time. When identity only becomes known later (a login part-way through the session), call identify() instead.

endpointstring'https://kya.vouched.id/api/v1/beacon'

Where batches are POSTed. Defaults to the hosted ingest endpoint declared in NETWORK_CONTRACT.md.

Set it to send somewhere else — a self-hosted deployment, a regional install, or a test origin. An override means the destination is the integrator's, not ours, so NETWORK_CONTRACT.md describes the DEFAULT and says so explicitly.

environmentstringNo default

Optional environment tag for data segmentation

tagsRecord<string, string | number | boolean>No default

Optional tags for custom data segmentation

enabledDeprecatedbooleanNo default

Not read by the SDK. Construct the beacon only when it should run, and call destroy() to stop it.

debugbooleanfalse

Verbose console logging.

logLevel"debug" | "error" | "info" | "warn"'error'

Log verbosity.

respectDoNotTrackbooleantrue

When the browser sends Do Not Track, the beacon collects and sends nothing.

anonymizeIpbooleanfalse

Truncate the stored IP to /24 (IPv4) or /48 (IPv6) before persistence.

stripUrlFragmentbooleantrue

Strip the #fragment from the url/referrer collector fields before they leave the page. Defaults to true: a fragment is never sent to the server by the browser in the first place for any OTHER surface (it is script-visible only), so a beacon that forwards it is the one place it can leak — and it is also the highest-risk part of the URL, since SPA routers, magic-link auth, and OAuth implicit-flow responses all put tokens there. Set to false only if a project genuinely consumes url's fragment server-side.

scrubQueryParamsstring[][]

Query parameter names (matched case-insensitively) to redact from the url/referrer collector fields before they leave the page. Each matching value is replaced with the literal string "[redacted]"; the parameter itself stays present so the URL's shape is unchanged. Empty by default — nothing is scrubbed unless a project opts in with the param names it puts tokens/PII behind (e.g. ["token", "email"]). Ignored for a parameter when dropQueryParams is also true, since there is no query string left to scrub.

dropQueryParamsbooleanfalse

Drop the entire query string from the url/referrer collector fields before they leave the page. Off by default. Takes priority over scrubQueryParams when both are set — there is nothing left to scrub once the whole query string is gone.

sessionTimeoutnumber1800000

Inactivity timeout in milliseconds (30 minutes). Every send extends it, so the session cookie rotates only after that much idle time.

sessionCookieNamestring'_as_beacon_session'

Name of the visitor session cookie shared by pixel, beacon, gateway, and SDK rows. Query it with ?sessionId= on the detections endpoint.

browserSessionIdstringNo default

Resume a browser session id the page stored earlier (a receipt's browserSessionId, see onReceipt) instead of reading the session cookie or minting a new id. With cookies off this is the only way two page loads stay one session, and a posture token verifies only against the session id it was issued to. 1 to 255 characters of A-Z a-z 0-9 . _ ~ -; anything else is ignored with a warning. updateConfig({ browserSessionId }) switches a running beacon to it.

onReceipt(receipt: BeaconReceipt) => voidNo default

Called with every ingest receipt: the server's session id, the browser session id the batch was sent under, and the posture token or the reason there is none. Runs on the main thread in worker mode too, after the posture cookie write. An error it throws is logged at warn and never affects delivery.

batchSizeDeprecatednumberNo default
flushIntervalDeprecatednumberNo default
evidenceGrowthFactornumber2

How much keyDwellCount + pressCount + untrustedEvents has to grow, as a multiple of its value at the last send, before an interaction sends again. 2 sends at 1, 2, 4, 8, 16 samples; 1.5 sends more often, 3 less. A value at or below 1 falls back to the default.

postureRefreshFractionnumber0.5

Fraction of the posture token's lifetime after which a counted interaction sends again, so the token stays fresh under a live hand. 0.5 refreshes the 120 s token once an interaction lands more than 60 s after the last send. A value outside (0, 1] falls back to the default.

triggersBeaconDispatchTriggersNo default

Which automatic sends fire. Every trigger is on unless set to false. Turning unload off leaves trackPageUnload() available for a host that wires its own listener; turning arrival off leaves collect().

proactiveRefreshMinIntervalMsnumber30000

Low-frequency backstop that refreshes posture on a jittered timer, independent of interaction, so a visitor who goes idle past the posture TTL with no counted interaction is not one stale click on a critical action away from an expired token (H0BB5, PR #5095 review). The actual interval on each tick is proactiveRefreshMinIntervalMs plus a random [0, max - min) jitter, so a fleet of clients loaded at the same moment does not all refresh on the same wall-clock boundary. Paused while the page is hidden, resumed on visibilitychange. Gated by triggers.posture, the same switch that already turns off the interaction-triggered staleness check — this timer is that same 'posture' trigger firing proactively, not a second mechanism. A non-positive value falls back to the default.

proactiveRefreshMaxIntervalMsnumber60000

The upper bound of the proactive-refresh jitter window; see proactiveRefreshMinIntervalMs. A non-positive value falls back to the default; a value below proactiveRefreshMinIntervalMs degrades to the minimum on every tick rather than throwing.

proactiveRefreshFreshnessMsnumberNo default

How old the last send must be, in milliseconds, before a visibility resume forces an immediate posture refresh. Defaults to the same threshold the interaction-triggered check already uses (postureRefreshFraction of the posture token's lifetime), so the two stay in lockstep unless a host deliberately wants the visibility check to be stricter or looser. A non-positive value falls back to that default.

proactiveRefreshOnVisibilityResumebooleantrue

On visibilitychange back to visible, force an immediate posture refresh when it is stale (see proactiveRefreshFreshnessMs); a fresh tab regaining focus does not get an unnecessary send either way. Set to false to leave visibility resume to the plain interval alone.

proactiveRefreshOnOnlinebooleantrue

Refresh posture (and drain anything the offline queue is still holding) when the browser fires online. Set to false to leave reconnection recovery to the offline queue's own online listener alone.

proactiveRefreshJitterSeednumberNo default

A fixed seed for the proactive-refresh jitter roll, for deterministic tests. Omit in production for real Math.random() jitter.

collectorDeadlineMsnumber1600

Per-collector deadline in milliseconds. A collector still running past this deadline is dropped from the current collection round rather than awaited further — see collectWithDeadline in checkpoint-beacon. Default and rationale documented in checkpoint-beacon's src/budget/thresholds.ts.

maxQueueSizenumber100

Maximum number of queued events.

retryAttemptsnumber3

Retry count for failed sends.

retryDelaynumber2000

Base retry delay in milliseconds.

postureRetryScheduleMsnumber[]No default

Delays in milliseconds between posture-recovery attempts after a durably-queued ingest response that carried no posture verdict. The array's length is the attempt ceiling. Defaults to the shipped ladder; override only in tests and in a self-hosted deployment with a different outage profile.

useWorkerbooleantrue

Prefer a Web Worker for event processing, batching, and transport. Page-dependent collection remains on the main thread. Unsupported browsers fall back to the main thread; SSR never starts a worker. Set false to disable the processing worker.

enableWebWorkerbooleanNo default

Alias for useWorker, accepted for callers using the older option name. When both are set, useWorker wins — see resolveUseWorker.

workerUrlstringNo default

URL of the worker script (beacon.worker.js, exported as @kya-os/checkpoint-beacon/worker). Classic workers must be same-origin. When unset, the beacon derives beacon.worker.js next to the <script> tag that loaded beacon.min.js, else /beacon.worker.js at the site root.

workerConfigWorkerConfigNo default

Worker lifecycle tuning: initTimeoutMs (3000), messageTimeout (30000), heartbeatInterval (5000), maxRetries (3), retryDelay (1000), compressionThreshold (5120), allowFallback (true).

lazySignalsUrlstringNo default

Explicit URL override for the lazy-loaded signals chunk (beacon-lazy-signals.min.js — userAgentData, fontPreferences, Client Hints, webrtcCodecs, speechVoices, plus canvas/WebGL/audio/permissions render entropy). Same shape and purpose as workerUrl: when unset, checkpoint-beacon derives the chunk's URL from wherever beacon.min.js itself was loaded from — see checkpoint-beacon's src/core/load-lazy-chunk.ts for the full resolution order.

To send batches through your own CDN instead of straight to Checkpoint, point endpoint at a path your edge proxies. See Forward edge evidence.

For dispatch triggers, worker settings, and how several beacons on one page combine their settings, see Tuning dispatch, Web Worker mode, and More than one beacon on a page.

Read once at construction: endpoint, sessionTimeout, sessionCookieName, and lazySignalsUrl. updateConfig() doesn't change them, so construct a new beacon instead. anonymizeIp and the URL redaction options apply from the next send when changed through updateConfig(), and changing anonymizeIp restarts the worker. useWorker defaults to true when the browser supports Web Workers. enableWebWorker is an accepted alias, and useWorker wins when both are set.

Content security policy

If your site restricts outbound requests, allow the ingest host in connect-src. If you set a custom endpoint, allow that host instead:

Content-Security-Policy: connect-src 'self' https://kya.vouched.id;

The script build also needs script-src for the host it loads from, and worker mode needs worker-src for your own origin. The Network & CSP reference has the full directive table, self-hosting the companion files, Trusted Types, and cross-site iframes.

Framework integration

React

import { useEffect } from 'react';
import { CheckpointBeacon } from '@kya-os/checkpoint-beacon';

function App() {
  useEffect(() => {
    const beacon = new CheckpointBeacon({
      projectId: process.env.NEXT_PUBLIC_CHECKPOINT_PROJECT_ID!,
    });

    const onPageHide = () => beacon.trackPageUnload();
    window.addEventListener('pagehide', onPageHide);

    return () => {
      window.removeEventListener('pagehide', onPageHide);
      beacon.destroy();
    };
  }, []);

  return <div>Your app</div>;
}

Construction sends the first pageview, so don't also call collect('pageview') here. That sends a second one. Under React Strict Mode in development the effect runs twice, so the beacon is constructed, destroyed, and constructed again. That is harmless: both instances read the same session cookie.

Next.js (App Router)

app/providers/beacon-provider.tsx
'use client';

import { useEffect } from 'react';
import { CheckpointBeacon } from '@kya-os/checkpoint-beacon';

export function BeaconProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    const beacon = new CheckpointBeacon({
      projectId: process.env.NEXT_PUBLIC_CHECKPOINT_PROJECT_ID!,
    });
    return () => beacon.destroy();
  }, []);

  return <>{children}</>;
}

Render it inside app/layout.tsx. The 'use client' directive matters: the constructor must not run during server rendering.

Vue

import { onMounted, onUnmounted } from 'vue';
import { CheckpointBeacon } from '@kya-os/checkpoint-beacon';

let beacon: CheckpointBeacon | undefined;

onMounted(() => {
  beacon = new CheckpointBeacon({
    projectId: import.meta.env.VITE_CHECKPOINT_PROJECT_ID,
  });
});

onUnmounted(() => {
  beacon?.destroy();
});

Troubleshooting

"Project ID is required for Checkpoint Beacon" thrown at construction

The constructor throws synchronously if config.projectId is missing or empty. Pass the same Project ID used elsewhere in your stack. See Credentials for where to find it.

No detections in the dashboard

  • Check the Network tab for a POST to https://kya.vouched.id/api/v1/beacon. If it is absent, the beacon is not sending. Otherwise the status tells you why nothing lands: 202 accepted, 404 Project ID unknown, 400 batch rejected (the response body says why), 429 rate limited.
  • If the visitor's browser sends Do Not Track, the beacon collects and sends nothing (respectDoNotTrack defaults to true). This is silent by design, not an error.
  • Confirm the Project ID matches the project you are viewing in the dashboard.
  • A restrictive CSP, corporate proxy, or tracker blocker can block the request to kya.vouched.id. See Content Security Policy.
  • Classification runs server-side after ingest, so allow a few seconds before the dashboard reflects a new session.

Worker failures don't show up anywhere

Worker initialization and worker-send failures are caught internally, and the beacon falls back to the main thread. This is expected behavior, not a bug. To see what happened, use the npm build with debug: true and logLevel: 'debug', then look for [Checkpoint Beacon]-prefixed console messages such as Worker unavailable, using main thread or Worker failed, using fallback. With a bundler, the usual cause is a missing same-origin worker script: host beacon.worker.js and pass workerUrl, or leave useWorker off.

Nothing logs even with debug: true

  • The log level defaults to 'error'. Set logLevel: 'debug' (or 'info') to see initialization and send logs.
  • The script tag build (beacon.min.js) strips console output entirely. Only the npm build logs.

"window is not defined" or "navigator is not defined" during server rendering

The constructor touches browser globals. Construct the beacon inside a client-side effect (a 'use client' component in Next.js) or a plain browser script, never at module top level in code that also runs on the server.

The lazy signals or the worker never load with a bundler

Both files are located relative to a <script> tag that loaded beacon.min.js, which a bundled app does not have. Host dist/beacon-lazy-signals.min.js and dist/beacon.worker.js yourself, both from the same package version, and pass their URLs as lazySignalsUrl and workerUrl (Self-host the companion files).

Beacon vs Pixel

For a lightweight, no-code alternative, see the Marketing Pixel.

FeatureBeaconPixel
Installationnpm package or script tagScript tag / GTM
Signal richnessBrowser, performance, interaction counts, 25 fingerprint sources plus 22 lazyBasic + fingerprint
Custom eventstrackEvent()track()
Identify usersidentify({ userId })identify(userId, traits)
Web WorkerOptional (useWorker)No
Offline queueYesNo
Session id_as_beacon_session cookieThe same cookie (1.3.0 and later)
Best forApplicationsMarketing sites

Next steps