Beacon reference

Look up every Beacon method, session and receipt rule, data collection detail, posture verification step, delivery behavior, CSP directive, and browser requirement.

To install the Beacon and send your first event, start with Beacon setup.

How it works

StageWhat happens
ConstructionValidates projectId, reads or mints the _as_beacon_session cookie (not for a Do Not Track visitor, see Do Not Track), builds the five default collectors, creates the transport, and starts worker initialization if useWorker is on.
ListenersChecks Do Not Track. Unless that blocks it, the interaction counters attach, the evidence-dispatch listeners wire up (the counted-interaction callback, visibilitychange/pagehide, online), and a low-frequency proactive posture-refresh timer starts (jittered, paused while the page is hidden).
ArrivalOne automatic pageview, the arrival event, goes out. No timer drives sends after that.
Each sendcollect(), trackEvent(), and every automatic send run one round. It waits for browser idle time (requestIdleCallback, capped at 50 ms), runs every enabled collector in parallel under a per-collector deadline (collectorDeadlineMs, 1600 ms by default), then stamps the event with the session id, the trigger's timestamp, metadata.trigger (why it fired), and the configured identity. A collector that misses the deadline is dropped from that round and the rest are kept. One round runs at a time, and a trigger that arrives mid-round folds into a single follow-up round.
DeliveryImmediate, with no batch and no timer. See Delivery.
UnloadA pageunload event skips the idle wait and the collectors that aren't safe during unload. It goes out immediately and uncompressed through navigator.sendBeacon (or fetch with keepalive where sendBeacon is missing), then drains the offline queue and flushes the worker. It fires on visibilitychange (hidden) and pagehide, and skips the send when everything observed has already been delivered. A visit that ends before the arrival round finishes still sends its picture.
WorkerDelivery runs inside it. Collection stays on the main thread, because the collectors need the DOM. See Web Worker mode.

Beacon API

MethodWhat it does
new CheckpointBeacon(config)Creates the beacon and sends the arrival pageview.
collect(eventType?)Runs a collection round and sends one event.
trackEvent(name, metadata?)Sends a custom event.
prepareForAction({ action, maxPostureAgeMs? })Refreshes posture in the background, without blocking.
identify(identity?)Attaches your user id to later events.
getSessionId()Returns the client session id.
getPostureHeaders()Returns the headers a backend reads posture from.
getRequestProofHeader(request)Signs a proof for one outgoing request.
trackPageUnload()Records a pageunload event.
updateConfig(updates)Merges configuration at runtime.
addCollector(collector)Registers an extra collector.
destroy()Releases the beacon.

new CheckpointBeacon(config)

Creates a beacon, wires the evidence-dispatch triggers, and sends one automatic pageview. Throws when config.projectId is missing or empty. Every other option has a default.

collect(eventType?)

Runs a collection round and sends one event immediately. eventType defaults to 'pageview'. The valid types are 'pageview', 'pageunload', 'evidence', 'heartbeat', 'custom', 'error', and 'performance'. The SDK doesn't produce 'heartbeat' on its own; the type stays for direct calls and already-deployed bundles. Returns a promise that resolves once the send completes, ingest round trip included, so the posture cookie (when the server issues one) exists by then. Under Do Not Track it resolves without doing anything.

await beacon.collect('pageview');

trackEvent(name, metadata?)

Sends a custom event. Metadata must be JSON-serializable. The beacon spreads it into the event envelope, then sets eventName, sessionId, timestamp, and identity, so those four keys cannot be overridden.

await beacon.trackEvent('button_click', {
  buttonId: 'signup-cta',
  page: '/home',
});

The event name and your metadata travel with the event and pass validation, but the ingest endpoint currently persists only the event type, the session id, and the identified user id from the envelope. Custom metadata does not appear in the dashboard today. Use trackEvent() to add a detection sample at a meaningful moment, not to store your own attributes.

prepareForAction({ action, maxPostureAgeMs? })

Refreshes posture evidence in the background and returns immediately, with nothing to await. It is a no-op when posture is already fresh (age under maxPostureAgeMs, default 30 seconds), a refresh is in flight or settled too recently, the beacon is already retrying a pending posture, or ingest withheld a token for a terminal reason such as not-enabled or origin-mismatch. Calling it on every keystroke or pointer move costs at most one request.

generateButton.addEventListener('focus', () => {
  checkpoint.prepareForAction({ action: 'firefly_generate', maxPostureAgeMs: 30_000 });
});

async function onGenerateClick() {
  checkpoint.prepareForAction({ action: 'firefly_generate', maxPostureAgeMs: 30_000 });
  await callFireflyGenerate();
}

Never await this method, or the work it starts, before running the protected action. Proceed immediately on whatever posture cookie is already on hand. That is the entire point of this API.

identify(identity?)

Tells the beacon who the visitor is once your site knows. Pass { userId }, your own id for the signed-in user, up to 255 characters. Longer values are rejected at ingest. Every later event carries it as metadata.identity.userId, and it is stored as the detection's user id. Earlier events in the session stay anonymous. Call it with no argument on sign-out, so a shared device stops attributing the next visitor's activity to the person who left. If the identity is known at render, pass identity in the constructor instead.

