# WhiskerJS > WhiskerJS is an open-source (Apache-2.0) browser library that reads device and browser signals in a web page and returns them as one plain, versioned JSON result: 46 components, a 128-bit visitorId, a group-by-group structuredId and a list of integrity flags. It is a collector, not a scoring engine: it never sends anything, never stores anything and never decides "human or bot". The site that uses it sends the result to its own server, which decides. Key facts (version 0.1.0, signalsVersion 5, idVersion 2): - Status: in development. Not yet published to npm or any registry; build it from source. The npm package is coming. - Licence: Apache License 2.0. Two small parts are adapted from MIT-licensed projects (Math operations from FingerprintJS 5.3.0, media MIME lists from fpscanner 1.0.8) with their notices kept. - 46 components; 37 stable ones form the visitorId, each in one of 7 groups (gpu, audio, fonts, device, browser, locale, prefs), the parts of the structuredId; 9 are reported but kept out of both ids. - 14 integrity flags (signs of automation or tampering). Flags are hints for the server, never a verdict. - Privacy: no network requests, no telemetry, no storage (no cookies, localStorage, sessionStorage or IndexedDB), no permission prompts, no console output. Large values leave the page only as the first 128 bits of SHA-256. - Optional anti-replay binding: the server's nonce and timestamp are echoed in the result with a digest over the whole result. It stops replay of an old result; it is not a signature. - Privacy browsers are not worked around: Brave farbling and Firefox resistFingerprinting (also Tor Browser) make WhiskerJS exclude the affected components. - No accuracy, stability or uniqueness figures are published yet: the head-to-head measurement against other libraries has not run. - Ids describe a browser setup, not a person, and are comparable only within the same signalsVersion and idVersion. This file concatenates every documentation and guide page of https://whiskerjs.com as Markdown, in reading order. Each page starts with its title and its source URL. --- # Overview > What WhiskerJS is, what it returns, and how to read the live demo. Source: https://whiskerjs.com/docs/ (last updated 2026-10-26) > **In plain words:** > > WhiskerJS is an open-source (Apache-2.0) script for web pages. It reads technical characteristics of the visitor’s browser and device, such as screen size, fonts and how a test image is drawn, and returns them as one JSON object. Your own server then decides what to do with it: recognise a device it has seen before, or notice that something looks automated. WhiskerJS itself never decides, never sends anything and never stores anything. ## What you get from one call ```ts import { load } from 'whiskerjs'; const agent = await load(); const result = await agent.collect(); ``` | Field | What it is | | --- | --- | | `components` | 46 measurements, each with a status (`ok`, `unavailable`, `blocked`, `noisy`, `excluded`, `timed_out`) and, when not ok, a reason code | | `visitorId` | a 32-character hash of the 37 stable components: equal ids mean the same browser setup | | `structuredId` | seven 12-character hashes, one per signal group, so two results can be compared group by group | | `integrityFlags` | names of the 14 checks for automation and tampering that fired | | `binding` | your server’s nonce and timestamp, bound to the result with a digest (only if you pass them) | | `version`, `signalsVersion`, `idVersion` | `0.1.0`, `5`, `2`: store them with every id | | `durationMs` | how long the collection took | ## What it is and is not | WhiskerJS is | WhiskerJS is not | | --- | --- | | a collector of device and browser signals | a bot verdict or a risk score | | a library that runs in your page | a hosted service or an API | | stateless: every call starts from nothing | a tracker: it stores no cookie or id | | silent on the network | telemetry: it never phones home | | open source under Apache-2.0 | on npm yet (build it from source) | ## The pages - [Getting started](https://whiskerjs.com/docs/getting-started/): Build it, add it to a page, collect once. - [How it works](https://whiskerjs.com/docs/how-it-works/): The pipeline, timeouts, statuses, hashing, noise. - [Signals](https://whiskerjs.com/docs/signals/): All 46 components and what they read. - [Identifiers](https://whiskerjs.com/docs/identifiers/): visitorId, structuredId and comparing results. - [Integrity checks](https://whiskerjs.com/docs/integrity/): 14 flags and their false positives. - [Privacy](https://whiskerjs.com/docs/privacy/): What is and is not collected; the notice to give. - [Integration](https://whiskerjs.com/docs/integration/): Your server, the onResult hook, anti-replay. - [FAQ](https://whiskerjs.com/docs/faq/): Short answers to common questions. - [API reference](https://whiskerjs.com/docs/api/): Every export, option and type. ## Reading the live demo The [live demo](https://whiskerjs.com/demo/) runs the real library (the build of commit `1100e48`, signalsVersion 5, idVersion 2) in your browser and shows exactly what it returns. Nothing is sent anywhere; the demo page keeps a history of your runs in your own browser so it can compare them. - **visitorId** (top left): the id for this browser setup. The chip under it says whether it matches your previous run on this device. - **Version tiles**: `version`, `signalsVersion` and `idVersion` of the library, the run’s duration, how many components are `ok`, and how many times you have loaded the page. - **structuredId**: the full value and one tile per group. After a second run, a group whose part changed is highlighted, which tells you *what* moved (for example `gpu` after a graphics driver update). - **Integrity flags**: usually empty on a normal browser. “What every flag means” explains each one; see [Integrity checks](https://whiskerjs.com/docs/integrity/) for the false positives (privacy extensions often set `native_tampering`). - **Components table**: every component with its status, the reason code when it is not ok, whether it counts in the visitorId, and its value. Values that are 32 hex characters are digests, not raw data. Common non-ok rows are normal: `device_memory` and `heap_limit` are `unavailable` outside Chromium-based browsers, `audio_latency` is `unavailable` with `skipped` outside Safari and Android, and under Brave several components are `excluded` with `brave_farbling`. - **History**: newest first, with the groups and components that changed since the run before. Runs from different library versions are never compared; the list marks the boundary. - **Lab testing mode**: label the device, load the page a few times, and copy one tab-separated line per run into a sheet to measure stability on your own devices. “Copy full JSON” includes the anti-replay binding (the demo makes its own nonce; a real site gets it from its server). What to try: run it twice (the id should hold), open a private window (some browsers add canvas noise there, and `canvas` or `webgl_image` turn `noisy`), or change the browser language (usually only the `locale` part of the structuredId changes). --- # Getting started > From the source code to a first result in your page. Source: https://whiskerjs.com/docs/getting-started/ (last updated 2026-10-26) > **In plain words:** > > WhiskerJS is a small script you run in your web page. You call `collect()`, wait a moment, and get back one JSON object describing the browser and device: 46 measurements, two identifiers and a list of warning flags. You then send that object to your own server with a request you already make. The library is not on npm yet, so you build it from source first. This page takes you from the source code to a first result in a page. It assumes you know how to run a JavaScript build and serve a web page. ## 1. Build the library WhiskerJS is not on npm yet, and its source repository opens with the first public release. With the source in a folder named `whiskerjs`: ```sh cd whiskerjs pnpm install # dev dependencies only: TypeScript and Node types pnpm build # writes dist/: ES2020 modules plus .d.ts type files pnpm test # optional: runs the test suite with node:test ``` Requirements: Node 18 or newer (tested on Node 22) and pnpm 10 (`corepack enable` gives you the pinned version). ## 2. Add it to your project Pick one of these: - **As a package.** In the WhiskerJS folder run `pnpm pack`. It writes `whiskerjs-0.1.0.tgz` with `dist/`, `LICENSE`, `NOTICE` and `THIRD_PARTY_NOTICES.md`. In your project: `npm install ../whiskerjs/whiskerjs-0.1.0.tgz` (or the pnpm or yarn equivalent). Your bundler then resolves `import { load } from 'whiskerjs'`. - **As plain files.** Copy `dist/*.js` and `dist/collectors/*.js` (keep the folder layout) to your static files and load `index.js` as a module: ``. This is how the live demo on whiskerjs.com runs it. There are no runtime dependencies and no CDN. ## 3. Collect once ```ts import { load } from 'whiskerjs'; const agent = await load(); // cheap: no work happens yet const result = await agent.collect(); // at most 5 s by default (1 s per component) console.log(result.visitorId); // '3feb3ead31e6b520459ad33ebc58f2d7' console.log(result.integrityFlags); // [] or e.g. ['automation_webdriver'] ``` `collect()` never throws and never rejects, whatever the browser does. Collect in the browser, after the page has a `
`: several probes add hidden elements to the body for a moment, and without one they report `unavailable`. The page must be a **secure context** (https, or `http://localhost` while developing). Hashing uses `crypto.subtle`, which browsers only offer there. Without it `visitorId` and `structuredId` are `null` and every large component reports `unavailable` with ruleId `no_digest`. ## 4. Read the result ```ts for (const [name, c] of Object.entries(result.components)) { if (c.status === 'ok') console.log(name, c.value, c.hashed ? '(digest)' : ''); else console.log(name, c.status, c.ruleId); } ``` - `visitorId`: one hash over the 37 stable components. Equal ids mean the same browser setup, as far as these signals can tell. - `structuredId`: seven short hashes, one per signal group, so you can see *which part* of a device changed. See [Identifiers](https://whiskerjs.com/docs/identifiers/). - `components`: all 46, always present, each with a status. See [Signals](https://whiskerjs.com/docs/signals/) and [How it works](https://whiskerjs.com/docs/how-it-works/). - `integrityFlags`: names of checks that saw signs of automation or tampering. A hint for your server, not a verdict. See [Integrity checks](https://whiskerjs.com/docs/integrity/). - `signalsVersion` and `idVersion`: store them with every id; ids are only comparable within the same versions. ## 5. Send it to your own server WhiskerJS has no network code. Add the result to a request you already make, for example the login form post: ```ts const result = await agent.collect({ timeoutMs: 3000, binding: await getNonceFromMyServer(), // { nonce, timestamp }, optional }); await fetch('/api/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password, device: result }), }); ``` The result is plain JSON, about 4 KB. Read [Integration](https://whiskerjs.com/docs/integration/) for what to check on the server, and [Anti-replay binding](https://whiskerjs.com/docs/integration/#anti-replay-binding) for the nonce. ## 6. Tell your users Device signals can be personal data. Before you deploy, read [Privacy](https://whiskerjs.com/docs/privacy/) and add a notice to your privacy policy. ## Try it without building The live demo at [whiskerjs.com/demo](https://whiskerjs.com/demo/) runs the real library in your browser and shows every component, both ids and the flags. --- # How it works > The collection pipeline, step by step, with every limit and reason code. Source: https://whiskerjs.com/docs/how-it-works/ (last updated 2026-10-26) > **In plain words:** > > When you call `collect()`, WhiskerJS asks the browser 46 small questions at the same time (screen size, fonts, how a test image renders, and so on). Each question has one second to answer. Every answer gets a status: it worked, it is not available here, it was refused, it was random, it was skipped on purpose, or it took too long. Big answers are turned into short fingerprints (hashes) so the raw data never leaves. Then it checks the page for signs of robots or faked values, and builds two identifiers from the answers that rarely change. In one sentence: `collect()` runs 46 small, independent probes at the same time, each against its own clock, turns whatever happens into a status, hashes anything large, checks the page for signs of automation or tampering, and derives two identifiers from the stable part. This page explains each step in plain words first, then in exact detail. Everything here describes the code at signalsVersion 5 and idVersion 2. ## The pipeline ```text load(options) pick the window, clock, timers and crypto (once) | collect(options) 1. budget overall deadline = now + timeoutMs 2. browser class brave, firefox_rfp, firefox, safari, chromium, other 3. run 46 collectors in parallel; each one: excluded? -> race(work, timer, abort) 4. clean up release the shared WebGL context 5. finalise sanitise each outcome; hash large or unprintable values 6. integrity checks 14 checks, some against a fresh blank iframe 7. identifiers visitorId and structuredId from the stable components 8. binding echo your nonce and timestamp, digest over the result 9. onResult hook your optional callback gets a copy -> resolve with the result ``` ### Step by step 1. **Budget.** `timeoutMs` (default 5000) sets the deadline for the whole call. A value that is not a finite number falls back to 5000; it is clamped to the range 0 to 60000. 2. **Browser class.** A cheap check of `navigator.brave`, the user agent, `navigator.vendor` and, for Firefox, the resistFingerprinting traits. The class picks a row of the exclusion table (below) and is itself the `browser_class` component. 3. **Collectors.** All 46 start at once (`Promise.all`). For each one, in this order: - named in `collect({ exclude })`: `excluded`, ruleId `caller_excluded`; - listed in the exclusion table for this browser class: `excluded` with the table’s ruleId; - the abort signal has already fired: `timed_out`, ruleId `aborted`; - otherwise its work races three things: its own result, a timer, and the abort signal. The timer is the smaller of the collector’s inner timeout (1000 ms for every collector today) and the time left before the overall deadline. A collector that loses the race is not cancelled; its late answer is simply ignored. 4. **Clean up.** Collectors that share a resource release it once all of them have settled. Today that is the single WebGL context, which is released with `WEBGL_lose_context`. Probes that add elements to the page (font measurement, font preferences, content-blocker baits) remove them in their own `finally` block. 5. **Finalise.** Each outcome becomes the public component shape (see “Hashing” below). 6. **Integrity checks.** Fourteen read-only checks, two of them against a clean iframe realm. See [Integrity checks](https://whiskerjs.com/docs/integrity/). 7. **Identifiers.** See [Identifiers](https://whiskerjs.com/docs/identifiers/). 8. **Binding.** Only when you pass `binding`. See [Anti-replay binding](https://whiskerjs.com/docs/integration/#anti-replay-binding). 9. **Hook.** When `load({ onResult })` was given, it is called with a deep copy of the result. See [Integration](https://whiskerjs.com/docs/integration/). `durationMs` is the whole time from step 1 to the end of step 7, rounded to whole milliseconds, read from the injected clock (`performance.now()` by default). If anything outside every guard fails, `collect()` still resolves: with a fallback result where every component is `blocked` with ruleId `internal_error`, both ids `null` and no flags. ## Timeouts and budgets | Limit | Value | What happens when it is hit | | --- | --- | --- | | Inner timeout | 1000 ms per collector | that component is `timed_out`, ruleId `timeout` | | Overall budget | `timeoutMs`, default 5000, max 60000 | every component still pending is `timed_out`, ruleId `timeout` | | Abort | `collect({ signal })` | every component still pending is `timed_out`, ruleId `aborted`; the iframe checks are skipped | | Voices wait | up to 600 ms inside `speech_voices` | the list is read as it is then | | Bait wait | 50 ms inside `content_blockers` | cosmetic filters get this long to hide the baits | A `timeoutMs` of 0 still resolves with every component present (most of them `timed_out`). The other steps (finalising, integrity checks, hashing) are not under the budget, but they are short and do not wait on the browser except for `crypto.subtle.digest`. ## Statuses and reason codes Every component is always present and has exactly one status. | Status | Plain meaning | ruleIds you can see | | --- | --- | --- | | `ok` | the value was read | none | | `unavailable` | this browser does not offer the API, or it gave nothing usable | `no_api`, `no_document`, `no_context`, `no_layout`, `no_match`, `no_value`, `no_digest`, `skipped` | | `blocked` | the API refused or failed | `security_error`, `threw`, `empty_image`, `no_buffer`, `short_buffer`, `silent`, `bad_outcome`, `internal_error` | | `noisy` | two identical renders differed, so the browser adds random noise | `render_mismatch` | | `excluded` | not collected, on purpose | `caller_excluded`, `brave_farbling`, `firefox_rfp` | | `timed_out` | it ran out of time or was cancelled | `timeout`, `aborted` | What each ruleId means: | ruleId | Meaning | | --- | --- | | `no_api` | the API or constructor does not exist here | | `no_document` | there is no `document` (or no `document.body` where one is needed) | | `no_context` | a canvas, WebGL or WebGPU context or adapter could not be created | | `no_layout` | elements were added but the page laid nothing out (for example `display: none` on the body) | | `no_match` | no media query option matched | | `no_value` | the API answered with nothing usable | | `no_digest` | the value had to be hashed but `crypto.subtle` is missing or failed | | `skipped` | read only in some browsers on purpose (`audio_latency`, see [Signals](https://whiskerjs.com/docs/signals/)) | | `security_error` | the browser threw a `SecurityError` or `NotAllowedError` | | `threw` | the browser threw any other error | | `empty_image` | the canvas export was empty or not an image | | `no_buffer`, `short_buffer`, `silent` | the offline audio render gave no buffer, too few samples, or only zeros | | `bad_outcome` | a collector returned something that is not an outcome (a guard; not expected) | | `internal_error` | the fallback result: something outside every guard failed | | `render_mismatch` | two renders of the same scene differed | | `caller_excluded` | you excluded it with `collect({ exclude })` | | `brave_farbling`, `firefox_rfp` | the exclusion table removed it for this browser class | | `timeout`, `aborted` | inner timeout or overall budget; abort signal | | `unknown` | a guard: a reason that is not a well-formed name is replaced with this | A ruleId always matches `^[a-z0-9_]{1,32}$`. Some `ok` components report a plain word instead of a measurement: `none` (nothing found, for example no plugins or no content blocker), `exact` or `altered` (`canvas_readback`), `he=blocked`, `he=failed` or `he=none` inside `ua_client_hints` and `ua_versions` (the detailed Client Hints were refused by a Permissions Policy, failed, or do not exist). ## Hashing Plainly: anything big or unusual leaves the library only as a fingerprint of itself, never as the raw thing. Exactly: - The digest is SHA-256 from `crypto.subtle.digest`, cut to its first 128 bits and written as 32 lowercase hex characters. Strings are UTF-8 encoded first; byte values (audio samples, WebGL pixels) are hashed as bytes. - Thirteen components are **large** and always hashed: `audio`, `canvas`, `font_preferences`, `fonts`, `fonts_canvas`, `math`, `media_codecs`, `permissions`, `plugins`, `speech_voices`, `webgl_image`, `webgl_params`, `webgpu`. The exception is a plain sentinel: `fonts`, `fonts_canvas`, `plugins` and `speech_voices` report `none` unhashed when they find nothing. - Every other component is reported as is, **unless** its value is longer than 128 characters or contains a character outside printable ASCII (0x20 to 0x7e). Then it is hashed too. This is why a long `webgl_renderer`, a long `ua_versions` or a keyboard layout in a non-Latin script can arrive as a digest. - `hashed: true` tells you the value is a digest; `hashed: false` that it is the plain value. - Without a working `crypto.subtle` a value that must be hashed is never sent raw: the component becomes `unavailable` with ruleId `no_digest`. ## The exclusion table Some browsers randomise or normalise certain signals on purpose. Reading them would only add noise to the id, so for those browser classes the components are not collected at all and report `excluded`. | Browser class | Excluded components | ruleId | | --- | --- | --- | | `brave` | audio, canvas, fonts, fonts_canvas, hardware_concurrency, plugins, webgl_image | `brave_farbling` | | `firefox_rfp` | canvas, hardware_concurrency, pixel_ratio, screen_frame, screen_resolution, time_zone, webgl_image, webgl_renderer, webgl_vendor | `firefox_rfp` | | `firefox`, `safari`, `chromium`, `other` | none | | - **Brave** is recognised by `navigator.brave.isBrave` being a function. Its “farbling” adds per-site, per-session noise to these signals. - **Firefox with resistFingerprinting** (also Tor Browser) reports fixed or normalised values. Firefox is classed `firefox_rfp` only when every one of these holds at once: the time zone is `UTC`, `Etc/UTC` or `Atlantic/Reykjavik`; the screen is exactly the size of the content area (`innerWidth` and `innerHeight`); the available screen area equals the screen (no taskbar); `devicePixelRatio` is 1; `screenX` and `screenY` are 0; and `navigator.webdriver` is not true. Each trait alone is common in stock Firefox, and stock Firefox under automation (Playwright runs it in UTC with the screen equal to the viewport) matches several, so the test errs towards plain `firefox`. A missed resistFingerprinting browser only leaves normalised values in; a false positive would drop nine components (seven of them in the visitorId). The table is exported as `EXCLUSION_TABLE`. Changing it bumps `signalsVersion`. ## Noise handling: the double render Some browsers and extensions add small random changes to canvas and WebGL readback, often only in private windows. WhiskerJS cannot know in advance, so it checks: - `canvas` draws the same scene (text, an emoji, blending, an even-odd shape) twice on the same canvas and compares the two `toDataURL()` strings. - `webgl_image` draws the same shaded triangle twice and compares the two `readPixels()` buffers byte by byte. If the two differ, the component is `noisy` with ruleId `render_mismatch` and carries no value. In the visitorId it counts as “missing”, like any other non-ok status, so the id stays the same from run to run instead of changing every time. `canvas_readback` is a separate, simpler probe: it paints a known colour into a 1x1 canvas and reports `exact` or `altered`. ## The clean iframe realm Several integrity checks need a second, untouched copy of the browser’s built-in objects to compare against. WhiskerJS makes one like this: - after the collectors finish, it creates one `