Zeno 문서
API 키 받기

서버

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.

  • createdAt and externalId: is this the identification of the login you're looking at? Refuse an old one or another user's.
  • bot: bad means a script drives the browser (automation, headless), a replayed request or a fake crawler; see Bot verdict.
  • riskScore with its detected signals: how suspicious it looks, and why. Smart signals has the bands and what each signal means.
  • visitorId and confidence: 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, tor or private-relay among the detected signals, country, ipInfo.city and the coordinates are the exit's, not the person's, however high locationAccuracy is. Even without them, the location is the network's, not the person's. vpn.browserTimeZone is the time zone the browser is set to, the better hint of where they are.

Get an event

Shell
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):

JSON
{
	"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

FieldDescription
requestIdThe event's id.
visitorIdThe visitor: one browser on one device, within your project.
riskScore0 to 100: the sum of the detected signals' points, with the project's weights at the time of the event, capped at 100.
botbad, good or not_detected; see Bot verdict.
detectedThe smart signals that fired, like vpn.
confidenceHow sure the visitor ID is, from 0.1 to 1; see Confidence.
visitorMatch.methodHow the visitor ID came about, one of the rows of the Confidence table.
visitorMatch.matcherVersionThe version of the matching, which goes up when we change how browsers are matched, so you can tell events from before and after apart.
returningWhether the project had seen this visitor before this event, as the agent's get() said. Not in search results.
externalId, actionWhat the browser passed to get(), or what you attached later. Absent when neither did.
metadataYour own key-values the browser passed to get(), as they were sent. Absent when it passed none; not in search results.
apiKeyIdThe 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.
createdAtWhen it was identified, ISO 8601 in UTC.
updatedAtWhen an external ID or action was last attached afterwards, with PATCH below; absent on an event never updated.
expiresAtWhen 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".
originThe page's origin, from the request's Origin header.
url, referrerThe 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.
userAgentThe browser's user agent string.
browserDetailsThe 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.
incognitotrue for a private window (with incognito detected) and false for a normal one.
ip, countryThe connection's IP and its two letter country. Behind a VPN or proxy, the exit's.
ipTimeZoneThe time zone of the connection's location, from our CDN or the IP lookup.
ipInfoWhat 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, .continentWhere the lookup places the network.
ipInfo.latitude, .longitudeThe same as numbers.
ipInfo.locationAccuracyHow precisely the lookup places the network: very_high, high, medium, low or very_low.
ipInfo.companyThe company that owns the address.
ipInfo.companyTypeWhat the company is: isp, hosting, business, education, government or banking.
ipInfo.companyAbuseScoreA 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.datacenterNameThe hosting provider, when the address is in a datacenter.
ipInfo.asnThe autonomous system (the network on the internet) the IP belongs to, like AS39351.
ipInfo.networkName, .networkTypeThe network operator's name and kind: isp, hosting, business, education or government.
ipInfo.networkRangeThe address range the IP is in, like 185.65.134.0/24.
ipInfo.vpnProvider, .vpnExitLastSeenAtThe VPN service whose exit this is, and when the IP was last seen as one (an ISO time).
ipInfo.exitServiceType, .exitServiceProviderA managed service's exit and who runs it, like private_relay for iCloud Private Relay.
ipInfo.mobileNetwork, .satelliteNetworkBooleans: a mobile carrier's or a satellite provider's address.
ipInfo.knownCrawlerA boolean: one of a known crawler's addresses, like Googlebot's.
ipInfo.reservedAddressA boolean: a private or reserved address that nobody on the internet has.
ja4The connection's TLS fingerprint, like t13d1516h2_8daaf6152771_02713d6af862, read by our CDN from the handshake (see TLS fingerprint); on every event.
ja3The 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.
socketProofWhat 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.pointsEach 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.addedForBadBotWhat a bad bot was topped up by to reach 15.
riskScoreBreakdown.removedByCapWhat the cap at 100 left out. riskScore is the points' sum plus addedForBadBot minus removedByCap.
signalsEvery 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.*.reasonip-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).
detectorVersionThe 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.
automationMarkersWith automation: the traces a driver left in the page, like webdriver, cdc (ChromeDriver), __playwright__binding__ or _selenium.
gpuRendererWith software-renderer, or a virtual machine's graphics adapter: the WebGL renderer the browser reported, like SwiftShader or VMware SVGA 3D.
vpn.confidenceOn 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.browserTimeZoneThe time zone the browser is set to, the better hint of where the person is.
vpn.methodsBooleans for what found it: knownVpnExit, privateRelay (iCloud Private Relay) and timeZoneMismatch.
proxyOn 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.contradictionScore0 to 1: how much the browser data contradicts itself. Above 0.5 the event detects tampering.
tampering.antiDetectBrowserA 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.contradictionsWhat 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.reasonWith 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.clientWith non-browser-client: the client the handshake belongs to, like Go crypto/tls or curl (macOS).
tlsMismatch.claimedBrowserThe browser and major version the user agent claims, like Chrome 156.
virtualMachine.scoreA 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.indicatorsWhat 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.
velocityOnly 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:

HowStarts at
stored-id-and-fingerprint: the id the browser stored, on the same hardware, with a fingerprint seen for it before0.99
stored-id-same-hardware: the stored id, kept because the hardware still matches, after a browser update shifted other signals0.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 enough0.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 reuse0.4
new-visitor: a new visitor, with a new random id0.9
stored-id-rejected: the browser had a stored id that's unknown or from other hardware, so the id came from the fingerprint instead0.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()
RuleWhat happens
BodyJSON 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").
LimitsexternalId 1 to 256 characters (an empty one is refused, it names nobody), action up to 1,024.
An event that already has oneThe 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 hashx-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.
Answer200 with the updated event, updatedAt set, without velocity.
WebhookEvery 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.