beacon.identify({ userId: 'user_123' });

beacon.identify();

getSessionId()

Returns the client session id the beacon stamps on every event. It is a pure read: it doesn't extend the session or touch the cookie, so polling it from a status footer is safe. The server assigns its own consolidated session id (see Sessions).

getPostureHeaders()

Returns the two request headers a backend needs to read the session's posture verdict, as a plain object. It is a pure read: no network call, no storage write, and it doesn't extend the session.

HeaderHolds
KYA-Posture-SessionAlways present. The browser session id the latest posture token is bound to, or the current session id until a receipt has carried a token.
KYA-PostureThe latest token. Present only while that token is unexpired.

The latest token is the newest one: a late receipt doesn't replace a newer unexpired token for the same binding. The posture cookie follows the same rule: the newer issue time wins, and a later clean token doesn't replace an agent-driven one while that one is unexpired. Withdrawing consent through respectDoNotTrack, or destroying the last beacon, clears the token, and KYA-Posture-Session then follows getSessionId(). See Sending posture to another host.

const headers = beacon.getPostureHeaders();

getRequestProofHeader(request)

Signs a proof that this browser holds the beacon's key, for one outgoing request. request is { method, url }. It returns a promise for the signed proof string. When the browser reports Do Not Track it resolves to undefined and signs nothing, whatever respectDoNotTrack is set to.

PartValue
Header typdpop+jwt
Header algES256
Header jwkThe beacon's public key
Claim htmThe request method, uppercased
Claim htuThe url you passed, unchanged
Claim iatThe signing time, in Unix seconds
Claim jtiA fresh random id for each proof

The key pair is a non-extractable P-256 pair. The beacon creates it on first use and keeps it in IndexedDB for the browser profile. Every call signs a new proof. If signing fails, for example when WebCrypto is unavailable, the promise rejects and your code decides what to do.

const proof = await beacon.getRequestProofHeader({
  method: 'POST',
  url: 'https://api.example.com/generate',
});

trackPageUnload()

Records a pageunload event through the Unload path in How it works. Calling it from a pagehide handler is optional and safe (see Quick start).

updateConfig(updates)

Merges configuration at runtime.

  • Changing useWorker, enableWebWorker, workerUrl, or workerConfig restarts the worker.
  • Setting browserSessionId moves the next send onto that session.
  • flushInterval and batchSize are accepted and ignored (deprecated no-ops).
  • Setting respectDoNotTrack: true for a DNT visitor mid-session releases the interaction listeners and evidence-dispatch triggers, expires the _as_beacon_session cookie, and starts a new in-memory session id. Setting it back re-wires them under that new id, so a consent manager can drive it.

The options it cannot change are in the read-once callout under Configuration options.

beacon.updateConfig({ respectDoNotTrack: false });

addCollector(collector)

Registers an extra collector for a signal the default set doesn't carry. A collector implements ICollector (exported from the package): type, enabled, unloadSafe, isSupported(), collect(), and optional start() and dispose(). A second instance of an already registered class is refused with a warning. When two collectors write the same key, the first writer wins and the collision is logged.

destroy()

Removes the visibilitychange/pagehide listeners and the counted-interaction callback, disposes every collector (removing its listeners), terminates the worker, and drains the offline queue.

beacon.destroy();
After destroy()Result
That beacon's own methodscollect() and trackEvent() resolve with nothing sent. prepareForAction(), trackPageUnload(), addCollector(), and identify() do nothing. updateConfig() is ignored.
The reading methodsgetSessionId(), getPostureHeaders(), and getRequestProofHeader() still answer.
With more than one beacon on the pagedestroy() releases only the beacon you call it on. The shared beacon stops when the last one has released it, and calling destroy() twice on one beacon counts once.
A destroyed first beaconIt stops sending and its onReceipt stops firing, while the shared beacon keeps running for any handle still alive.
A response in flight when the last one is destroyedIt is ignored: no posture cookie, no receipt, and no token from getPostureHeaders().

The Beacon is not an event emitter: there is no .on(...), no client-side detection event, and no start(), stop(), or getDetectionResult() method.

More than one beacon on a page

A tag manager, a framework wrapper, and a hand-written snippet can each construct a beacon on the same page. Beacons that share a projectId share one running beacon. The second new CheckpointBeacon(...) attaches to the first and returns a handle to it. The handle's prototype is CheckpointBeacon.prototype, so instanceof is true.

The page gets one arrival pageview, one worker, one set of dispatch timers, and one DPoP key. Every method on the handle calls the shared beacon, so getSessionId() and getPostureHeaders() return the same values from either.

The match is on projectId character for character. acme-corp and the project's UUID are different keys even for the same project, so two beacons built with them run separately and each sends its own pageview. Use one id everywhere; the UUID is the form we recommend. Only copies that carry this behavior attach to each other, and an older release on the same page runs on its own.

When the second beacon's config differs from the first's:

