zeno.id
v0.3.2
Published
Zeno's SDK for device identification: the browser SDK, and the proxy route and API client for your server
Readme
zeno.id
Zeno tells your server whether a login, signup or checkout came from a real browser, and whether to let it through. This package has both sides. In the browser, zeno.id is a small loader: start() imports the script itself at runtime through a route on your own site (the proxy route), so collection fixes reach every site without a release, and identify() answers a request ID for your server to verify. On your server, zeno.id/server is that proxy route, which adds your API key and passes the script's signals on to Zeno, a typed client for the Zeno API with verify, and the helpers for webhooks and sealed results.
Install
npm install zeno.idOne package for the browser and the server. zeno.id/server holds your API key, so import it on your server only. It uses only standard web APIs, so it runs on Node 20+, Bun, Deno, Cloudflare Workers, Vercel and Netlify edge, with no compatibility flags; zeno.id/server/node adds nodeHandler for Express, Fastify and Node's http server. A browser bundle of zeno.id holds the loader only, none of the server code. Every entry ships ES modules, CommonJS and their types. Not JavaScript? The Zeno API is plain HTTP: the docs' server API reference has every call.
How it fits together
Make a project and an API key in the dashboard, and put the key in your server's environment:
ZENO_API_KEY=sk_...Make the proxy route once, with the key, and mount it on your site at a path of your choice (
/zenohere).In the browser, on a page with an action you want to identify (a login, signup or checkout),
start()the browser SDK (zeno.id) with the same path when the page loads (it gathers signals; nothing is sent or billed), callidentify()when the user takes the action, the identity check, and send itsrequestIdto your server with the action's request.On your server, make an API client with the same key,
verifythe request ID and allow, challenge or block.
// zeno.ts
import { createProxy } from 'zeno.id/server'
// Your proxy route: the browser SDK talks to Zeno through it
export const zeno = createProxy(process.env.ZENO_API_KEY)import { createClient } from 'zeno.id/server'
// Your API client, made once with the project's API key
const client = createClient(process.env.ZENO_API_KEY)
// After the password matched:
const {
action, // 'allow', 'challenge' or 'block'
reasons, // why, like ['vpn', 'automation']
event, // the full event (visitor ID, IP, signals, velocity…), when found
} = await client.verify(requestId)
if (action === 'block') {
// Refuse, or hold it for review
} else if (action === 'challenge') {
// Ask for a second factor
}The package reads no option or secret from the environment: createProxy(apiKey, options?) and createClient(apiKey, options?) take the key from process.env or wherever your secrets live (env on Cloudflare Workers). It may be undefined where the route or client is made: neither fails at import or at build time, where the environment may be missing (Next.js collecting page data), and a call, or a request to the route, without it fails saying how to set it. Both know where the Zeno API is, so there's no address to set.
In the browser
Call start() on a page with an action you want to identify (a login, signup or checkout), when that page loads, not on every page, with the path of your route on your site. It loads Zeno's script and starts gathering signals in the browser; nothing is sent to Zeno and nothing is billed. Call identify() when the user takes the action: that's the identity check, it sends the report and gives you the request ID. Starting early only makes identify() quick, and one start() serves every identify() on that page. Call it in a module, not in a component (a component remounts, and each start() collects again). A module's top level is fine in any framework: while a page renders on the server, start() does nothing and its identify() answers the not_in_browser error.
import { start } from 'zeno.id'
// Start as early as you can: it loads Zeno and starts reading the browser,
// so identify() is quick later (your proxy route's path)
const zeno = start('/zeno')
// Recognise the browser the moment the user logs in
// (never throws: requestId is undefined when nothing was identified)
const { requestId } = await zeno.identify({ action: 'login' })
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
password,
requestId,
}),
})
if (response.ok) {
location.assign('/account')
} else {
// A wrong password, or a block or challenge from your server
showError(await response.text())
}Your server then calls client.verify(requestId) from zeno.id/server, which answers allow, challenge or block: that's the answer to trust, never anything the browser says. A login request without a request ID gets challenge with no-request-id, so your server decides whether to let the action through or ask for more; a request ID verifies once. A project in zero trust mode answers only requestId (no visitorId or returning), and one with sealed results also returns sealedResult for your server to open with unsealResult.
start() imports the script as an ES module from that path, and the script identifies with a POST to the same path. Nothing else is tried: on a failure identify() resolves at once with its error (script_load_failed, csp_block, network_connection…).
The script also sends the page url and referrer as origin and path only, without the query string or #fragment; set pageUrls in the project's settings to origin to keep only the site, or none to keep neither.
Content Security Policy
script-src 'self'for the script's code from the route, andconnect-src 'self'for the identification andforget().script-src blob:andworker-src blob:for some of the script's checks (noeval). Without them those checks go unreported and the identification still works.
Content-Security-Policy: script-src 'self' blob:; connect-src 'self'; worker-src 'self' blob:The script's code uses no eval or new Function, so 'unsafe-eval' isn't needed.
Options
start(path) takes your proxy route's path on this site, /zeno when you leave it out. It starts with /; a full url, a query string or a #fragment is refused with invalid_options, since the route answers the page's own origin only.
| identify() option | |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| externalId | Your id for the user behind the browser, to look visitors up by and to delete later. 1 to 256 characters (invalid_options otherwise). |
| action | What the visitor is doing, like login, signup or checkout, up to 1,024 characters. Returned with the event. |
| 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 a string up to 256 characters, a number or a boolean, 2 KB in all. Returned with the event, never used for detection. |
The script keeps no visitor id itself. The route keeps it in the zeno_id cookie (HttpOnly, SameSite=Lax, Secure on https, a year), on the route's own path, so two routes on one site, like a site's own login and a demo, keep separate ids. Zeno reuses it while it's still the same device, so the id survives browser updates. The script keeps nothing in the browser's storage, and every identify() identifies again.
Stop and forget
zeno.stop() ends identification for good, for a visitor who withdraws consent: an identify() in progress and every later one answer the stopped error, nothing more is collected or sent, and a POST to the route's ?forget clears its cookie. A script still loading never starts: its module may finish downloading, but it isn't started. forget(path) does the same without loading or starting anything, and resolves once the route cleared the cookie (it rejects with network_connection or server_error when it couldn't, and not_in_browser on the server). Call start() again to identify once consent is back.
import { forget, start, type Zeno } from 'zeno.id'
let zeno: Zeno | undefined
consentTool.onChange(async ({ fraudPrevention }) => {
if (fraudPrevention) {
zeno ??= start('/zeno')
} else if (zeno) {
zeno.stop()
zeno = undefined
} else {
await forget('/zeno')
}
})Errors
identify() never rejects. When nothing was identified it resolves { requestId: undefined, error }, with error a ZenoError whose code is one of these (ZenoErrorCode), and whose message says more where there's more to say. For all of them the action can go on without a request ID: your server's verify answers challenge with no-request-id, so it decides. start() itself throws a ZenoError for options it can't use (invalid_options).
// Never throws: requestId is undefined when nothing was identified
const { requestId, error } = await zeno.identify({ action: 'login' })
if (error) {
console.warn('Identification failed', error.code, error.message)
}| Code | What happened | What to do |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| client_timeout | Zeno didn't answer in time. | Go on without a request ID. |
| network_connection | The route couldn't be reached, or the request was cut off. | Go on without a request ID. |
| csp_block | The page's Content Security Policy blocked the script's code (script-src) or the identification (connect-src). | Allow the route's origin in both (see Content Security Policy). |
| insecure_context | The page isn't a secure context (https or localhost), which the script needs for its random ids and hashes. | Serve the page over https. |
| collection_failed | The browser data couldn't be collected at all, as when an extension broke the browser's basics. | Go on without a request ID. |
| invalid_options | An option start() or identify() can't use (a route path that isn't on this site, a bad external id or metadata), or one Zeno refused; the message names it. | Fix the option. |
| script_load_failed | The script couldn't be loaded from the route: nothing answers there (your site's own 404 page), something other than the script does, or the network failed. The message names the url and the status. | Check the route answers at its path; meanwhile go on without a request ID. |
| server_error | The route or Zeno failed, or answered something that isn't the route's JSON. The message says what: a route that wasn't given the visitor's IP says to pass it, a refused API key says to check the route's. | Read the message and the route's server log; go on without a request ID. |
| account_closed | Zeno closed the organization's account (as for unpaid invoices): identification is refused until it's reopened. | Contact support. |
| stopped | stop() was called, before or during the identify(). | Call start() again once consent is back. |
| capped | A limit stopped the identification: a spend limit you set, the key's ceiling or the visitor ip's rate limit. Not a bot. | Go on without a request ID. |
| not_in_browser | identify() ran on the server, while a page rendered. | Call it in the action's handler in the browser. |
What it collects
- The device and browser: technical characteristics of the hardware and software, like the screen, the browser's settings, its languages and time zone.
- Integrity checks: whether a script drives the browser and whether what it reports fits together.
- With the request: the page url and referrer without their query string and fragment. Your route adds the request's headers (the user agent among them, never other cookies or authorization headers) and the visitor's ip.
It reads no personal content, like names, messages or what people type, and nothing from other sites. The report is about a kilobyte.
Without a bundler
A page without a build step can import start from the route itself, which serves the script as an ES module:
<button id="login">Log in</button>
<script type="module">
import { start } from '/zeno' // your route's path
const zeno = start()
document.querySelector('#login').addEventListener('click', async () => {
const { requestId } = await zeno.identify({ action: 'login' })
await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, requestId }),
})
})
</script>That start() takes the path it was imported from unless you pass another, and answers the same identify() and stop() with the same error codes. What it can't do is report a script that doesn't load: the import itself fails, without script_load_failed or csp_block, and there's no forget(). Use zeno.id where you can.
Proxy route
The browser SDK never talks to Zeno directly. It loads its script from a route on your own site and sends its identifications there, and the route passes them on with your API key and the visitor's IP.
- Ad and tracker blockers block requests to identification services. A path on your own site looks like any other request your pages make.
- The route keeps the visitor id in a first-party
HttpOnlycookie (zeno_id) that it sets itself, which Safari keeps for a year where it would delete a script's storage after 7 days. - Your API key stays on your server, and the route refuses a browser's cross-site request, so there's no allow list of origins to keep.
createProxy(apiKey, options?) returns the route's handler: zeno(request, ip) takes a standard Request and the IP address it came from, and returns a Response, using web APIs only. The browser SDK calls it with two methods on one path: GET loads the script (gzipped when the browser takes it; HEAD too), and POST identifies, or, as forget(), clears the visitor id for withdrawn consent.
// ip: the IP address the request came from, as your server gives it
export const handle = (request: Request, ip: string) => {
if (new URL(request.url).pathname === '/zeno') {
// the path you chose
return zeno(request, ip)
}
// …the rest of your app
}// Express, Fastify or Node's http server
import { nodeHandler } from 'zeno.id/server/node'
// behind a proxy: app.set('trust proxy', 1)
app.all('/zeno', nodeHandler(zeno)) // the path you chose// app/zeno/route.ts
import { zeno } from '@/lib/zeno'
// X-Real-IP: set by Vercel, or by your own proxy (nginx: proxy_set_header X-Real-IP $remote_addr;)
const handler = (request: Request) => {
return zeno(request, request.headers.get('x-real-ip'))
}
// Next.js calls the export named after the method
export const GET = handler
export const POST = handler// Bun.serve
Bun.serve({
routes: {
'/zeno': (request, server) => zeno(request, server.requestIP(request)?.address),
},
})// A Cloudflare Worker
import { env } from 'cloudflare:workers'
import { createProxy } from 'zeno.id/server'
// Your proxy route: the browser SDK talks to Zeno through it
const zeno = createProxy(env.ZENO_API_KEY)
export default {
fetch: (request: Request) =>
new URL(request.url).pathname === '/zeno'
? zeno(request, request.headers.get('cf-connecting-ip'))
: fetch(request),
}The docs' quickstart has SvelteKit, Nuxt, React Router, SolidStart, Astro and Hono too.
| Option | |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| cookiePath | The path the browser calls the route on, when something in front of the handler rewrites it (the browser calls /zeno, a handler at /api/proxy answers); by default the request's. |
The route forwards your visitors' headers by name only, apart from the values Zeno reads (User-Agent, Origin, Signature-Agent, Signature-Input), so your cookies and credentials never leave your server.
The visitor's IP
Pass the IP address the request came from: your framework's client IP. Behind a proxy of your own, turn on your framework's trust-proxy setting so it's the visitor's. The route uses the IP as given and reads no header for it; without one, an identification answers 500 server_error saying to pass it, which the browser SDK passes on. nodeHandler (from zeno.id/server/node, which alone uses Node's types) passes Express's and Fastify's req.ip, else the socket's address.
The route checks a POST before it calls Zeno: a JSON body of at most 128 KB with a browserData object, and no Sec-Fetch-Site: cross-site. Anything else gets a 400 invalid_request and costs nothing. Zeno's errors reach the browser with their status and code; a 401 (the route's API key is wrong) is logged with Zeno's message and the browser only gets the code. When Zeno can't be reached within 10 seconds, or something else answers in its place, the route logs it and answers 502.
Verify
client.verify(requestId, { ip?, externalId?, maxAge?, idempotencyKey? }?) sends the request ID to POST /verify, which checks it and answers { action, reasons, event? }. Each request ID verifies once. The event comes with its velocity, as getEvent has it, from before verify attached your externalId: event.velocity['24h'].distinctExternalId counts the users seen on this browser today, and sameAction its events with this event's action, for a site without external IDs. Identical devices can look like one browser, so treat a busy one as a lead to review.
| Answer | When |
| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| { action: 'challenge', reasons: ['no-request-id'] } | requestId is undefined, null or empty: the browser sent none (its identification failed, was blocked or capped, or a bot never ran the script). |
| { action: 'block', reasons: ['not-found'] } | No such event: a forged request ID, or another project's. |
| { action: 'block', reasons: ['already-used'], event } | The request ID was verified before: an action's request sent again, or a captured request ID. |
| { action: 'block', reasons: ['ip-mismatch'], event } | The event came from another ip (for IPv6 another /64; IPv4 and IPv6 are never compared): a request ID captured and sent elsewhere. |
| { action: 'block', reasons: ['too-old'], event } | The event is older than maxAge seconds, 300 (5 minutes) by default. |
| { action: 'block', reasons: ['external-id-mismatch'], event } | The event has another external ID than the one you passed: another user's identification. One without an external ID is fine. |
| the event's verdict, with the event | Otherwise: allow, challenge or block, and the signals behind it. |
ip-mismatch, too-old and external-id-mismatch come together when several fail, and leave the request ID unused, so a request ID sent from elsewhere doesn't use up the visitor's own. The verdict comes from the event's risk score and bot verdict: block for a bad bot or a risk score of 15 or more, challenge from 9 to 14, else allow; its reasons are the detected signals that scored, strongest first, and bad-bot.
Call it after the action succeeded (the password matched, the account exists), with the user's ID as externalId: Zeno records it on an event that has none, so a signup needs no other call and a failed login attaches nothing. It writes nothing else but the request ID's use.
// At a signup, once the account exists:
const { action } = await client.verify(requestId, {
externalId: account.id,
})ip is optional: pass the same client IP your proxy route got, not your load balancer's, and a request ID sent from another ip blocks with ip-mismatch; leave it out (or pass null or undefined) and there's no ip check, as there's none between an IPv4 and an IPv6 address. no-request-id is a challenge so you decide: ask for a second factor, or let it through and flag the account; never treat it as verified. API errors throw a ZenoError.
A lost answer is safe to retry. Each call sends an idempotencyKey, and after a network error or a 5xx the SDK sends it again, twice at most, with the same key; a repeat with the same key within 5 minutes answers what the first call did instead of already-used. If your own code may send the same verify again (a retried job, a second try of your handler), pass your own idempotencyKey, 8 to 128 characters, like your request's ID.
Test keys
For automated tests and staging, use a test key in place of your API key, in createProxy and createClient. Each answers verify the same every time, with a sample event; nothing is stored or billed, a test request ID can be verified again and again, and other calls refuse a test key. Without a request ID each answers challenge with no-request-id, like a real key.
| Test key | verify answers |
| ------------------- | ----------------------- |
| sk_test_allow | allow |
| sk_test_challenge | challenge, with vpn |
| sk_test_block | block, with bad-bot |
Server API
import { createClient } from 'zeno.id/server'
const client = createClient(process.env.ZENO_API_KEY)
const event = await client.getEvent(requestId)
const { items, cursor } = await client.searchEvents({
externalId: user.id,
signal: 'vpn',
})
// Several values match any of them
const risky = await client.searchEvents({
country: ['US', 'DE'],
signal: ['vpn', 'tor'],
})
await client.deleteExternalId(user.id)
// Related accounts: add what only you know
await client.addRelation({
externalId: user.id,
type: 'phone',
value: order.phone,
action: 'checkout',
})
await client.addRelation({
externalId: user.id,
type: 'referral',
relatedExternalId: referrer.id,
at: user.createdAt,
})
const { duplicate, identified } = await client.getRelatedValues(user.id)
const { externalIds } = await client.getRelated(user.id, { depth: 2 })
// For your admin panel's user page, one per view
const { url } = await client.createEmbed({
externalId: user.id,
origin: 'https://admin.example.com',
})| Method | Call |
| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| verify(requestId, { ip?, externalId?, maxAge?, idempotencyKey? }?) | POST /verify |
| getEvent(requestId) | GET /events/{requestId} |
| searchEvents(filters) | GET /events?... |
| getVisitor(visitorId) | GET /visitors/{visitorId} |
| deleteVisitor(visitorId) | DELETE /visitors/{visitorId} |
| deleteExternalId(externalId) | DELETE /external-ids?externalId=... |
| addRelation({ externalId, type, value or relatedExternalId, action?, at? }) | POST /related |
| removeRelation({ externalId, type, value or relatedExternalId }) | DELETE /related?externalId=...&type=..., { removed }, free |
| getRelated(externalId, { type?, depth? }) | GET /related?externalId=..., every type unless type names one |
| getRelatedValues(externalId, { type? }) | GET /related/values?externalId=... |
| lookupRelated(type, value, { limit?, cursor? }) | GET /related/lookup?type=...&value=..., 20 a page by default (up to 100) |
| getSharedValues(type, { limit?, cursor? }) | GET /related/shared?type=... |
| createEmbed({ externalId, origin, ttl? }) | POST /embeds |
A list filter (apiKeyId, country, origin, asn, browser, os, action, signal, bot) takes an array and matches any of its values. Visitor ids and ips relate your users by themselves for events with an external id; addRelation adds what only you know: an ip seen elsewhere, a value of a type of your own, or a relation between two users. Make a createEmbed link for each view of your admin panel's page.
A failed call throws a ZenoError with the status and the API's error code (ZenoErrorCode), like not_found. A call answered 429 rate_limited or 503 search_unavailable is sent again after the API's Retry-After, twice, a little later each time at random so calls limited together don't all come back at once; the error of one that stays limited has retryAfter in seconds. Neither was carried out, so retrying a write or a delete is safe.
The SDK's own codes: network_error (status 0) when the API can't be reached or doesn't answer within 10 seconds, retried only for verify (above); invalid_request with status 0 for an empty id (getEvent('')), refused before anything is sent; request_failed for an error answer without a code, or a success answer that isn't what the call returns (each answer is checked before it's handed back, so something between your server and Zeno that answers otherwise fails here rather than later in your code).
addRelation is the one billed call: each is billed like an identification and counts toward the free monthly identifications and the spend limits (reads, createEmbed and what events relate by themselves are free). Once a spend limit you set for the project, its organization or the key is reached it throws a 402 spend_limit_reached and adds nothing. Every call answers 402 account_closed while Zeno has closed the organization's account (for unpaid invoices); contact support to reopen it.
addRelation's at takes a Date, epoch milliseconds or an ISO string. A type's first call fixes whether it holds values or relations. The project's settings hold each type's weight (visitor-id 0.8, ip 0.4, a type of your own 0.5 by default), applied when relations are read, so a change re-scores every relation. The reads answer each value's weight and each related user's (what the two share combined as 1 - Π(1 - weight), so two shared ips make 0.64 and a browser and an ip 0.88, multiplied along the path); duplicate means they share one value weighing 0.5 or more with another user. createEmbed answers { url, expiresAt }: put the url in an iframe on origin, the only page allowed to frame it, before expiresAt (ttl, 5 to 60 minutes, 15 by default).
Webhooks
import { verifyWebhook } from 'zeno.id/server'
// POST /webhooks/zeno: any runtime with web Request and Response
export const handle = async (request: Request) => {
// The raw body: the signature covers the exact bytes
const body = await request.text()
const event = await verifyWebhook(body, request.headers.get('zeno-signature'), process.env.ZENO_WEBHOOK_SECRET)
if (!event) {
return new Response(null, { status: 400 })
}
return new Response(null, { status: 204 })
}verifyWebhook resolves to the event when the signature matched, a WebhookEvent (the event without its velocity), else to undefined. In Express, read the raw body with express.text({ type: 'application/json' }). Pass the raw body: the signature covers it byte for byte. Deliveries older than 5 minutes (tolerance, in seconds) are refused, so a captured one can't be replayed. The header is t=<unix seconds>,v1=<hmac sha256 hex>; spaces after the commas and several v1= signatures are accepted: for 24 hours after a secret rotation every delivery carries one signed with the new secret and one with the previous. Each delivery also has Zeno-Delivery-Id (the same on every retry, to drop one you handled) and Zeno-Trigger (event, update, redeliver or test; a test sends the project's latest event or a made up one, so don't act on it).
Sealed results
import { unsealResult } from 'zeno.id/server'
const { action, reasons } = await unsealResult(sealedResult, {
key: process.env.ZENO_ENCRYPTION_KEY,
ip, // the same IP your proxy route got
externalId: user.id,
})A project with sealed results gives the browser an encrypted copy of the event, for a server that can't wait for an API call. A sealed result can't be forged: only Zeno can make one that decrypts with your key. But it comes from the browser, so the same one can be sent again within a few minutes. unsealResult(sealed, { key, ip?, externalId?, maxAge? }) answers like verify, { action, reasons, event? }: challenge with no-request-id for none, block with not-found for one that doesn't open (changed, or sealed with another key), ip-mismatch from another IP than ip (compared as verify does), too-old past maxAge (300 seconds by default), external-id-mismatch for another user's, else the event's verdict. If a repeat matters for the action, use verify, which allows each request ID once. While you replace the key, pass both, key: [current, previous]: each is tried in order. It rejects with a TypeError for a key that isn't one (missing, hex, the wrong length), and an Error for one that opens but isn't an event.
License
MIT, see LICENSE.
