开始使用
Quickstart
Identify your first visitor with the hosted script, serve the agent from your custom domain for production, and verify the event on your server.
This guide takes you from a new project to a verified identification: the browser agent identifies the visitor, and your server reads the event with a secret key. It uses the words from Key terms (visitor, event, external ID, capped) in the sense defined there.
Your server calls the API at
https://api.zeno.id, and the browser agent loads from and identifies athttps://zenocdn.net, a domain of its own so a blocklist that lists it never touches your server's calls. Every sample in these docs already uses them, and the dashboard's API keys page shows them with your keys filled in.
Step 1: Get a project
Sign up with your email and a password, then open the link we email you: it verifies the address and logs you in. (Invited by a teammate? Their emailed invite makes your account instead.) Create project then asks for an organization name (the organization holds your projects and their bill) and your first project, and the project comes with two keys:
- a public key (
pk_...) for the browser. It's in your pages' source anyway, so it stays visible on the API keys page; - a secret key (
sk_...) for your backend only, shown once: copy it to your server's secrets right away.
Creating the project also asks for its allowed origins, the sites that may identify with the public key. You can leave them empty while you try things, since identification is then accepted from any site, but add your sites' origins before going live, your development origin included; Allowed origins has the rules.
During the beta each project has 20,000 free identifications in total, not per month: that's the usage limit. Ask us to raise it; after that you pay as you go, and the first 20,000 of every month stay free. See Keys and billing.
Try it without code
To see an event before writing anything, open https://zenocdn.net/demo?key=pk_...&externalId=test-1 with your public key in a browser. The page runs the agent and shows the answer, and the event appears on the dashboard's Events page. Try a private window, another browser, a VPN or a phone, each with its own externalId, to see how they show up. The page is served from the API's own origin, so while your allowed origins aren't empty, add https://api.zeno.id to them for the test, and remove it after.
Step 2: Add the agent to your page
There are two ways to load the agent. Try it with the hosted script, which takes a minute; before you go live, serve it from your own domain.
Try it: the hosted script
Load the agent from the API and start it once per page load, early: collecting the signals (canvas, audio and WebGL rendering among them) takes a moment, and start() does it in the background, so a later get() mostly waits for the network.
<script src="https://zenocdn.net/agent.js"></script>
<script>
const agent = FingerprintAgent.start({ endpoint: 'https://zenocdn.net', publicKey: 'pk_...' })
</script>That's enough to follow the rest of this guide. It isn't what we recommend for a live site.
Go to production: serve it from your own domain
Ad and tracker blocklists match third-party domains. Once the agent's domain is on a list, an integration that calls it directly loses identifications without any error you'd notice: for those visitors your server gets no request ID and challenges people it would have let through. Served from a subdomain of your own site, the agent and its requests aren't a third party, so a list that names our domain doesn't reach them. Your consent rules stay the same: your own domain changes where the requests go, not when the agent may run.
- Add a custom domain on the dashboard's Settings › Custom domain page: a subdomain of your site, like
metrics.example.com. - Create the one DNS record it shows at your DNS provider, and wait until the domain is active (we issue its certificate).
- Install the agent from npm and start it with your subdomain as its endpoint.
import { start } from 'zeno-id'
const agent = start({ publicKey: 'pk_...', endpoint: 'https://metrics.example.com' })The npm package is a small loader that fetches the current agent from your subdomain at runtime, so bundling it doesn't freeze an old version; Install shows how to add it. Custom domain has the details: naming the subdomain, the DNS record, cookies in Safari, CSP and what each status means. For single page apps, see where start() and get() go in React and others.
Step 3: Identify the visitor
Call get() once the user has logged in, and send the requestId to your server:
// After a successful login:
let requestId
try {
const result = await agent.get({ externalId: user.id })
// capped: a budget, a key's spend limit, the usage limit or the per-IP
// rate limit stopped it and nothing was identified. Not a bot in itself:
// send no requestId and let the server decide.
if (!result.capped) requestId = result.requestId
} catch (error) {
// An AgentError, like a timeout or a blocked request; error.code says which.
console.warn('Identification failed', error.code)
}
await fetch('/api/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId }),
})The browser only gets a requestId, a visitorId and returning. Never trust the visitorId from the browser: send the requestId to your server. A capped answer or a rejected get() (an ad blocker, the network, a setup mistake; the error codes say which) leaves you without one. Neither means a bot, but your server can't tell them from a bot that skipped the agent, so it challenges them: No request ID explains.
Step 4: Verify on your server
Read the event with your secret key in the Auth-API-Key header, check the answer is a success, then check the event belongs to this login: a few minutes old at most, with this user's externalId. Only then decide on it. Any language works: it's one HTTPS call.
// Read the key once; a missing one fails at startup, not on a login.
const FP_SECRET_KEY = process.env.FP_SECRET_KEY
if (!FP_SECRET_KEY) throw new Error('FP_SECRET_KEY is not set')
if (!requestId) {
// No identification: capped, failed, or a bot that skipped the agent.
// Your server can't tell which, so challenge instead of allowing.
return askForSecondFactor()
}
const response = await fetch(`https://api.zeno.id/events/${encodeURIComponent(requestId)}`, {
headers: { 'Auth-API-Key': FP_SECRET_KEY },
})
if (!response.ok) {
// 404 event_not_found (a made up or another project's id), 401, 429...
const { error } = await response.json()
throw new Error(`Reading the event failed: ${error}`)
}
const event = await response.json()
const fresh = Date.now() - new Date(event.createdAt).getTime() < 5 * 60_000
if (!fresh || event.externalId !== user.id) {
// An old identification, or another login's: treat it as none.
return askForSecondFactor()
}
if (event.bot === 'bad' || event.riskScore >= 15) {
// Refuse, or send it to review.
} else if (event.riskScore >= 9) {
return askForSecondFactor()
}import { createClient, verifyEvent } from '@zeno-id/server'
const fingerprint = createClient({ secretKey: process.env.FP_SECRET_KEY, endpoint: 'https://api.zeno.id' })
if (!requestId) {
return askForSecondFactor()
}
// Throws a FingerprintError (status, code) on any error answer.
const event = await fingerprint.getEvent(requestId)
// From this visitor's ip (its own, not your load balancer's) and at most 5 minutes old.
const check = verifyEvent(event, { ip: visitorIp, maxAge: 5 * 60 })
if (!check.ok || event.externalId !== user.id) {
// A request ID used from another ip or later, or another login's: treat it as none.
return askForSecondFactor()
}
if (event.bot === 'bad' || event.riskScore >= 15) {
// Refuse, or send it to review.
} else if (event.riskScore >= 9) {
return askForSecondFactor()
}import os
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
import requests
FP_SECRET_KEY = os.environ["FP_SECRET_KEY"]
if not request_id:
# No identification: capped, failed, or a bot that skipped the agent.
return ask_for_second_factor()
response = requests.get(
f"https://api.zeno.id/events/{quote(request_id, safe='')}",
headers={"Auth-API-Key": FP_SECRET_KEY},
timeout=5,
)
if not response.ok:
raise RuntimeError(f"Reading the event failed: {response.json().get('error')}")
event = response.json()
# Python before 3.11 can't parse the trailing Z itself.
created = datetime.fromisoformat(event["createdAt"].replace("Z", "+00:00"))
fresh = datetime.now(timezone.utc) - created < timedelta(minutes=5)
if not fresh or event.get("externalId") != user.id:
return ask_for_second_factor()
if event["bot"] == "bad" or event["riskScore"] >= 15:
pass # Refuse, or send it to review.
elif event["riskScore"] >= 9:
return ask_for_second_factor()// Laravel's HTTP client; with Guzzle alone it's the same request.
use Illuminate\Support\Facades\Http;
if (!$requestId) {
// No identification: capped, failed, or a bot that skipped the agent.
return $this->askForSecondFactor();
}
$response = Http::withHeaders(['Auth-API-Key' => config('services.fingerprint.secret_key')])
->timeout(5)
->get('https://api.zeno.id/events/' . rawurlencode($requestId));
if ($response->failed()) {
// The API's errors are JSON { error, message }; the CDN's 403 isn't.
throw new RuntimeException('Reading the event failed: ' . ($response->json('error') ?? $response->body()));
}
$event = $response->json();
$age = time() - (new DateTimeImmutable($event['createdAt']))->getTimestamp();
if ($age > 300 || ($event['externalId'] ?? null) !== (string) $user->id) {
return $this->askForSecondFactor();
}
if ($event['bot'] === 'bad' || $event['riskScore'] >= 15) {
// Refuse, or send it to review.
} elseif ($event['riskScore'] >= 9) {
return $this->askForSecondFactor();
}if requestID == "" {
// No identification: capped, failed, or a bot that skipped the agent.
return askForSecondFactor()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.zeno.id/events/"+url.PathEscape(requestID), nil)
if err != nil {
return err
}
req.Header.Set("Auth-API-Key", os.Getenv("FP_SECRET_KEY"))
res, err := httpClient.Do(req) // an http.Client with a Timeout
if err != nil {
return err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return err
}
if res.StatusCode != http.StatusOK {
// The API's errors are JSON { error, message }; the CDN's 403 isn't.
var apiErr struct{ Error, Message string }
if json.Unmarshal(body, &apiErr) != nil {
apiErr.Message = string(body)
}
return fmt.Errorf("reading the event failed: %d %s: %s", res.StatusCode, apiErr.Error, apiErr.Message)
}
var event struct {
ExternalID string `json:"externalId"`
CreatedAt time.Time `json:"createdAt"`
Bot string `json:"bot"`
RiskScore float64 `json:"riskScore"`
}
if err := json.Unmarshal(body, &event); err != nil {
return err
}
if time.Since(event.CreatedAt) > 5*time.Minute || event.ExternalID != user.ID {
return askForSecondFactor()
}
switch {
case event.Bot == "bad" || event.RiskScore >= 15:
// Refuse, or send it to review.
case event.RiskScore >= 9:
return askForSecondFactor()
}The SDK's verifyEvent also compares the event's ip with the request's, so a request ID caught on the way and replayed from somewhere else fails, and checks the age, so one used again later does. Pass the visitor's real IP: behind a load balancer or proxy that's the address it forwards, not the connection's. Without the SDK, compare event.ip yourself (for IPv6 the first four groups); Replays explains.
The decision uses two things: a bad bot verdict, and the risk score's starting bands, challenge from 9 and refuse or review from 15. They let ordinary VPN users through. Everything else, what each flag means, velocity and recipes per action, is in Smart signals. If you turn on the agent's cache, keep its ttl well under these 5 minutes, or a cached request ID can reach your server already too old.
Signup forms
At signup the browser can't pass externalId: the user id doesn't exist until your server creates the account. Identify on the form, send the requestId with the signup, and attach the new id on the server.
// On submit of the signup form:
let requestId
try {
const result = await agent.get({ action: 'signup' })
if (!result.capped) requestId = result.requestId
} catch (error) {
console.warn('Identification failed', error.code)
}
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, requestId }),
})On the server, read the event before creating the account, decide as above, then attach the new user id with PATCH /events/{requestId}. The check below uses velocity: how many external IDs this browser was seen with in the last 24 hours.
import { createHash } from 'node:crypto'
const FP_SECRET_KEY = process.env.FP_SECRET_KEY!
if (!requestId) {
// No identification: capped, failed, or a bot posting the form
// directly. Never a silent signup: challenge it (a captcha, or
// keep the account inactive until the email is verified).
return challengeSignup()
}
const response = await fetch(`https://api.zeno.id/events/${encodeURIComponent(requestId)}`, {
headers: { 'Auth-API-Key': FP_SECRET_KEY },
})
if (!response.ok) {
throw new Error(`Reading the event failed: ${(await response.json()).error}`)
}
const event = await response.json()
const fresh = Date.now() - new Date(event.createdAt).getTime() < 5 * 60_000
if (!fresh || event.action !== 'signup' || event.externalId) {
return challengeSignup()
}
// The external IDs this browser was seen with in the last 24 hours,
// counting this event: 3 means this signup and two others. GET
// /events/{id} always has velocity.
if (event.velocity['24h'].distinctExternalId >= 3) {
// A visitor making many accounts: review, or ask for more.
}
const user = await createUser(email, password)
// Serialise once, hash those bytes and send the same bytes.
const body = JSON.stringify({ externalId: user.id })
await fetch(`https://api.zeno.id/events/${encodeURIComponent(requestId)}`, {
method: 'PATCH',
headers: {
'Auth-API-Key': FP_SECRET_KEY,
'Content-Type': 'application/json',
'x-amz-content-sha256': createHash('sha256').update(body).digest('hex'),
},
body,
})import { createClient } from '@zeno-id/server'
const fingerprint = createClient({ secretKey: process.env.FP_SECRET_KEY, endpoint: 'https://api.zeno.id' })
if (!requestId) {
return challengeSignup()
}
const event = await fingerprint.getEvent(requestId)
const fresh = Date.now() - new Date(event.createdAt).getTime() < 5 * 60_000
if (!fresh || event.action !== 'signup' || event.externalId) {
return challengeSignup()
}
// getEvent always has velocity; the type has it optional for updateEvent's.
// getEvent always has velocity; updateEvent's answer doesn't.
if ((event.velocity?.['24h'].distinctExternalId ?? 0) >= 3) {
// A visitor making many accounts: review, or ask for more.
}
const user = await createUser(email, password)
// The SDK adds the body hash itself.
await fingerprint.updateEvent(requestId, { externalId: user.id })import hashlib
import json
import os
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
import requests
FP_SECRET_KEY = os.environ["FP_SECRET_KEY"]
if not request_id:
return challenge_signup()
url = f"https://api.zeno.id/events/{quote(request_id, safe='')}"
response = requests.get(url, headers={"Auth-API-Key": FP_SECRET_KEY}, timeout=5)
if not response.ok:
raise RuntimeError(f"Reading the event failed: {response.json().get('error')}")
event = response.json()
created = datetime.fromisoformat(event["createdAt"].replace("Z", "+00:00"))
fresh = datetime.now(timezone.utc) - created < timedelta(minutes=5)
if not fresh or event.get("action") != "signup" or event.get("externalId"):
return challenge_signup()
if event["velocity"]["24h"]["distinctExternalId"] >= 3:
pass # A visitor making many accounts: review, or ask for more.
user = create_user(email, password)
# Serialise once, hash those bytes and send the same bytes: data=, not json=.
body = json.dumps({"externalId": user.id}).encode()
requests.patch(
url,
data=body,
headers={
"Auth-API-Key": FP_SECRET_KEY,
"Content-Type": "application/json",
"x-amz-content-sha256": hashlib.sha256(body).hexdigest(),
},
timeout=5,
).raise_for_status()The x-amz-content-sha256 header is required on the hosted API for every request with a body: the hex SHA-256 of the exact bytes you send. Serialise the body once, hash those bytes and send the same bytes; a client that serialises JSON for you (json= in Python's requests or httpx, an array passed to Laravel's Http::patch or Guzzle's json option) may send different bytes than you hashed. Requests with a body has PHP, Go and shell samples and a test vector. The server SDK adds it for you.
Once attached, the event counts as if the browser had sent the id itself:
- Related link the user to the visitor id and IP, so
GET /fingerprints?externalId=saysduplicatewhen another account shares the browser, and its related external IDs show the ring. - Velocity counts the user from then on: the visitor's later events count it in
distinctExternalId, and the user's own indistinctVisitorIdByExternalIdanddistinctIpByExternalId. Your own test logins count too, so the 24 hour check may fire while you test. - Search and the visitor API find the event by the user id; the visitor lists every external ID it was seen with.
A webhook delivers the event twice: as it was identified, without the user id, and again after the PATCH, with the id and updatedAt set.
What's next
- Browser agent: options, caching, single page apps and error codes.
- Events: every field of an event.
- Smart signals: what each signal means, how the risk score adds up, and recipes per action.
- Webhooks: get every event pushed to your server instead.
- Server SDK: every call, typed, for Node.