SettingsResult
Transport and asset: endpoint, lazySignalsUrl, useWorker, enableWebWorker, workerUrl, workerConfig, triggersThe first beacon's values stand. Each distinct conflicting value logs one warn (npm build, when logLevel allows it).
Session: sessionTimeout, sessionCookieNameThe first beacon's values stand, with no warning.
onReceiptEvery beacon's callback receives every receipt, including receipts for sends another beacon triggered.
Privacy: respectDoNotTrack, anonymizeIp, stripUrlFragment, dropQueryParams, scrubQueryParamsThe most restrictive live handle wins, with no conflict warn. See the next table.
Everything elseApplies to the shared beacon as an updateConfig call would.

How the privacy settings combine:

SituationResult
Each live handle, the first includedIt contributes its own parsed value per setting and its own list for scrubQueryParams. A setting left unset (or set to undefined or null) contributes the default: respectDoNotTrack true, stripUrlFragment true, anonymizeIp false, dropQueryParams false, and no scrubQueryParams names.
The shared beaconIt uses true if any live handle contributes true, and the deduplicated union of every live handle's scrubQueryParams. It recomputes whenever a handle calls updateConfig or is destroyed. Changing the effective anonymizeIp restarts the worker.
A handle passes false or fewer namesIt loosens only its own contribution. Nothing changes while another live handle still holds the setting tight.
The first beacon calls updateConfig({ respectDoNotTrack: false }) or { anonymizeIp: false }A restriction another handle set stays. Destroying the handle that asked for it lifts it.
A later beacon omits respectDoNotTrack (or passes undefined or null)It asks for the default, true, so it turns Do Not Track handling on while it is alive.
Two default beacons, and the first is destroyedFragments stay stripped for the survivor, because each contributes the default stripUrlFragment: true.
The first beacon sets stripUrlFragment: falseIt stays stripped while any other live handle leaves it unset. It is loosened only when that handle sets false itself or is destroyed.

updateConfig on a handle follows the same rules, and a destroyed handle's updateConfig is ignored.

Sessions

The beacon keys every event to a visitor session stored in a first-party cookie, _as_beacon_session by default (sessionCookieName).

TopicRule
CookieThe value is a random UUID, written with path=/ and SameSite=Lax and no Domain, so it is host-only. For Do Not Track visitors, see Do Not Track.
ExpiryAn inactivity timeout: sessionTimeout (30 minutes by default) from the last send. Every send rewrites it, so a session rotates only after that much idle time. Two tabs share one cookie and one session.
Across surfacesThe same cookie is the cross-surface visitor session id. The Marketing Pixel (1.3.0 and later) reads it and the Gateway forwards it, so pixel, beacon, and gateway rows for one browser visit land on the same session.
Look up a visitRead getSessionId() on the page (ready as soon as the beacon is constructed) and pass it as sessionId to Get Project Detections.
Server session idA live 202 also carries the server's consolidated session id, the key Get Project Session takes. onReceipt hands it to your page as sessionId (1.4.3 and later), and a queued 202 includes it once the origin has answered. The signed posture token carries it as session_id.
Cookies blockedThe beacon keeps the session id in memory. Events and getSessionId() still carry it, but every full page load starts a new session unless the page stores the id and passes it back as browserSessionId (Receipts and resuming a session).

Receipts and resuming a session

onReceipt is called on the main thread for every 202 ingest response, in worker mode too, right after the beacon stores the posture cookie. It receives a BeaconReceipt:

FieldWhat it is
sessionIdThe server's consolidated session id, the key Get Project Session takes. On a queued receipt, present once the origin has answered for the session.
browserSessionIdThe beacon's own session id the batch was sent under: what the posture token is bound to, and what the browserSessionId option resumes.
posture{ token, expiresAt } (Unix seconds) when ingest issued a posture token. Present even when cookies are blocked or the page is not HTTPS, where no cookie is written.
postureStatusWhy posture is absent: pending while a queued batch awaits replay, otherwise the withheld reason (see Cookies the beacon writes).

An error thrown inside onReceipt is logged at warn and never affects delivery.

To keep one session across page loads without cookies, store browserSessionId wherever the page keeps state and pass it back at construction:

const beacon = new CheckpointBeacon({
  projectId: '<project UUID>',
  browserSessionId: loadStoredBrowserSessionId(),
  onReceipt: ({ sessionId, browserSessionId, posture }) => {
    saveBrowserSessionId(browserSessionId);
    if (sessionId) recordCheckpointSession(sessionId);
    if (posture) latestPosture = posture.token;
  },
});

loadStoredBrowserSessionId() returns undefined on a first visit. recordCheckpointSession() stands for your own code, which joins your record to the visit.

The option takes 1 to 255 characters of A-Z a-z 0-9 . _ ~ -. Anything else is ignored with a warning. The resumed id still rotates after sessionTimeout of inactivity. updateConfig({ browserSessionId }) switches a running beacon to a stored id, for a page that loads it after construction.

Data collection

Five collectors run on every collection round:

