Quản lý
Privacy and consent
What the agent reads and stores, starting it only after consent or where you need it, keeping and erasing less, and your privacy policy.
Identifying a browser reads information from the device, and the agent stores a visitor id in it. In the EU and the UK both fall under the ePrivacy rules as well as the GDPR, wherever the request ends up. This page shows what the agent reads and keeps, how to start it only after consent or only where you need it, and how to keep and erase as little as possible. It isn't legal advice: whether you need consent depends on what you use the identification for and on the law where your users are, and you're responsible for it, as our Europe Privacy Policy Addendum says.
What the agent reads and stores
start() reads the device as soon as it's called: rendering (canvas, WebGL, audio), device details (screen, cores, memory, fonts, client hints), settings (time zone, languages, permissions) and integrity checks, listed under What it collects. It also opens a WebSocket to the API for the socket proof. get() sends that report with the page's origin and path, the referrer, the user agent and your externalId, action and metadata; the API sees the IP address and the connection's TLS fingerprint (JA4, read from the handshake, not from the browser), and an IP lookup adds a location (country, region, city, postal code and coordinates of the network) and network details.
In the browser the agent writes:
| Name | Where | What and how long |
|---|---|---|
_fpvid_<last 8 characters of the key> | First-party cookie | The visitor id, a year. Not in zero trust mode. |
fp:visitor:<public key> | localStorage | The same visitor id, until cleared. Not in zero trust mode. |
fp:result:<public key>:<hash> | sessionStorage or localStorage | Only with the cache option: the last identification for an external id, action and metadata, for the ttl you set. The key holds a hash of them, not the ids. |
The Cookie Policy lists the same entries for your own cookie notice. Clearing them usually doesn't change the visitor id, since it's worked out from the device again; say so in your notice rather than promising otherwise.
Start the agent after consent
Where you rely on consent, don't call start() until the visitor has agreed: creating the agent is what reads the device. Load it lazily, so nothing runs before, and follow every change of mind: a visitor can withdraw consent as easily as they gave it, and from then on nothing may be read or sent.
On withdrawal, call the agent's stop() and drop it. It ends the agent for good: a get() in progress and every later one reject with agent_stopped, nothing more is collected or sent, its WebSocket closes, and the visitor id and cached results it stored for the public key (the entries in the table above) are removed. An agent that's still loading never starts. forget({ publicKey }) removes the same entries without starting anything, for a visitor who withdrew on an earlier visit. To identify again once consent is back, call start() again.
// src/fingerprint.ts: nothing is loaded, read or stored while the visitor
// hasn't agreed, and everything stops when they withdraw. The import waits
// too, so the agent's code isn't even downloaded before then.
import type { Agent } from 'zeno-id'
const publicKey = 'pk_...'
let agent: Promise<Agent | undefined> | undefined
// Call it with the visitor's current choice on every page load and on every
// change, withdrawal included.
export const setConsent = (granted: boolean) => {
if (granted) {
if (agent) return
const loading: Promise<Agent | undefined> = import('zeno-id').then(({ start }) =>
// Withdrawn (or withdrawn and given again) while the import loaded:
// this load starts nothing.
agent === loading ? start({ publicKey, endpoint: 'https://metrics.example.com' }) : undefined,
)
agent = loading
return
}
const running = agent
agent = undefined
// stop() ends a get() in progress, closes the agent's connection and
// removes the visitor id and cached results it stored.
void running?.then(current => current?.stop())
// forget() removes what an earlier visit stored, without starting anything.
void import('zeno-id').then(({ forget }) => forget({ publicKey }))
}
// The requestId for your server, or undefined without consent: the server
// handles that like any visitor it couldn't identify.
export const identify = async (options: { externalId?: string; action?: string }) => {
const current = await agent
if (!current) return undefined
// A withdrawal during the call rejects it with agent_stopped.
const result = await current.get(options).catch(() => undefined)
return result && !result.capped ? result.requestId : undefined
}import { setConsent } from './fingerprint'
// Hook it to your consent tool, whatever its API looks like: with the stored
// choice when the page loads and with every change after. Name the purpose
// in the banner: fraud prevention and security.
consentTool.onChange(choices => setConsent(Boolean(choices.fraudPrevention)))With the hosted script, add the script tag only when the visitor agrees, and stop the agent when they withdraw. The script also has FingerprintAgent.forget({ publicKey }), but it isn't loaded without consent, so the page removes an earlier visit's entries itself:
<script>
const FP_KEY = 'pk_...'
let fpScript
let fpAgent
// Called by your consent banner with the visitor's current choice, on every
// page load and on every change.
function setFingerprintConsent(granted) {
if (granted) {
if (fpScript) return
const script = (fpScript = document.createElement('script'))
script.src = 'https://zenocdn.net/agent.js'
script.onload = () => {
// Withdrawn while the script loaded: start nothing.
if (fpScript === script) {
fpAgent = FingerprintAgent.start({ endpoint: 'https://zenocdn.net', publicKey: FP_KEY })
}
}
document.head.append(script)
return
}
fpScript = undefined
// Ends a get() in progress and removes what the agent stored.
fpAgent?.stop()
fpAgent = undefined
// What an earlier visit stored, without loading the script.
localStorage.removeItem('fp:visitor:' + FP_KEY)
document.cookie = '_fpvid_' + FP_KEY.slice(-8) + '=; max-age=0; path=/'
for (const storage of [localStorage, sessionStorage]) {
for (const key of Object.keys(storage)) {
if (key.startsWith('fp:result:' + FP_KEY + ':')) storage.removeItem(key)
}
}
}
</script>A visitor who refuses gets no requestId. Your server already handles that case (a capped or blocked identification looks the same): ask for a second factor or review the signup, as the quickstart does, rather than refusing the visitor for saying no.
Only where you need it
Fraud checks matter on a few actions: signing up, logging in, paying, redeeming a promotion. Running the agent only on those pages, and only when the form is sent, reads far fewer devices than identifying every page view, and it's easier to explain in your privacy policy. Some data protection authorities accept identification without consent when it's strictly necessary to secure an action the user asked for, like logging in; check your authority's guidance before relying on it.
// On the login page only: the agent never runs on the rest of the site.
import { start } from 'zeno-id'
const agent = start({ publicKey: 'pk_...', endpoint: 'https://metrics.example.com' })
form.addEventListener('submit', async event => {
event.preventDefault()
const result = await agent.get({ action: 'login' }).catch(() => undefined)
const requestId = result && !result.capped ? result.requestId : undefined
await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, requestId }),
})
})On a custom domain the same rules apply: it changes where requests go, not whether you may identify a visitor who refused.
What you send us
- Use a pseudonymous internal id as
externalId(your user's row id, say), never an email address, name or phone number. It's stored with every event, in search, in webhooks and in related IDs. - Keep personal data out of
actionandmetadata: name what the visitor did, likeloginorcheckout, and use your own ids, like an order number, not names or email addresses. - Leave the
cacheoption off unless you need it, and keep its ttl short.
Keep less
| Setting | What it changes |
|---|---|
| Zero trust mode | The browser gets only a requestId, and the agent stores no visitor id in it (removing one it stored before) and no cached identification. The device is still read and matched on the server. |
| Page URLs | Keep the page and referrer as origin and path, the origin only, or nothing. Query strings and fragments are never sent. |
| Data retention | Indefinitely by default (kept until you erase them), or 30 days, 1, 3 or 5 years. A shorter retention applies to events identified from then on; older events keep the expiry they got, so erase those through the API if they must go sooner. |
Erasure
When one of your users asks to be forgotten, erase their external id; to remove a device, delete its visitor. Both take a secret key and are idempotent (see Visitors and external IDs):
curl -X DELETE "https://api.zeno.id/external-ids/user-2077" \
-H "Auth-API-Key: sk_..."
curl -X DELETE "https://api.zeno.id/visitors/q1yLOPpzvOstEQCFQQxE" \
-H "Auth-API-Key: sk_..."The events go from the API, search and the dashboard at once, with the external id's links to visitors and IPs. Some copies stay for a while:
| Where | What | How long |
|---|---|---|
| Webhook deliveries | The bodies sent to your webhook, with your server's answers | 30 days from the delivery |
| IP lookup cache | An IP address with its location and network, shared by every project | 90 days from the lookup |
| Failed jobs and error messages | The data a failed background job was handling | Until we remove them |
| Service logs | Request details | 7 days |
| Backups | Point-in-time copies of the databases | 35 days |
| Audit log | Which secret key erased how many events, never the id it erased | 2 years |
| Usage records | Each identification's request id, time, public key, signals and risk score, for usage, billing and the score comparison; never the visitor id, external id, IP or device | 32 days at most from the identification |
| Usage totals | Identifications, capped requests and how often each signal fired, per hour and public key, without personal data | Kept |
Your privacy policy
Tell your users what you identify and why. A starting point, to adapt to your own use and have checked:
To protect your account and our service from fraud, bots and abuse, we identify the browser and device you use when you [sign up, log in and pay]. For this our processor, [the service's legal name], reads technical characteristics of your browser and device (such as its graphics, audio, fonts, screen, language and time zone settings), and receives your IP address, from which it derives an approximate location of your network, the page address without its query string, and our internal id for your account. It stores an identifier in a cookie (
_fpvid_…) and in your browser's local storage (fp:visitor:…). Clearing them usually doesn't change the identifier, since it is computed from the device. We keep this data for [retention] and use it only for fraud prevention and security, on the basis of [our legitimate interest in securing our service / your consent]. You can ask us to erase it at [contact].
AI assistants and other tools
The MCP server hands an AI assistant your project's events, visitors and related IDs, with their IP addresses, locations and external ids. That data then goes to the assistant's model provider, a recipient your privacy policy and contracts have to cover. Secret keys have no read-only scope, so give the assistant a key of its own that you can revoke, and keep its erasing tools behind their confirmation. The same goes for tunnels you use to test webhooks: send test events through them, not real ones.