Palvelin
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.
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()import { createClient } from '@zeno-id/server'
const fingerprint = createClient({ secretKey: process.env.FP_SECRET_KEY, endpoint: 'https://api.zeno.id' })
const event = await fingerprint.getEvent(requestId)import os
from urllib.parse import quote
import requests
session = requests.Session()
session.headers["Auth-API-Key"] = os.environ["FP_SECRET_KEY"]
response = session.get(f"https://api.zeno.id/events/{quote(request_id, safe='')}", timeout=5)
if not response.ok:
body = response.json()
raise RuntimeError(f"{response.status_code} {body.get('error')}: {body.get('message')}")
event = response.json()use Illuminate\Support\Facades\Http;
$response = Http::withHeaders(['Auth-API-Key' => config('services.fingerprint.secret_key')])
->acceptJson()
->timeout(5)
->get('https://api.zeno.id/events/' . rawurlencode($requestId));
if ($response->failed()) {
// { error, message }, except the CDN's 403, which isn't JSON.
throw new RuntimeException(sprintf('%d %s: %s', $response->status(),
$response->json('error') ?? '', $response->json('message') ?? $response->body()));
}
$event = $response->json();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 {
// { error, message }, except the CDN's 403, which isn't JSON.
var apiErr struct{ Error, Message string }
if json.Unmarshal(body, &apiErr) != nil {
apiErr.Message = string(body)
}
return fmt.Errorf("%d %s: %s", res.StatusCode, apiErr.Error, apiErr.Message)
}
var event map[string]any // or a struct with the fields you use
err = json.Unmarshal(body, &event)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 anx-amz-content-sha256header 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 a403from AWS that isn't the API's JSON error (noerrorfield; 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,
})import hashlib
import json
body = json.dumps({"externalId": user.id}).encode()
# data= sends these exact bytes; json= would serialise again.
response = session.patch(
f"https://api.zeno.id/events/{quote(request_id, safe='')}",
data=body,
headers={
"Content-Type": "application/json",
"x-amz-content-sha256": hashlib.sha256(body).hexdigest(),
},
timeout=5,
)// Encode once and send those bytes. An array passed to patch() would be
// encoded again by Laravel; json_encode's \/ and \uXXXX escapes are fine,
// as long as the hashed bytes are the bytes sent.
$body = json_encode(['externalId' => (string) $user->id], 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));body, err := json.Marshal(map[string]string{"externalId": user.ID})
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)BODY='{"externalId":"user-2077"}'
# --data-binary sends the bytes as they are; -d would strip newlines.
curl -X PATCH "https://api.zeno.id/events/1792578564800.yU4Ety" \
-H "Auth-API-Key: $FP_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "x-amz-content-sha256: $(printf '%s' "$BODY" | openssl dgst -sha256 -r | cut -d' ' -f1)" \
--data-binary "$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/2077 | 15ff61639a7dac48a7c08ea8a414b598506f6f6aa33fb8b92d2b8ecdebb7b9ac |
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
| Call | Success | Returns |
|---|---|---|
GET /events/{requestId} | 200 | One event with its details and velocity. See Events. |
PATCH /events/{requestId} | 200 | Attaches an externalId or action afterwards, like the new user's id after a signup; answers the updated event without velocity. |
GET /events | 200 | Searches events, newest first. See Search events. |
GET /visitors/{visitorId} | 200 | A visitor with every external ID seen with it. See Visitors and external IDs. |
DELETE /visitors/{visitorId} | 200 | Erases a visitor and all its events. |
DELETE /external-ids/{externalId} | 200 | Erases 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 /fingerprints | 201 | Links an external ID to an IP seen elsewhere, and answers the link. See Related. |
GET /fingerprints?externalId= | 200 | An external ID's links and whether it's a duplicate. |
GET /fingerprints?type=&value= | 200 | The external IDs linked to one value. |
GET /fingerprints/shared | 200 | The values more than one external ID shares. |
GET /fingerprints/related | 200 | The 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:
| Id | Format | Pattern | Example |
|---|---|---|---|
requestId | 13 digits, a dot, 6 letters or digits | ^\d{13}\.[A-Za-z0-9]{6}$ | 1792578564800.yU4Ety |
visitorId | 20 letters and digits | ^[A-Za-z0-9]{20}$ | q1yLOPpzvOstEQCFQQxE |
externalId | Any text, 1 to 256 characters | user-2077 | |
keyId | A UUID | 7d0c5f0e-… |
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
}import time
def call(method, url, tries=3, **kwargs):
"""Waits out a 429 (Retry-After is 1 second) and tries again."""
response = session.request(method, url, timeout=5, **kwargs)
if response.status_code == 429 and tries > 1:
time.sleep(float(response.headers.get("Retry-After", 1)))
return call(method, url, tries - 1, **kwargs)
return responseuse Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
// Laravel: send a 429 again after its Retry-After (1 second), three tries.
$http = Http::withHeaders(['Auth-API-Key' => config('services.fingerprint.secret_key')])
->retry(3, fn (int $attempt, Throwable $e) => $e instanceof RequestException
? 1000 * (int) ($e->response->header('Retry-After') ?: 1)
: 1000,
when: fn (Throwable $e) => $e instanceof RequestException && $e->response->status() === 429,
throw: false);// Sends a request again after a 429's Retry-After (1 second), three tries in
// all. newRequest builds it afresh each time, body included.
func call(client *http.Client, newRequest func() (*http.Request, error)) (*http.Response, error) {
for try := 1; ; try++ {
req, err := newRequest()
if err != nil {
return nil, err
}
res, err := client.Do(req)
if err != nil || res.StatusCode != http.StatusTooManyRequests || try == 3 {
return res, err
}
res.Body.Close()
wait, _ := strconv.Atoi(res.Header.Get("Retry-After"))
time.Sleep(time.Duration(max(wait, 1)) * time.Second)
}
}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.
| Status | Error | When |
|---|---|---|
400 | invalid_request | A 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. |
401 | invalid_api_key | The 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. |
403 | none | Hosted 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. |
404 | event_not_found, visitor_not_found | Missing, or another project's: the two look the same, so ids can't be probed across projects. |
404 | not_found | The path matches no call, often an external ID with an unencoded /. |
405 | method_not_allowed | The call doesn't take this method; Allow lists the ones it does. |
429 | rate_limited | More 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.