CollectorWhat it sends
BrowserCollectoruserAgent, language, platform, vendor, vendorSub, webdriver, screen resolution and color and pixel depth, viewport, cookiesEnabled, doNotTrack, timezone and its offset, hardwareConcurrency, deviceMemory, maxTouchPoints, and the page's url, referrer, and title
PerformanceCollectorNavigation timing (page load, DOM content loaded, DOM interactive, DNS, TCP, server response), transfer and body sizes, navigation type and redirect count, connection type, speed, RTT, and save-data, JS heap sizes (Chrome), and first paint, first contentful paint, and largest contentful paint
InteractionCollectorCounts only, accumulated across the visit: mouse movements, keyboard events, scrolls, clicks, touches, script-dispatched (untrusted) events, plus key dwell and gap timing sums and pointer path statistics. Which keys were pressed, where the pointer went, and what was clicked are never recorded
VendoredFingerprintCollector25 curated FingerprintJS sources under platform.* and rendering.mathFingerprint: languages, OS CPU, color gamut and depth, storage availability, touch support, PDF viewer, architecture, media-query preferences (forced colors, inverted colors, reduced motion, reduced transparency, monochrome, contrast, HDR), and more. Each arrives as an outcome object with a status and a duration
LazySignalsCollector22 signals that ship in a separate file, beacon-lazy-signals.min.js: userAgentData, font preferences, nine User-Agent Client Hints, speech voices, WebRTC codecs, canvas text and WebGL render hashes, an audio digest, platform permissions, and optional automation diagnostics for navigator.webdriver, CDP stack access, plugin and MIME type consistency, WebGL and User-Agent consistency, and distinctive automation properties

The fingerprint and lazy collectors are marked unsafe during unload and are skipped on the pageunload path.

The lazy chunk is injected as a <script> at low fetch priority after window.load and an idle period, and only when its URL can be resolved. The URL comes from lazySignalsUrl, otherwise from next to wherever beacon.min.js was loaded from, whatever the file is called. The script tag install needs no setup. A bundler does: see Self-host the companion files.

What leaves the page, alongside the signals above:

DataWhat leavesControl
Page URL and referrerThe current page's url (including its query string by default) and document.referrerstripUrlFragment (on by default) removes the #fragment, which no other web surface ever sends to a server and which is the highest-risk part of a URL for tokens (SPA routers, magic links, OAuth implicit flow). scrubQueryParams and dropQueryParams are off by default; set one if a project puts tokens or PII in its query strings. scrubQueryParams replaces each matching value with [redacted], and dropQueryParams drops the whole query string.
Titledocument.titleNo redaction option. Keep sensitive values out of document.title.
Canvas, WebGL, audioHashes onlyThe readings are hashed on the device.

Redaction runs client-side, at collection time, before url and referrer are placed on the event. URL redaction and IP truncation (anonymizeIp, which stores only a /24 (IPv4) or /48 (IPv6) prefix) require @kya-os/checkpoint-beacon 1.2.0 or later. The package's full data-inventory declaration is NETWORK_CONTRACT.md, and a test asserts the SDK's real network behavior against it.

Do Not Track

When navigator.doNotTrack (or the window.doNotTrack and msDoNotTrack variants) reads 1 or yes and respectDoNotTrack is on (the default), the beacon collects and sends nothing. collect(), trackEvent(), and trackPageUnload() return immediately, no automatic arrival send happens, and the interaction listeners and evidence-dispatch triggers are never attached. Global Privacy Control is not read.

The session cookie for a Do Not Track visitor depends on the release:

ReleaseSession cookie
Before 1.2.0Written unconditionally at construction, before the Do Not Track check.
1.2.0 and laterNever written. The beacon checks Do Not Track before minting it.
1.4.3 and laterAs 1.2.0, and an existing _as_beacon_session cookie is expired on load or when respectDoNotTrack flips to true. That expiry is the one document.cookie write the beacon makes. A consent manager that sets respectDoNotTrack: true mid-page also rotates the in-memory id, so a later re-grant starts a new session instead of reviving the withdrawn one. A posture token an in-flight request returns after that is not stored.

The other identifiers the beacon stores (nonce keys, the DPoP keypair and its sent marker, the offline queue) are not cleared. Neither is a posture cookie already stored, which expires by itself within its 120 second lifetime.

Cookies the beacon writes

CookiePurpose
_as_beacon_sessionThe visitor session id. See Sessions.
__Host-checkpoint_posture_<project UUID>On HTTPS pages, when the ingest response carries a signed posture token (projects with browser posture enabled), the SDK stores it in this short-lived cookie for the enforcement surfaces to read. The name uses the project's UUID from the signed token, whatever projectId the page passed, so a friendly id in the beacon config still delivers it. Delivery only: the origin verifies the signature before trusting any claim.

When ingest withholds a token, the 202 body says why in postureStatus, visible in the browser's network inspector on every build. The npm build also logs it once per reason at warn when logLevel allows.

postureStatusMeaning
not-enabled, domain-unset, origin-mismatch, unavailableIngest withheld a token for that reason.
pendingNot a withheld reason. A durable ingest edge accepted the event before any origin produced a verdict. The SDK treats it as non-terminal, keeps any existing posture cookie, and retries on its recovery ladder until a token or a withheld reason arrives.

