Webbläsare
Browser agent
Install and start the agent, identify visitors, and handle errors, single page apps and local development.
The agent collects device signals in the browser and sends them to the API, which stores an event and answers with the visitor's visitorId and the event's requestId. Its report is about a kilobyte.
Install
There are two ways in: the hosted script to try things, and the npm package through your own domain for production.
Try it: the hosted script
The quickest way is the hosted script: it serves the agent from the agent's origin and exposes start on the global FingerprintAgent. It works the same on a static site (Netlify, Vercel, S3, GitHub Pages): nothing runs on your server. It always loads from the API's own domain, so content blockers that list that domain stop it; use it to try things, not on a live site.
<script src="https://zenocdn.net/agent.js"></script>
<script>
const agent = FingerprintAgent.start({ endpoint: 'https://zenocdn.net', publicKey: 'pk_...' })
</script>For production: npm and your own domain
For a live site, install zeno-id and serve the agent from your own domain: a custom domain, a subdomain of your site you add in Settings › Custom domain with one DNS record. The package is a small typed loader, not the agent itself: when the page runs, start() fetches the current agent from your endpoint, so bundling it into your app doesn't freeze an old version, and our fixes reach your visitors within the hour the agent is cached for.
bun add zeno-idimport { AgentError, start, type AgentErrorCode } from 'zeno-id'
// Your custom domain; the agent's own origin when left out.
const agent = start({ publicKey: 'pk_...', endpoint: 'https://metrics.example.com' })Name the subdomain something plain, never with fp or fingerprint in it; Choose a subdomain says why.
Start
start() begins collecting right away and returns an agent. Call it once per page load, early, like when a login form opens; one agent serves every later get().
const agent = start({
publicKey: 'pk_...',
endpoint: 'https://metrics.example.com',
cache: { storage: 'sessionStorage', ttl: 60 },
})| Option | Description |
|---|---|
publicKey | The project's public key (pk_). |
endpoint | Where the agent loads from and identifies: your custom domain, like https://metrics.example.com. The agent's own origin, https://zenocdn.net, when left out. There's one endpoint and nothing else is tried: a failure rejects get() at once with its code. |
cache | { storage: 'sessionStorage' | 'localStorage', ttl: seconds }: reuses an identification for the same externalId, action and metadata instead of identifying again. Capped answers and errors aren't cached. |
A cached result is the same requestId, so it gets older while it's reused. Keep the ttl well under the age your server accepts (the quickstart refuses events older than 5 minutes), like 60 seconds. The cache key holds the external ID, so another user on the same browser identifies afresh; for a get() that must never be cached, like a payment, use a second agent started without cache.
Identify
const result = await agent.get({ externalId: user.id, action: 'login' })
if (!result.capped) {
const { requestId, visitorId, returning } = result
}| Option | Description |
|---|---|
externalId | Your own id for whoever is behind the browser, like a user or customer id, to look visitors up by and to erase later. 1 to 256 characters; an empty or longer one throws invalid_options before anything is sent. Use an internal, pseudonymous id, not an email address or a name. |
action | What the visitor is doing, like login, signup or checkout, up to 1,024 characters. Returned with the event, and the Events page filters by it. Keep personal data out of it. |
metadata | Your own key-values kept with the event, like { orderId: 'A-1042', plan: 'pro' }: up to 20 keys of 1 to 40 letters, digits, _, . or -, each value a string up to 256 characters, a number or a boolean (nothing nested), 2 KB as JSON. Returned with the event, in webhooks and sealed results, never used for detection and not searchable. The API refuses metadata past these limits with invalid_request and what's wrong. Keep personal data out of it. |
timeout | Milliseconds before giving up, 10 seconds by default, loading the agent included, so an endpoint that hangs uses all of it. |
The result holds requestId (the event's id), visitorId and returning (whether the project had seen this visitor before). Send the requestId to your server and read the event there; flags never come back to the browser. In zero trust mode the result has only the requestId, and with sealed results on it also has a sealedResult for your server to open. A capped identification resolves { capped: true } instead.
The agent also sends the page url and referrer without their query string and fragment, which events keep as origin and path, as the origin only or not at all, as the project's settings say. It hashes the request body itself for the x-amz-content-sha256 header the hosted API requires.
The socket proof
While it collects, the agent also opens a WebSocket (a connection that stays open, unlike a plain request) to the endpoint's origin (the agent's own or your custom domain), wss://…/ws, and sends back a challenge the API writes to it, as proof that a browser and not a plain HTTP client is identifying; see the socket proof. A page with a Content Security Policy needs the endpoint's host for both the request and the socket in connect-src, like connect-src 'self' https://metrics.example.com wss://metrics.example.com (or https://zenocdn.net wss://zenocdn.net for the API's own origin), or the socket fails and the events go without the proof. The npm loader also needs the agent's source in script-src, and the agent's worker check worker-src blob:; Content Security Policy has the lines for a custom domain.
The persistent visitor id
The visitor id is kept in localStorage (fp:visitor:<public key>) and a first-party cookie (_fpvid_ and the key's last 8 characters) and sent back on the next identification. Both are per public key, so two projects on one site never hand each other their ids. The service reuses the stored id while the device's hardware still matches, so it survives browser updates that shift canvas or audio output, and can't be copied to another device.
Stop and forget
agent.stop() ends an agent for good, as when a visitor withdraws consent: a get() in progress and every later one reject with agent_stopped, nothing more is collected or sent, the socket closes, and the visitor id and cached results stored for its public key are removed (another project's stay). An agent still loading from npm never starts. forget({ publicKey }), exported by the npm package and on the script's FingerprintAgent global, removes the same entries without starting anything. Call start() again to identify afterwards. Privacy and consent shows both in a consent integration.
Single page apps
In React, Vue, Svelte or any other SPA:
- Start once, in a module, not in a component: a component re-renders and remounts, and each
start()collects again. - Call
get()in the handler of the action you protect (the login or signup submit), not in an effect. React's StrictMode runs effects twice in development, and two concurrentget()calls make two events, both billed. If it has to be an effect, guard it with a ref. - One agent, many
get()calls: after the first, aget()is mostly the network round trip.
// src/fingerprint.ts: one agent for the whole app. start() collects the
// signals once; every get() reuses them.
import { AgentError, start } from 'zeno-id'
export const agent = start({ publicKey: 'pk_...', endpoint: 'https://metrics.example.com' })
// The requestId to send to your server, or undefined when nothing was
// identified (capped, blocked, timed out): the server challenges those.
export const identify = async (options: { externalId?: string; action?: string }) => {
try {
const result = await agent.get(options)
return result.capped ? undefined : result.requestId
} catch (error) {
if (error instanceof AgentError) {
console.warn('Identification failed', error.code, error.message)
return undefined
}
throw error
}
}import { identify } from './fingerprint'
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const user = await logIn(email, password)
const requestId = await identify({ externalId: user.id, action: 'login' })
await fetch('/api/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId }),
})
}With server-side rendering, create the agent on first use in the browser:
// Next.js, Nuxt, SvelteKit: start() reads browser APIs, so create the agent
// on first use in the browser, never while the server renders.
import { start } from 'zeno-id'
let agent: ReturnType<typeof start> | undefined
export const getAgent = () => (agent ??= start({ publicKey: 'pk_...', endpoint: 'https://metrics.example.com' }))Local development
- Your custom domain works from your dev server as it is; add the dev server's origin to the allowed origins.
- Allowed origins: add your dev server's origin; see Allowed origins.
- Secure context: the agent needs https, or
localhostand127.0.0.1. A LAN address over http, like thehttp://192.168.1.20:5173Vite prints for testing on a phone, fails withinsecure_context. Use https there (@vitejs/plugin-basic-ssl, or a tunnel likecloudflared tunnel --url http://localhost:5173). - No code yet: the /demo page runs the agent on a bare page.
Errors
start() throws, and get() rejects with, an AgentError whose code tells you what happened and whose message says what to change. From npm, check for it with instanceof; the script tag build has only start and forget on its global, so check error.code there.
import { AgentError } from 'zeno-id'
try {
const result = await agent.get({ externalId: user.id })
} catch (error) {
if (error instanceof AgentError && error.code === 'origin_not_allowed') {
// error.message names the origin to add in Settings.
}
}| Code | Meaning |
|---|---|
invalid_options | From start() or get(): the options are missing or malformed, like an endpoint that isn't an http(s) origin, a bad cache or timeout, an externalId that's empty or over 256 characters, an action over 1,024, or metadata that isn't an object. The message names the option. |
api_key_missing, api_key_invalid | No public key, or one the service doesn't know, or one that's disabled or expired. |
secret_key_in_browser | A secret key (sk_) was passed as the public key, caught by start() or answered by the API with a 400. Anyone who loaded the page can read it now, so create a new secret key on the API keys page, revoke this one, and pass the public key (pk_). |
origin_not_allowed | The page's origin isn't in the project's allowed origins, or the request has no page origin; the message names the origin to add. |
insecure_context | The page is served over plain http (localhost aside), where the browser's crypto the agent needs doesn't exist. |
collection_failed | An extension broke the browser's basics, like navigator or screen, so no signals could be read. |
client_timeout | The identification took longer than timeout. |
agent_stopped | stop() was called on this agent, during the get() or before it. Call start() again once you may identify. |
script_load_failed | From the npm loader: the endpoint couldn't serve the agent, because of the network, a content blocker on the agent's domain, a custom domain that isn't active yet, or an endpoint that isn't the API. The message names the url. A custom domain avoids the blocker. |
network_connection, network_abort | The request didn't reach the endpoint: offline, a blocked domain, or a dead endpoint. |
csp_block | The page's Content Security Policy blocks the agent's code (script-src, from the npm loader) or the identify request (connect-src). Add the endpoint's origin to both. |
usage_limit_reached | Only from older versions of the API; the current API answers { capped: true } instead. Handle it like capped. |
invalid_request | The API refused the request; message names the field. |
bad_response_format, server_error | Something went wrong on the way, or the endpoint isn't the API; retrying may help. |
Which code you get when
| What happened | What your code sees |
|---|---|
| An ad blocker blocks the agent's domain | From npm on the agent's own origin, get() rejects with script_load_failed, or network_connection when only the request is blocked. A custom domain avoids both. |
An ad blocker blocks the <script src=".../agent.js"> tag | The script never runs, so FingerprintAgent is undefined and FingerprintAgent.start throws a ReferenceError. Check typeof FingerprintAgent === 'undefined' first and carry on without a request ID. |
The page's CSP leaves the endpoint out of script-src | csp_block |
The page's CSP leaves the endpoint out of connect-src | csp_block |
| The page is on plain http, not localhost | insecure_context, at once |
| A slow or hanging network or endpoint | client_timeout after timeout |
| The origin isn't allowed | origin_not_allowed |
| A budget, a key's spend limit, the usage limit or the per-IP rate limit | No error: get() resolves { capped: true } with no request ID. See Capped, and No request ID for what your server does then. |
<script src="https://zenocdn.net/agent.js"></script>
<script>
// Blocked script: no agent, so no request ID; the server challenges.
const agent = typeof FingerprintAgent === 'undefined' ? undefined : FingerprintAgent.start({ publicKey: 'pk_...' })
</script>Every
get()that comes back with a freshrequestId(not one from the cache) made an event and is billed, the ones whose answer your code ignores too. Don't call it on every page view unless you mean to.
What it collects
- Rendering: canvas (marked as randomized when two identical drawings differ), WebGL vendor, renderer, extensions and parameters, the WebGPU adapter, and audio from a compressed oscillator.
- Device: screen size, color depth, pixel ratio, CPU cores, memory, touch support, platform, architecture, client hints, installed fonts, plugins, speech voices and a math fingerprint.
- Settings: time zone, languages, calendar, media preferences, storage support and quota, permission states, WebRTC support, Apple Pay and Private Click Measurement.
- Integrity: automation leftovers, ad blocker baits, browser functions replaced by script, and navigator values that differ between the page, a worker and an iframe.
- With the request: the page url and referrer without their query string and fragment, the user agent, and the IP address the API sees.
It gathers no network addresses through WebRTC.
Privacy and consent
start() reads the device as soon as it's called. Where you rely on your visitors' consent, start the agent only once they've given it, and identify on the actions you protect rather than on every page view: Privacy and consent shows both, with what to put in your privacy policy.