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
When the beacon sends
Every send is a full snapshot of what the page has observed so far. Each automatic send is tagged with the reason it fired (metadata.trigger). trackEvent() is tagged custom, and your own collect() carries no tag. Only one automatic send runs at a time, and triggers that arrive while one is in flight collapse into a single follow-up. The page-hide send and your own collect() and trackEvent() calls are not queued behind it.
There is no fixed-interval heartbeat, and nothing on the client suppresses the arrival send of a new full page load. A visible tab that sits idle still sends about once every 60 to 120 seconds through the posture timer, whether or not the project uses browser posture. Turn that off with triggers: { posture: false }, but that also turns off every posture refresh: the token lives 120 seconds, so once the page stops sending for other reasons, enforcement has no fresh posture after about two minutes. Do Not Track, destroy(), and the hosted script's Shopify consent gate apply to every row above. Each trigger's switch is in Tuning dispatch.
The last two rows are on by default. Turn them off with the navigation trigger:
const beacon = new CheckpointBeacon({
projectId: '<project UUID>',
triggers: { navigation: false },
});Already calling collect('pageview') on route changes? From 1.4.3 the beacon records soft
navigations itself, so every route change sends twice. Set triggers: { navigation: false }, or
remove your own call. The hosted script has no such call to remove.
Beacon API
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 domain-unset or origin-mismatch. Calling it on every keystroke or pointer move costs at most one request.
generateButton.addEventListener('focus', () => {
checkpoint.prepareForAction({ action: 'image_generate', maxPostureAgeMs: 30_000 });
});
async function onGenerateClick() {
checkpoint.prepareForAction({ action: 'image_generate', maxPostureAgeMs: 30_000 });
await callImageGenerate();
}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. That call also starts a new session, and so does passing a different userId than the one the session already has. If the identity is known at render, pass identity in the constructor instead.
beacon.identify({ userId: 'user_123' });
beacon.identify();handoffUrl(url)
Resolves with url plus a single-use checkpoint_handoff token bound to your project and the current session. Put the result in an email, a QR code, or a link to another domain that runs the same project's beacon or pixel. The landing page sends the token with its first event, Checkpoint continues the original session there, and the page strips the parameter from the address bar.
const link = await beacon.handoffUrl('https://shop.example.com/cart');The token is an opaque random value that Checkpoint stores for 15 minutes, and it works once: the first page to redeem it continues the session. Checkpoint issues a token only for a session it has seen, so the request carries the nonce from the session's latest response (valid for 10 minutes). A session can be adopted at most twice, one issuing device can hand off to at most three distinct devices in the 30 days after its first handoff, and one verified network and user agent can hand off to at most 20 distinct devices per hour, not counting redeemers on the same network and user agent as the issuer. A repeat use by the same redeemer within 15 minutes of the redemption returns the same session; any other later use starts a fresh session. A redemption without a valid device id is rejected before the token is read: the token is not spent and nothing is counted, and the landing page keeps its own session and latch. A visitor with Do Not Track or blocked storage sends no device id, so it never adopts. The promise resolves with the URL unchanged, without a network call, when cookies are not allowed (Do Not Track) and when there is no nonce yet. It also resolves with the URL unchanged, after the request, when Checkpoint cannot issue a token. With Do Not Track on the beacon removes the parameter and never redeems it. When Checkpoint could not reach its handoff state in time (handoff.status is unavailable) the parameter stays and the next batch retries, up to three attempts; after the third the beacon drops the token and removes the parameter. The response is applied only if the session did not change in the meantime. The beacon removes the parameter from the url and referrer it sends. If the destination is identified as a different user than the one who made the link, Checkpoint answers rejected, the page keeps its own session and the token stays unspent; an anonymous destination records the issuing user on the adopted session.
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.
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.
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, orworkerConfigrestarts the worker. - Setting
browserSessionIdmoves the next send onto that session. flushIntervalandbatchSizeare accepted and ignored (deprecated no-ops).- Setting
respectDoNotTrack: truefor a DNT visitor mid-session releases the interaction listeners and evidence-dispatch triggers, expires the_as_beacon_sessioncookie and thecheckpoint_userandagentshield_useridentity cookies, 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. It also discards events still held in memory, in the worker as well, so nothing queued is sent after the flip.
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();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:
How the privacy settings combine:
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).
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:
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 ends after the idle gap, like any other. 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:
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:
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 declares its full data inventory in a network contract, 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:
The stored DPoP keypair and its sent marker are not cleared, and neither is a posture cookie already stored, which expires by itself within its 120 second lifetime. The nonce keys and the offline queue are removed, and events still queued in memory, in the worker as well, are discarded, so nothing queued is sent after the flip.
Consent
The beacon withholds nothing on consent. requireConsent, grantConsent() and the script tag's data-require-consent are accepted and ignored, so existing installs and typed configs keep working (the first use on a page logs one console.warn saying so), and the hosted loader starts the beacon on every load except on a Shopify storefront, where Shopify's Customer Privacy API is still honored (below). Only Do Not Track (respectDoNotTrack) stops collection everywhere else. Storage the browser blocks only removes what would be stored: no device id is sent and the session id lives in memory.
On a Shopify storefront the hosted script constructs nothing, sends nothing and writes no identity cookie until Shopify reports that analytics processing is allowed (consent given, or not required in the visitor's region). It reads the answer on load and listens for visitorConsentCollected. When the answer is not allowed, at load or later, it destroys the beacon and expires checkpoint_user, agentshield_user and the _as_beacon_session cookie, and removes the project's stored nonces and the stored offline queue, so a later grant in the same page view starts a fresh beacon with a new session. The loader starts nothing when Shopify's API cannot be read.
CheckpointBeacon.clearIdentity() expires checkpoint_user and agentshield_user on request, for a page that wants to forget the device. Passing the project id, CheckpointBeacon.clearIdentity(projectId), also expires the default-named _as_beacon_session cookie, removes every stored nonce whose key starts with that project id (so a project whose id extends it loses its nonces too, and the server tags its next batch as having no nonce) and deletes the whole stored offline queue for the origin, which is shared by every beacon there. A beacon that was destroyed before the clear no longer writes failed sends to the stored queue, so a send that fails after a withdrawal cannot bring the old session back; a plain destroy() still keeps its stored retries. The Java SDK reads those cookies on the server, so it stops reporting the id once the browser stops sending them.
Cookies the beacon writes
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.
A withheld token never drops the visit: the detection is still recorded.
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.
A project's key id changes only when its key is rotated. Rotation publishes the next key in keys
before it is used and keeps the retiring key there until its tokens expire. A server that reads
the keys endpoint instead of pinning, such as a Java server configured with
BrowserPostureOptions.remoteKeys, picks the change up on its own within about two minutes. A
server that pinned one key must take the new one.
detection is { "class": "ai_agent" | "bot", "name": string, "confidence": integer }: the beacon's final verdict for the session, present only when that verdict is an agent or a bot. name is 1 to 64 characters with no control characters and confidence is 0 to 100. The beacon keeps it on later tokens for the session for 30 minutes, so a batch without the evidence does not drop it. A detection that is present but malformed makes the whole token invalid. Readers that predate the claim ignore it.
When a verified token carries detection, the Express and Next.js SDKs reclassify a request their own engine called human or incomplete data as that agent or bot. Without it they apply automation_disclosed (a bot named Automation Tools at confidence 80), then automation_corroborated (incomplete data at confidence 40), then an agent-driven verdict. They never downgrade an agent or bot the engine already found.
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-SessionA 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 beacon doesn't try to start a worker the browser would refuse, and uses the main thread. 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',
});workerConfig tunes the manager:
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.
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
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: *.
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.
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 setuseWorker: 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.
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'.
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).
Domains, proxies and CDNs
Detection works on any page that loads the beacon. The domain settings matter only for the signed posture token, the __Host-checkpoint_posture_* cookie that enforcement reads.
Site URL and additional site origins
Ingest issues a posture token only when the page's origin is one the project allows. Verifying the posture token has the matching rule. The rest is here:
- Site URL (the project's primary domain): its
https://origin is always allowed. A bare host such aswww.example.comreads ashttps://www.example.com. A Site URL with a path, withhttp://, or with a username is ignored. With no additional site origins either, posture is withheld asdomain-unset. - Additional site origins: up to 20 more. Edit them in the project's settings, directly under the Site URL, or on the Installations page. Add a custom storefront domain,
wwwnext to the apex, staging hosts, or every host your pages load on. The Shopify app adds the store's own domains for you and never removes ones you added.
Rules for each entry:
https://only, with no IP addresses and no username.https://*.example.comcovers every subdomain ofexample.com(shop.example.com,a.b.example.com) but neverexample.comitself. List the apex separately.- A wildcard over a shared hosting suffix is refused, because other tenants of that platform could mint posture for your project:
*.myshopify.com,*.vercel.app,*.netlify.app,*.pages.dev,*.github.io, and similar, plus single labels (*.com) and country second levels (*.co.uk). - The signed token always names the one concrete page origin that matched, never the pattern. The Gateway accepts it only on that origin. A server SDK accepts it when that origin is one you configured for the SDK (Set up browser posture).
- A change reaches the Gateway in about a minute. Token issuance at ingest follows within the window given in Verifying the posture token.
List where your pages load, not where the beacon script or ingest is served from.
Putting a proxy or CDN in front of the hosted script
You can serve the hosted script and the ingest from your own domain. A proxy host needs no entry in Additional site origins. It must forward these paths, and their responses, unchanged:
- The hosted script posts to
/api/v1/beaconon its own host and loads the lazy signals chunk from that host's root. It asks for/beacon.worker.jsonly when the page is on the proxy's own origin, because a worker script must be same-origin with the page. - Serve
beacon.jsand the lazy signals chunk from the same release, byte for byte. The script checks the chunk against a hash built into it (Integrity check on the lazy chunk), so a proxy on a different host from your pages must also pass the chunk'sAccess-Control-Allow-Originheader through. - Forward the browser's
Originheader untouched. Posture is issued for that origin, and theRefererorigin is used whenOriginis missing.X-Forwarded-Hostand similar headers are ignored, so a proxy that rewritesOrigin, or drops both it andReferer, getsorigin-mismatch. - Let the CORS preflight through:
OPTIONSwithContent-Type,X-Project-ID, andX-Content-EncodinginAccess-Control-Request-Headers. - To also forward the visitor IP and TLS fingerprint your edge saw, see Forward edge evidence. For the npm package, the CLI generates a proxy for you: see Serve the beacon through your own domain.
Example: pages on one host, tags served from another
A site serves its pages on https://www.example.com and https://help.example.com, and serves all of its tags from https://tags.example.net, which proxies to Checkpoint as in the table above.
<script
src="https://tags.example.net/checkpoint/beacon.js"
data-project-id="YOUR_PROJECT_ID"
></script>- Site URL:
https://www.example.com. - Additional site origins:
https://help.example.com.https://*.example.comwould also work and covers future subdomains too. tags.example.netis not listed anywhere. It serves/checkpoint/beacon.js,/beacon-lazy-signals.min.js, and/api/v1/beacon, and passesOriginthrough. It never gets a request for/beacon.worker.js, because the pages are on another origin.
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:
A hanging or failing IndexedDB costs only the holder-of-key binding, never delivery:
Bundle size
beacon.min.js is budgeted at 29 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.