From 1.4.3 a withheld reason on a queued receipt ends the ladder too. Earlier releases kept retrying on any queued receipt without a token.

Verifying the posture token

The posture token lets your server read the session's verdict without calling Checkpoint. The server SDKs (Java, .NET, Express, Next.js) verify it for you once browser posture is configured. Any other stack can verify the JWS directly.

RequirementWhat to do
Browser posture onUse Browser posture on the project's Installations page, or kya-os detect install --surface beacon --posture (see Set up browser posture). Turning it on or off takes effect for token issuance within 5 minutes, because each ingest instance keeps a project's settings for that long.
DomainSet the project's domain to the page's exact origin, for example https://app.example.com.
Additional originsFor more sites on the same project, list up to 20 more under Additional site origins on the same page, or with --origins.
Matching ruleEach is an exact origin or a wildcard like https://*.example.com, which covers every subdomain but not example.com itself. Ingest compares the request's Origin with these, or the Referer origin when Origin is absent, and withholds the token on a mismatch.
Proxy or CDNA proxy or CDN in front of ingest needs no entry, but it must pass the browser's Origin header through unchanged.
Token bindingEach token names the page it was issued on as its origin and aud, so each site's server, configured with its own origin, verifies only its own tokens. A server that answers for several of the origins lists them all: see Verify the token on your server.
HTTPSServe the page over HTTPS. The beacon doesn't store the token otherwise.
Public keyPin the public key in your configuration. The Browser posture card on the Installations page shows the key id and the public JWK once posture is on. GET https://kya.vouched.id/api/v1/beacon/keys?projectId=<project UUID> returns the same { keyId, publicKey, keys }.

Turning browser posture off (from the Installations page or the project posture API) deletes the project's signing key. Turning it on again issues a new key id, so a server that pinned the old key stops verifying until it takes the new one. A server that reads the keys endpoint instead of pinning, such as a Java server configured with BrowserPostureOptions.remoteKeys, picks the new key up on its own within about two minutes. Turning posture off is refused while human step-up (Turnstile) is configured, because step-up signs its clearance with the same key. Clear the Turnstile keys first.

Token fieldValue
FormatES256 JWS
typcheckpoint-posture+jwt
isscheckpoint:browser-posture:v1
audThe page origin
Lifetime120 seconds. The beacon refreshes it while the page is visible.
Claimsproject_id, origin, session_id (the consolidated session id), binding, verdict, score, scorer_version, reasons, automation_disclosed, automation_corroborated, iat, exp, and, from 1.4.3, cnf.jkt once the beacon has sent its key

Check the signature and kid against the pinned key, then typ, iss, aud, exp, and origin. Check that binding is the hex SHA-256 of the visitor's browser session id: the _as_beacon_session value, or the receipt's browserSessionId when cookies are blocked.

Treat a missing or invalid token as no evidence, never as a reason to allow.

Sending posture to another host

Both cookies are host-only. If the protected request goes to another host, such as an API subdomain, neither cookie goes with it. Attach the headers yourself with getPostureHeaders(). It reads the latest receipt, so it also works when cookies are blocked.

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

await fetch('https://api.example.com/generate', {
  method: 'POST',
  headers: { ...beacon.getPostureHeaders() },
});

The Java, .NET, Express, and Next.js server SDKs read these two headers, and each one wins over its cookie. A header sent twice is ignored rather than guessed at. For a cross-origin request, add both names to the API's Access-Control-Allow-Headers:

Access-Control-Allow-Headers: KYA-Posture, KYA-Posture-Session

A token lives for 120 seconds. After that, getPostureHeaders() still returns KYA-Posture-Session, leaves KYA-Posture out, and the server treats the request as having no posture evidence. Call prepareForAction shortly before a protected action: it starts a refresh without blocking, and the next getPostureHeaders() call returns the new token once its receipt arrives. To store the token yourself, keep the latest receipt from onReceipt.

Web Worker mode

Worker mode is on by default in a capable browser. Turn it off with useWorker: false (or the enableWebWorker: false alias). Network requests run in the worker. Collection always stays on the main thread.

The worker script must be on your own origin. If beacon.min.js loads from another host (a CDN, or your own tag loader) and you don't pass a same-origin workerUrl, the worker can't start and the beacon quietly uses the main thread. Set useWorker: false there to skip the attempt. With a bundler, workerUrl is required, because there's no script tag to find the worker from.

const beacon = new CheckpointBeacon({
  projectId: '<project UUID>',
  useWorker: true,
  workerUrl: '/static/beacon.worker.js',
});
StageWhat happens when it is on
Capability checkThe beacon checks that Worker exists and probes worker capabilities. A capability score below 50 falls back to the main thread.
CreationIt creates a classic worker from workerUrl, otherwise from beacon.worker.js next to the <script> that loaded beacon.min.js (whatever that file is called), otherwise from /beacon.worker.js at your site root. Classic workers must be same-origin: host dist/beacon.worker.js (exported as @kya-os/checkpoint-beacon/worker) on your own origin and pass workerUrl.
SendingThe worker receives the full configuration, runs its own transport, and sends every collect() and trackEvent() event immediately.
Startup deadlineStartup has a 3 second deadline (workerConfig.initTimeoutMs). If it is missed, the worker throws, or a worker send fails, the event goes through the main-thread transport instead, and the failure is logged only at debug level.

