Server
Events
Read one event with all its details, and attach an external ID to it afterwards.
Every identification is stored as an event. Its id is the requestId the browser got from get().
Reading an event
Most decisions need five fields; the rest explain them.
createdAtandexternalId: is this the identification of the login you're looking at? Refuse an old one or another user's.bot:badmeans a script drives the browser (automation,headless), a replayed request or a fake crawler; see Bot verdict.riskScorewith itsdetectedsignals: how suspicious it looks, and why. Smart signals has the bands and what each signal means.visitorIdandconfidence: which browser it was, and how sure that is. Below 0.8, don't link accounts on the visitor id alone.velocity: how busy this visitor, IP and external ID were in the last 5 minutes, hour and day; see Velocity.
Smart signals, the next page, explains the signals, the score, the bot verdict and velocity, with recipes per action.
With
vpn,proxy,tororprivate-relayamong the detected signals,country,ipInfo.cityand the coordinates are the exit's, not the person's, however highlocationAccuracyis. Even without them, the location is the network's, not the person's.vpn.browserTimeZoneis the time zone the browser is set to, the better hint of where they are.
Get an event
curl "https://api.zeno.id/events/1792578564800.yU4Ety" \
-H "Auth-API-Key: sk_..."A whole event; fields an event doesn't have are left out (this one's IP is no proxy, so it has no proxy):
{
"requestId": "1792578564800.yU4Ety",
"visitorId": "q1yLOPpzvOstEQCFQQxE",
"riskScore": 6,
"bot": "not_detected",
"detected": ["vpn", "abusive-ip"],
"confidence": 0.99,
"visitorMatch": { "method": "stored-id-and-fingerprint", "matcherVersion": 2 },
"returning": true,
"externalId": "user-2077",
"action": "login",
"metadata": { "plan": "pro", "seats": 5 },
"apiKeyId": "7d0c5f0e-3b6a-4c1e-9a52-1f4e8b2d6c90",
"createdAt": "2026-10-21T12:58:49.476Z",
"updatedAt": "2026-10-21T12:58:51.102Z",
"expiresAt": "2027-10-21T12:58:49.476Z",
"origin": "https://example.com",
"url": "https://example.com/login",
"referrer": "https://example.com/",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36",
"browserDetails": {
"browserName": "Chrome",
"browserMajorVersion": "156",
"browserFullVersion": "156.0.7562.92",
"os": "Windows",
"osVersion": "11",
"device": "desktop"
},
"incognito": false,
"ip": "185.65.134.10",
"country": "NL",
"ipTimeZone": "Europe/Amsterdam",
"ipInfo": {
"city": "Amsterdam",
"region": "North Holland",
"countryName": "Netherlands",
"continent": "EU",
"latitude": 52.37,
"longitude": 4.89,
"locationAccuracy": "very_high",
"company": "31173 Services AB",
"companyType": "hosting",
"companyAbuseScore": 0.18,
"datacenterName": "31173.se",
"asn": "AS39351",
"networkName": "31173 Services AB",
"networkType": "business",
"networkRange": "185.65.134.0/24",
"vpnProvider": "MullvadVPN",
"vpnExitLastSeenAt": "2026-10-20T00:00:00.000Z",
"mobileNetwork": false,
"satelliteNetwork": false,
"knownCrawler": false,
"reservedAddress": false
},
"ja4": "t13d1516h2_8daaf6152771_02713d6af862",
"ja3": "cd08e31494f9531f560d64c695473da9",
"socketProof": "verified",
"riskScoreBreakdown": {
"points": [
{ "signal": "abusive-ip", "family": "network", "weight": 5, "points": 5 },
{ "signal": "vpn", "family": "network", "weight": 2, "points": 1 }
],
"addedForBadBot": 0,
"removedByCap": 0
},
"signals": {
"automation": { "state": "not_detected" },
"headless": { "state": "not_detected" },
"virtual-machine": { "state": "not_detected" },
"software-renderer": { "state": "not_detected" },
"tor": { "state": "not_detected" },
"proxy": { "state": "not_detected" },
"datacenter": { "state": "not_detected" },
"abusive-ip": { "state": "detected" },
"residential-proxy": { "state": "not_detected" },
"vpn": { "state": "detected" },
"private-relay": { "state": "not_detected" },
"timezone-mismatch": { "state": "not_detected" },
"tampering": { "state": "not_detected" },
"developer-tools": { "state": "not_detected" },
"incognito": { "state": "not_detected" },
"privacy-settings": { "state": "not_detected" },
"privacy-browser": { "state": "not_detected" },
"rare-device": { "state": "not_detected" },
"replayed": { "state": "not_detected" },
"ai-agent": { "state": "not_detected" },
"socket-proof-failed": { "state": "not_detected" },
"tls-mismatch": { "state": "not_detected" }
},
"detectorVersion": 2,
"vpn": {
"confidence": "high",
"browserTimeZone": "Europe/Amsterdam",
"methods": { "knownVpnExit": true, "privateRelay": false, "timeZoneMismatch": false }
},
"tampering": { "contradictionScore": 0, "antiDetectBrowser": false, "contradictions": [] },
"virtualMachine": { "score": 0, "indicators": [] },
"velocity": {
"5m": { "events": 1, "distinctIp": 1, "distinctExternalId": 1, "distinctCountry": 1, "ipEvents": 1, "distinctIpByExternalId": 1, "distinctVisitorIdByExternalId": 1 },
"1h": { "events": 2, "distinctIp": 1, "distinctExternalId": 1, "distinctCountry": 1, "ipEvents": 3, "distinctIpByExternalId": 1, "distinctVisitorIdByExternalId": 1 },
"24h": { "events": 4, "distinctIp": 2, "distinctExternalId": 1, "distinctCountry": 2, "ipEvents": 9, "distinctIpByExternalId": 2, "distinctVisitorIdByExternalId": 1 }
}
}Fields
| Field | Description |
|---|---|
requestId | The event's id. |
visitorId | The visitor: one browser on one device, within your project. |
riskScore | 0 to 100: the sum of the detected signals' points, with the project's weights at the time of the event, capped at 100. |
bot | bad, good or not_detected; see Bot verdict. |
detected | The smart signals that fired, like vpn. |
confidence | How sure the visitor ID is, from 0.1 to 1; see Confidence. |
visitorMatch.method | How the visitor ID came about, one of the rows of the Confidence table. |
visitorMatch.matcherVersion | The version of the matching, which goes up when we change how browsers are matched, so you can tell events from before and after apart. |
returning | Whether the project had seen this visitor before this event, as the agent's get() said. Not in search results. |
externalId, action | What the browser passed to get(), or what you attached later. Absent when neither did. |
metadata | Your own key-values the browser passed to get(), as they were sent. Absent when it passed none; not in search results. |
apiKeyId | The id of the public key that identified, not the key itself: the one in the key's page address on the API keys page, /app/keys/<apiKeyId>. Search filters on it with apiKeyId. |
createdAt | When it was identified, ISO 8601 in UTC. |
updatedAt | When an external ID or action was last attached afterwards, with PATCH below; absent on an event never updated. |
expiresAt | When the project's data retention removes the event. Absent when the project keeps events until they're deleted. The event page shows it as Kept: "Until" the date, or "Until deleted". |
origin | The page's origin, from the request's Origin header. |
url, referrer | The page and where it came from, as origin and path without the query string or fragment, as the origin only, or left out, as the project's page URLs setting says. |
userAgent | The browser's user agent string. |
browserDetails | The browser parsed on the server: browserName, browserMajorVersion, browserFullVersion, os, osVersion and device (desktop, mobile or tablet). The full versions come from client hints, where Chromium's user agent says 156.0.0.0 and macOS 10.15.7. |
incognito | true for a private window (with incognito detected) and false for a normal one. |
ip, country | The connection's IP and its two letter country. Behind a VPN or proxy, the exit's. |
ipTimeZone | The time zone of the connection's location, from our CDN or the IP lookup. |
ipInfo | What the IP lookup knows of the IP; absent when it didn't answer in time. The fields below; text unless said otherwise. |
ipInfo.city, .region, .postalCode, .countryName, .continent | Where the lookup places the network. |
ipInfo.latitude, .longitude | The same as numbers. |
ipInfo.locationAccuracy | How precisely the lookup places the network: very_high, high, medium, low or very_low. |
ipInfo.company | The company that owns the address. |
ipInfo.companyType | What the company is: isp, hosting, business, education, government or banking. |
ipInfo.companyAbuseScore | A number from 0 to 1: how much of the company's address space abuse reports name. It doesn't set abusive-ip, which is about this IP itself. |
ipInfo.datacenterName | The hosting provider, when the address is in a datacenter. |
ipInfo.asn | The autonomous system (the network on the internet) the IP belongs to, like AS39351. |
ipInfo.networkName, .networkType | The network operator's name and kind: isp, hosting, business, education or government. |
ipInfo.networkRange | The address range the IP is in, like 185.65.134.0/24. |
ipInfo.vpnProvider, .vpnExitLastSeenAt | The VPN service whose exit this is, and when the IP was last seen as one (an ISO time). |
ipInfo.exitServiceType, .exitServiceProvider | A managed service's exit and who runs it, like private_relay for iCloud Private Relay. |
ipInfo.mobileNetwork, .satelliteNetwork | Booleans: a mobile carrier's or a satellite provider's address. |
ipInfo.knownCrawler | A boolean: one of a known crawler's addresses, like Googlebot's. |
ipInfo.reservedAddress | A boolean: a private or reserved address that nobody on the internet has. |
ja4 | The connection's TLS fingerprint, like t13d1516h2_8daaf6152771_02713d6af862, read by our CDN from the handshake (see TLS fingerprint); on every event. |
ja3 | The same handshake's older JA3 fingerprint as a lowercase hex md5, for tools that still use it; no signal reads it. Left out when our CDN didn't send one. |
socketProof | What the API knows of the agent's WebSocket handshake: verified, wrong-challenge (detects socket-proof-failed), connection-failed or challenge-not-received; absent with the signal off. |
riskScoreBreakdown.points | Each detected signal, strongest first: its signal, family, weight and the points it added (its weight, or one point after a stronger signal of its family, none past the family's cap). |
riskScoreBreakdown.addedForBadBot | What a bad bot was topped up by to reach 15. |
riskScoreBreakdown.removedByCap | What the cap at 100 left out. riskScore is the points' sum plus addedForBadBot minus removedByCap. |
signals | Every signal, keyed by its name: { "state": "detected" } (the signal is in detected), { "state": "not_detected" }, or unknown with a reason when what its detector reads was missing, so its absence from detected says nothing. An unknown signal adds no points: treat a low score with unknown network signals as unchecked, not clean. Not in search results. |
signals.*.reason | ip-lookup-failed or ip-lookup-timeout (the IP signals, and timezone-mismatch without our CDN's time zone), no-time-zone (the browser gave none), no-ip-time-zone (nor the connection), probe-timeout, probe-failed or not-collected (a browser probe), no-webgl, no-canvas, and for socket-proof-failed disabled (the signal is off, its default; see socket proof); too-few-visitors (rare-device before the project has 10,000 visitors) and no-nonce (replayed). |
detectorVersion | The version of the detectors behind signals and detected, which goes up when we change a detector's rule, so you can tell events from before and after apart. |
automationMarkers | With automation: the traces a driver left in the page, like webdriver, cdc (ChromeDriver), __playwright__binding__ or _selenium. |
gpuRenderer | With software-renderer, or a virtual machine's graphics adapter: the WebGL renderer the browser reported, like SwiftShader or VMware SVGA 3D. |
vpn.confidence | On events with vpn, private-relay or timezone-mismatch: high for a known VPN exit or Private Relay, medium for a time zone that doesn't fit on a hosting network, else low. |
vpn.browserTimeZone | The time zone the browser is set to, the better hint of where the person is. |
vpn.methods | Booleans for what found it: knownVpnExit, privateRelay (iCloud Private Relay) and timeZoneMismatch. |
proxy | On events with proxy or residential-proxy, like { "type": "residential", "confidence": "medium" }: type public (on the open proxy lists) or residential (a home or office ISP's IP whose time zone doesn't fit the browser's; no IP database knows residential proxies) and confidence. |
tampering.contradictionScore | 0 to 1: how much the browser data contradicts itself. Above 0.5 the event detects tampering. |
tampering.antiDetectBrowser | A boolean: the operating system the user agent claims doesn't fit the platform, client hints or graphics card, as when a device is pieced together from parts of others. |
tampering.contradictions | What contradicts: platform-not-user-agent-os, client-hints-not-user-agent-os, gpu-not-user-agent-os, browser-functions-overridden, chromium-without-client-hints, usable-area-larger-than-screen, no-languages, clock-offset-not-time-zone or standard-signals-missing (signals every browser reports are missing). |
tlsMismatch.reason | With tls-mismatch, why the handshake doesn't fit the browser: non-browser-client, old-tls-version, no-http2 or rare-for-claimed-browser (see TLS fingerprint). |
tlsMismatch.client | With non-browser-client: the client the handshake belongs to, like Go crypto/tls or curl (macOS). |
tlsMismatch.claimedBrowser | The browser and major version the user agent claims, like Chrome 156. |
virtualMachine.score | A number from 0 to 1 (0 comes back as an integer): how much the device looks like a virtual machine. From 0.6 the event gets virtual-machine. |
virtualMachine.indicators | What added to it: vm-display-adapter (settles it), software-renderer, two-cpu-cores-or-fewer, two-gb-memory-or-less, vm-screen-size or no-speech-voices. |
velocity | Only from GET /events/{requestId}. Keyed by exactly 5m, 1h and 24h, each with whole numbers: the visitor's events, distinctIp, distinctExternalId and distinctCountry; the IP's ipEvents across all visitors; and the external ID's distinctIpByExternalId and distinctVisitorIdByExternalId. What they count and when to look is in Smart signals › Velocity. |
Confidence
confidence starts from how the visitor id came about, then drops for signals that make an id less certain:
| How | Starts at |
|---|---|
stored-id-and-fingerprint: the id the browser stored, on the same hardware, with a fingerprint seen for it before | 0.99 |
stored-id-same-hardware: the stored id, kept because the hardware still matches, after a browser update shifted other signals | 0.97 |
fingerprint-match: no stored id, but the exact fingerprint was seen before (identical devices, like two phones of one model, share one) | 0.95 |
similar-fingerprint: no stored id and no exact match, but a known visitor on the same hardware is alike enough | 0.9 × similarity × the share of the known visitor's signals the browser reported |
ambiguous-fingerprint: no stored id and no exact match, and the browser is about as alike to several known visitors on the same hardware, so it could be any of them. It gets a new random id, which the next identification doesn't reuse | 0.4 |
new-visitor: a new visitor, with a new random id | 0.9 |
stored-id-rejected: the browser had a stored id that's unknown or from other hardware, so the id came from the fingerprint instead | 0.7 |
A fuzzy match compares the browser's components (fonts, voices, emoji, keyboard layout, screen, languages, canvas and more, weighted by how much each tells devices of one model apart) with the latest visitors on the same hardware. It needs a similarity of 0.85, or 0.7 on the network the visitor was last seen on, and a clear lead over the next candidate; without that lead it's ambiguous-fingerprint, and with no candidate alike enough a new visitor, so merging two people is the rarer mistake. A browser that leaves some signals out is compared on the ones it reports, so it keeps its visitor, with a lower confidence. Canvas and audio are left out for browsers that add noise to them (Brave, private windows in Safari and Firefox).
Then tampering takes off 0.2, privacy-settings or privacy-browser 0.1, and each of canvas, WebGL, audio and fonts the browser withheld 0.05, down to at least 0.1. Below 0.8, treat the visitor match as uncertain: fine for a velocity count, not for tying two accounts together.
Before using an event for a login, check it's a few minutes old at most and has this user's externalId: Verify on your server has it in code, in five languages.
Update an event
Attach an external ID or action after the fact, like when the login succeeded after the identification:
import { createHash } from 'node:crypto'
const body = JSON.stringify({ externalId: 'user-2077', action: 'checkout' })
const response = await fetch(`https://api.zeno.id/events/${encodeURIComponent(requestId)}`, {
method: 'PATCH',
headers: {
'Auth-API-Key': process.env.FP_SECRET_KEY!,
'Content-Type': 'application/json',
'x-amz-content-sha256': createHash('sha256').update(body).digest('hex'),
},
body,
})
if (!response.ok) {
throw new Error((await response.json()).error)
}
const updated = await response.json()const updated = await fingerprint.updateEvent(requestId, { externalId: 'user-2077', action: 'checkout' })import hashlib
import json
import os
from urllib.parse import quote
import requests
body = json.dumps({"externalId": "user-2077", "action": "checkout"}).encode()
response = requests.patch(
f"https://api.zeno.id/events/{quote(request_id, safe='')}",
data=body,
headers={
"Auth-API-Key": os.environ["FP_SECRET_KEY"],
"Content-Type": "application/json",
"x-amz-content-sha256": hashlib.sha256(body).hexdigest(),
},
timeout=5,
)
response.raise_for_status()
updated = response.json()// Encode once and send those bytes, with their hash.
$body = json_encode(['externalId' => 'user-2077', 'action' => 'checkout'], JSON_THROW_ON_ERROR);
$response = Http::withHeaders([
'Auth-API-Key' => config('services.fingerprint.secret_key'),
'x-amz-content-sha256' => hash('sha256', $body),
])
->withBody($body, 'application/json')
->timeout(5)
->patch('https://api.zeno.id/events/' . rawurlencode($requestId));
$updated = $response->throw()->json();body, err := json.Marshal(map[string]string{"externalId": "user-2077", "action": "checkout"})
if err != nil {
return err
}
sum := sha256.Sum256(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch,
"https://api.zeno.id/events/"+url.PathEscape(requestID), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Auth-API-Key", os.Getenv("FP_SECRET_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-amz-content-sha256", hex.EncodeToString(sum[:]))
res, err := httpClient.Do(req)| Rule | What happens |
|---|---|
| Body | JSON with externalId, action or both; other fields are ignored, and a body with neither answers 400 "Send an externalId or an action", and null for either is refused ("action must be text"). |
| Limits | externalId 1 to 256 characters (an empty one is refused, it names nobody), action up to 1,024. |
| An event that already has one | The new value replaces it. The old external ID stays in the visitor's externalIds and keeps its links until they expire; to remove a user entirely, forget the external ID. |
| Body hash | x-amz-content-sha256, the hex SHA-256 of the exact body, is required on the hosted API (see Server API, with a shell sample and a test vector); the SDK adds it. Any JSON encoding works, PHP's \/ and \uXXXX escapes included, as long as the hashed bytes are the bytes sent. |
| Answer | 200 with the updated event, updatedAt set, without velocity. |
| Webhook | Every successful PATCH, even one that changes nothing, sends the event again with the trigger update; see Webhooks. |
This is how a signup form ties its event to the new account: the browser identifies before the user id exists, and your server attaches it once the account is made. The quickstart shows the whole flow. From then on the event counts for related IDs, velocity and search as if the browser had sent the id.