Zeno dokumentacija
Gauti API raktus

Serveris

Server API

Authenticate with a secret key, send bodies with their hash, handle errors and the rate limit, from any language.

The server API runs on https://api.zeno.id, without a version prefix. Your backend calls it to read events, search them, look up visitors, link and check external IDs, and erase visitors or external IDs. It's JSON over HTTPS with one header, so any language works: the samples show JavaScript's fetch (Node, Bun, Deno, workers), Python's requests, PHP with Laravel's HTTP client (Guzzle underneath) and Go's standard library. In Node, the server SDK does the same with types.

Authentication

Every call takes a secret key in the Auth-API-Key header. Authorization: Bearer isn't accepted.

Shell
curl "https://api.zeno.id/events/1792578564800.yU4Ety" \
	-H "Auth-API-Key: sk_..."
const FP_SECRET_KEY = process.env.FP_SECRET_KEY!

const response = await fetch(`https://api.zeno.id/events/${encodeURIComponent(requestId)}`, {
	headers: { 'Auth-API-Key': FP_SECRET_KEY },
})

if (!response.ok) {
	const { error, message } = await response.json()
	throw new Error(`${response.status} ${error}: ${message}`)
}

const event = await response.json()

Keep the secret key on your server, in an environment variable or your secrets store: it reads every event of the project. If one ends up in browser code or a repository, make a new one on the API keys page and revoke the old one.

Requests with a body

Every request with a body (PATCH /events/{requestId}, POST /fingerprints) needs an x-amz-content-sha256 header on the hosted API: the SHA-256 of the exact bytes you send, as 64 lowercase hex digits. The CDN in front of the API only passes a body on with its hash. Without it, or with a hash of other bytes, the call is refused with a 403 from AWS that isn't the API's JSON error (no error field; it's about the request's signature). A local dev server doesn't check the hash, so don't take a working call there as proof: check your code against the test vector below. The server SDK adds it for you.

Serialise the body once, hash those bytes and send the same bytes. Any valid JSON encoding works, escapes included: what matters is that the hashed bytes are the bytes sent. Send Content-Type: application/json too.

import { createHash } from 'node:crypto'

const body = JSON.stringify({ externalId: user.id })

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,
})

Test vector

Body (the exact bytes)x-amz-content-sha256
{"externalId":"user-2077"}ef1124fbc3d09d06aa920d209a67c1257c5429d1c44132866a0d2a1d05dd97fb
{"externalId":"user\/2077"}, as PHP's json_encode writes user/207715ff61639a7dac48a7c08ea8a414b598506f6f6aa33fb8b92d2b8ecdebb7b9ac

The two bodies mean nearly the same to the API but are different bytes, so they hash differently; either is fine sent with its own hash.

Calls

CallSuccessReturns
GET /events/{requestId}200One event with its details and velocity. See Events.
PATCH /events/{requestId}200Attaches an externalId or action afterwards, like the new user's id after a signup; answers the updated event without velocity.
GET /events200Searches events, newest first. See Search events.
GET /visitors/{visitorId}200A visitor with every external ID seen with it. See Visitors and external IDs.
DELETE /visitors/{visitorId}200Erases a visitor and all its events.
DELETE /external-ids/{externalId}200Erases everything stored for one external ID. URL-encode the id as a path segment (a / as %2F, a space as %20: encodeURIComponent, rawurlencode, Go's url.PathEscape).
POST /fingerprints201Links an external ID to an IP seen elsewhere, and answers the link. See Related.
GET /fingerprints?externalId=200An external ID's links and whether it's a duplicate.
GET /fingerprints?type=&value=200The external IDs linked to one value.
GET /fingerprints/shared200The values more than one external ID shares.
GET /fingerprints/related200The external IDs related to one through shared values.

Both deletes are idempotent: a second call answers 200 with deletedEvents: 0, not a 404. Lookups that find nothing on the related IDs calls answer 200 with empty lists, not a 404; only an event or a visitor that doesn't exist is a 404.

Id formats

Check ids before calling, if you like; a malformed one answers 400:

IdFormatPatternExample
requestId13 digits, a dot, 6 letters or digits^\d{13}\.[A-Za-z0-9]{6}$1792578564800.yU4Ety
visitorId20 letters and digits^[A-Za-z0-9]{20}$q1yLOPpzvOstEQCFQQxE
externalIdAny text, 1 to 256 charactersuser-2077
keyIdA UUID7d0c5f0e-…

Times, like createdAt, are ISO 8601 in UTC with milliseconds and a Z: 2026-10-21T12:58:49.476Z. Read numbers as floating point: a score or a count can come back as 0 rather than 0.0.

Rate limit

A secret key may make 20 requests a second; so may its project, and so may your server's IP (an IPv6 /64 counting as one). Each is a token bucket holding 20 that refills evenly through the second, so a burst of about 20 passes at once and then roughly one every 50 milliseconds; don't size a worker pool to exactly 20. Past that the API answers 429 rate_limited with Retry-After: 1. A rate limited call was never carried out, so any call is safe to send again, PATCH and POST included. Wait the Retry-After seconds and try again:

// Waits out a 429 (Retry-After is 1 second) and tries again, three times.
const call = async (url: string, init: RequestInit = {}, tries = 3): Promise<Response> => {
	const response = await fetch(url, init)

	if (response.status === 429 && tries > 1) {
		const wait = Number(response.headers.get('retry-after') ?? 1)
		await new Promise(resolve => setTimeout(resolve, wait * 1000))
		return call(url, init, tries - 1)
	}

	return response
}

Errors

Errors come back as JSON with an error code and a message for people, like { "error": "invalid_request", "message": "limit must be at most 100" }. Switch on error; the message may change. A 400's message starts with the field it's about. The one answer that isn't the API's JSON is the CDN's 403, so read the body as text when it doesn't parse.

StatusErrorWhen
400invalid_requestA malformed id in the path, filter or body, a query parameter the call doesn't take (named, with the one you probably meant), or a cursor that isn't the previous page's; message says which and what it should look like.
401invalid_api_keyThe secret key is missing or sent in another header ("Send the project's secret key (sk_…) in the Auth-API-Key header"), or it's unknown, disabled or expired.
403noneHosted API only: a request with a body whose x-amz-content-sha256 is missing or doesn't match the bytes sent. The body is AWS's, not JSON.
404event_not_found, visitor_not_foundMissing, or another project's: the two look the same, so ids can't be probed across projects.
404not_foundThe path matches no call, often an external ID with an unencoded /.
405method_not_allowedThe call doesn't take this method; Allow lists the ones it does.
429rate_limitedMore than 20 requests a second for the key, its project or your server's IP. Wait the Retry-After seconds and try again.

Using the API from an AI assistant, or pasting these docs into one? See AI assistants.