workerConfig tunes the manager:

KeyDefault
initTimeoutMs3000
messageTimeout30000
heartbeatInterval5000
maxRetries3
retryDelay1000
compressionThreshold5120
allowFallbacktrue

A Content Security Policy whose worker-src (or script-src) excludes the worker URL blocks creation, and the beacon falls back. For advanced setups, the BeaconWorkerClient class is exported from @kya-os/checkpoint-beacon.

Tuning dispatch

Three settings shape the automatic sends, plus a low-frequency backstop timer that only ever refreshes posture. None of them brings back a fixed-interval heartbeat.

SettingDefaultWhat it does
evidenceGrowthFactor2An interaction sends again once keyDwellCount + pressCount + untrustedEvents has grown by this factor since the last send. 2 sends at 1, 2, 4, 8, 16 samples, 1.5 more often, 3 less. Mouse movement and scroll never count. A value at or below 1 falls back to the default.
postureRefreshFraction0.5A counted interaction sends again when the last send is older than this fraction of the posture token's 120 s lifetime, so the token stays fresh under a live hand. 0.25 refreshes after 30 s of silence, 1 only at expiry. A value outside (0, 1] falls back to the default.
triggersEvery trigger onEvery automatic send is on unless you set it to false. See the trigger table below.
proactiveRefreshMinIntervalMs and proactiveRefreshMaxIntervalMs30000 and 60000The reactive checks only fire on a counted interaction, so this jittered backstop covers an idle visitor past the posture TTL. Every tick is a random point in [min, max), so clients don't all refresh on the same boundary. Paused while the page is hidden, resumed on visibilitychange. Independent of every other trigger, and every tick also drains the offline queue, like any send. Gated by triggers.posture. A non-positive value falls back to the default.
proactiveRefreshFreshnessMspostureRefreshFraction's own thresholdOn visibility resume, forces an immediate refresh only when posture is actually this stale. A fresh tab regaining focus gets no unnecessary send.
proactiveRefreshOnVisibilityResume and proactiveRefreshOnOnlinetrue and trueTurn off the visibility-resume and browser-reconnect refreshes independently of the timer.
proactiveRefreshJitterSeedUnsetA fixed seed for the jitter roll, for deterministic tests. Omit it in production for real Math.random() jitter.
TriggerSends
arrivalThe pageview at construction and on consent restore.
signalsThe follow-up when the lazy signals chunk resolves after arrival missed it.
interactionThe first interaction, the first timing sample, and evidence growth.
untrustedThe first script-dispatched input.
postureThe stale-token refresh, both reactive and the proactive backstop.
unloadThe SDK-wired visibilitychange and pagehide send.
navigationA pageview on a back/forward-cache restore or a client-side route change to a new path (1.4.3 and later).

With unload: false, trackPageUnload() still works for a listener you wire yourself. With arrival: false, call collect('pageview') yourself, and whatever has not been delivered still leaves on unload.

const beacon = new CheckpointBeacon({
  projectId: '<project UUID>',
  evidenceGrowthFactor: 3,
  postureRefreshFraction: 0.25,
  triggers: { signals: false },
  proactiveRefreshMinIntervalMs: 45000,
  proactiveRefreshMaxIntervalMs: 90000,
});

Delivery: retries and offline

BehaviorRule
No batchingEvery send goes out immediately, on its own, when an evidence transition, collect(), or trackEvent() triggers it. No fixed-interval timer.
CompressionPayloads of 1 KB or more are gzipped with CompressionStream when the browser has it and compression saves at least 10 percent. They go out base64-encoded with Content-Type: application/octet-stream and X-Content-Encoding: gzip-base64, not the standard Content-Encoding: the body is base64 text of gzip bytes, not gzip bytes on the wire, so the standard header would be a false claim an intermediary could act on. Unload sends are never compressed, because sendBeacon cannot set headers.
Transportnavigator.sendBeacon for unload sends, fetch with keepalive: true otherwise, and XMLHttpRequest where fetch is missing.
RetriesUp to retryAttempts (3) attempts in total, with exponential backoff: retryDelay (2 seconds) doubling per attempt, capped at 30 seconds, with 20 percent jitter. Network errors, timeouts, and HTTP 408, 429, 500, 502, 503, and 504 are retryable; other responses are not. After five consecutive failures a circuit breaker pauses sends for 30 seconds. A send that exhausts its retries on a retryable error goes to the offline queue.
Offline queueWhen navigator.onLine is false, events are stored in IndexedDB, falling back to localStorage, then memory. The queue holds up to maxQueueSize (100) entries. When it is full the oldest 10 percent are evicted, and entries expire after 24 hours. It drains before every later send, when the browser fires online, and on unload: up to 100 queued events go in one request (the per-request limit) and the rest on the next drain. A send that fails and queues waits for the next send.

Network & CSP

Every batch is a POST to https://kya.vouched.id/api/v1/beacon (override it with endpoint), with Content-Type and X-Project-ID headers, plus X-Content-Encoding: gzip-base64 when compressed. Those headers mean every request is preflighted with OPTIONS. The endpoint answers with Access-Control-Allow-Origin: *.

StatusMeaning
202Accepted. A live receipt is { success, accepted, sessionId, posture?, postureStatus?, integrity?, nonce, receiptId?, durableStatus? }. A queued one (durableStatus: 'queued') is { success, accepted, receiptId, durableStatus, sessionId?, posture?, postureStatus?, nonce? }: sessionId and the posture answer appear once the origin has answered for the session. The SDK reads posture; postureStatus is diagnostic, except pending, the one non-terminal value, which the SDK retries on.
400Invalid JSON, a batch that fails schema validation (details says why), or events whose session id differs from the batch's.
404Unknown projectId.
429Rate limited per client IP and project. X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset say when to retry; the SDK retries these with backoff.

From 1.4.3, onReceipt hands a 202's sessionId, posture, and postureStatus to your page (Receipts).

If your site restricts outbound requests (a Content Security Policy, a corporate proxy, or tracker blockers), allow these. The connect-src policy to copy is in Content Security Policy.

DirectiveAllowWhy
connect-srchttps://kya.vouched.id, or your custom endpoint hostThe ingest POST. By default the beacon's own code contacts no other host. The one exception is the lazy chunk retry under Integrity check on the lazy chunk.
script-srcThe host beacon.min.js loads from (https://cdn.jsdelivr.net for the script tag above)beacon.min.js loads beacon-lazy-signals.min.js and beacon.worker.js from the directory it was served from. If you self-host, keep all the files together. Allow the lazy chunk's host too.
worker-srcThe worker's origin (your own origin)Worker mode (Web Worker mode).
base-uri'none', with a nonce-based CSPThe beacon takes its directory only from the <script> element running it, never from other markup, so injected markup can't choose what it loads.

A policy with default-src also reports a style-src-attr violation from a fingerprint probe, which costs no signal. Releases before 1.4.3 also reported a worker-src violation for each blob: worker their capability probe started (one from the lazy chunk, and a second unless useWorker is off). From 1.4.3 the probe starts no worker, so the beacon needs no worker-src blob:.

Self-host the companion files

By default the beacon looks for beacon-lazy-signals.min.js and beacon.worker.js next to the beacon.min.js tag that loaded it. A bundled install has no such tag, so the worker and the lazy signals never load. Host both files yourself, name each one, and copy them from the dist/ of the installed package:

const beacon = new CheckpointBeacon({
  projectId: '<project UUID>',
  lazySignalsUrl: '/static/checkpoint/beacon-lazy-signals.min.js',
  workerUrl: '/static/checkpoint/beacon.worker.js',
});
  • Copy both files from the same version as beacon.min.js. A mismatched chunk fails the integrity check.
  • The worker must be same-origin. Pass a same-origin workerUrl, or set useWorker: false. Otherwise the worker can't start and the beacon uses the main thread.
  • Without lazySignalsUrl, a bundled install never collects the 22 lazy signals. The chunk is also exported as @kya-os/checkpoint-beacon/lazy-signals.

lazySignalsUrl is read once at construction (Configuration options). A chunk on another origin must send Access-Control-Allow-Origin (CORS); a same-origin file needs nothing extra. Allow the chunk's host in script-src and the worker's origin in worker-src (see Network & CSP).

Integrity check on the lazy chunk

beacon.min.js and the npm build embed the SHA-384 hash of beacon-lazy-signals.min.js as it shipped in the same release. The beacon loads the chunk with that hash as its integrity and with crossorigin="anonymous". A chunk that differs by one byte never runs, and the beacon then behaves as it does when the chunk is blocked: the 22 lazy signals are missing.

CaseWhat happens
A new beacon.min.js next to an old chunkThe check fails. Upgrade them together.
An explicit lazySignalsUrlIt is checked against the same hash. Point it at the file from the same version.
A chunk served from https://cdn.jsdelivr.net or https://kya.vouched.id (the Checkpoint-hosted loader) fails to load or fails the checkbeacon.min.js requests it once from https://cdn.jsdelivr.net/npm/@kya-os/checkpoint-beacon@<its own version>/dist/beacon-lazy-signals.min.js and applies the same check. One cause is a CDN or browser cache holding the previous release's chunk beside a newer beacon.min.js. The pinned URL is not retried again.
The retry succeeds, or fails toolazySignalsStatus is loaded_pinned, or failed.
A chunk on any other origin, a self-hosted copy includedNo retry, so a self-hosted install contacts no third party.
The retry runsIt exposes the visitor's IP address, user agent, and referrer to jsDelivr, like any GET.

Only the script build has the retry, and it only helps for a version already published to npm. To let it run when you load the beacon from a Checkpoint origin, allow https://cdn.jsdelivr.net in script-src. A host-allowlist policy without it blocks the retry, reports a violation, and leaves the chunk missing, exactly as without the retry. A policy with 'strict-dynamic' ignores the host list for scripts the beacon inserts, so the retry runs whatever the list says.

crossorigin="anonymous" makes the chunk request a CORS request without credentials. A host that serves the chunk from another origin than the page must send Access-Control-Allow-Origin. The Checkpoint-hosted file and public CDNs such as jsDelivr do. Your script-src rules don't change.

The check covers the lazy chunk only. It doesn't cover beacon.min.js itself, which you pin with an integrity attribute on your own tag (see Script tag), or the worker file, because a Worker constructor has no integrity option.

Trusted Types

A page that enforces Trusted Types (require-trusted-types-for 'script') must allow the beacon's policy by name, next to its own policies:

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types checkpoint-beacon;

From 1.4.3 the beacon creates one policy, checkpoint-beacon, once per page. It passes only its own inputs through it: an http: or https: URL naming beacon.worker.js or beacon-lazy-signals.min.js on the page's own origin (in the directory the beacon's script was loaded from, or at a workerUrl or lazySignalsUrl you configured), and the fixed, script-free document a fingerprint probe renders in a hidden iframe. Every copy of the beacon shares that one policy, so the directive needs no 'allow-duplicates'.

CaseWhat happens
The directive doesn't list checkpoint-beaconThe page reports violations for the policy, the worker, and the lazy chunk, and the beacon sends every event from the main thread without the lazy signals. Earlier releases had no policy, so an enforcing page always ran them that way, whatever its directive listed.
A copy loaded from a different directory than the first has a workerUrl or lazySignalsUrl of its ownThe policy the first copy created refuses it, and it falls back to the main thread.
A workerUrl or lazySignalsUrl points at a renamed file (not one of those two names)Same refusal on an enforcing page: the worker or lazy chunk quietly fails to load and the beacon sends from the main thread without them. Host the file under its own name, or self-host with a proxy rewrite that keeps the filename, rather than renaming it.

Inside a cross-site iframe

Install the script tag in the framed document, not the page that embeds it. The beacon then works as it would on a top-level page of the frame's origin: it loads its worker and lazy chunk from next to the frame's beacon.min.js, and every batch goes out with the frame's Origin. Posture is issued for that origin too, so list the frame's origin, not the embedding page's, as the project's domain or under Additional site origins.

Chromium refuses the beacon's SameSite=Lax cookies in a cross-site frame, and other browsers block or partition third-party cookies. Without them, each load of the frame starts a new session and no posture cookie is written. Read both from onReceipt instead: store browserSessionId and pass it back as the browserSessionId option, and send the latest posture.token to your server in the KYA-Posture header (see Verifying the posture token).

Browser compatibility

The package's stated support is Chrome 90+, Firefox 88+, Safari 14+, and Edge 90+. The browser files (beacon.min.js, beacon.worker.js, beacon-lazy-signals.min.js, and beacon.modern.mjs) are built for exactly that floor, so only syntax those browsers lack is lowered. The npm ESM entry targets ES2020.

CI checks syntax esbuild can lower (nothing needed lowering at Chrome 90, Firefox 88, Safari 14, and Edge 90), plus a denylist of runtime APIs it cannot lower (.at(), Object.hasOwn, structuredClone, AbortSignal.timeout, findLast, toSorted, groupBy, Promise.withResolvers, crypto.randomUUID, Error cause, Array.fromAsync) and regex lookbehind.

The one hard requirement is crypto.getRandomValues, which mints session ids. Everything else degrades:

MissingFallback
CompressionStreamPayloads go uncompressed.
requestIdleCallback (Safari)Collection yields with setTimeout.
sendBeaconUnload sends use fetch with keepalive.
WorkerEverything runs on the main thread.
IndexedDBThe offline queue uses localStorage, then memory.

A hanging or failing IndexedDB costs only the holder-of-key binding, never delivery:

CaseBehavior
A sendWaits at most 1.6 seconds for the key, then goes out without it while generation continues.
Later sendsDon't wait again until the key resolves.
A failed key attemptRetried at most once every 30 seconds.
With a workerThe main thread decides and hands the key to the worker with the event. The first worker batch of a session carries it once, never for a Do Not Track visitor and never again once announced. The worker never waits for the key.

Bundle size

FileGzipped sizeEnforcement
beacon.min.js24,751 bytes todayA CI test fails the build when it grows past the budget or leaves under 400 bytes of headroom.
beacon.worker.jsUnder 8 KBA separate file, fetched only when used.
beacon-lazy-signals.min.js9,626 bytes todayA separate file, fetched only when used, and enforced the same way.

beacon.min.js is budgeted at 28 KB gzipped, and the lazy chunk is budgeted at 10 KB gzipped. The measurement is Node 22.23.1 at gzip level 9. The npm package is marked side-effect free, so bundlers drop the exports you don't import.