Serveur
Server SDK
A typed Node client that handles the body hash, retries, webhooks and sealed results, and what to do without it.
@zeno-id/server is a typed Node client for the server API. It saves you the parts that are easy to get wrong: the x-amz-content-sha256 body hash, the error handling, repeated search filters, and the webhook and sealed result checks. It runs in Node 18 or later and Bun. From another language, see Without the SDK: the API is one HTTPS call with a header, and the pages show Python, PHP and Go next to fetch.
Install
bun add @zeno-id/serverimport { 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)
const page = await fingerprint.searchEvents({ externalId: user.id, signal: ['vpn', 'tor'] })
await fingerprint.updateEvent(requestId, { externalId: user.id })
const { duplicate, identified } = await fingerprint.getFingerprints(user.id)
const related = await fingerprint.getRelated(user.id, { depth: 2 })
await fingerprint.forgetExternalId(user.id)endpoint is the server API's origin, https://api.zeno.id. createClient takes process.env.FP_SECRET_KEY as it is, without a !, and throws at once when secretKey or endpoint is missing, like an unset environment variable, rather than failing with a 401 later. verifyWebhook and unsealResult do the same for their secret. Pass fetch to use your own, as in tests, and retries for rate limits.
Functions
| Function | Call | Returns |
|---|---|---|
getEvent(requestId) | GET /events/{requestId} | The event with velocity (Event) |
updateEvent(requestId, { externalId?, action? }) | PATCH /events/{requestId} | The updated event, without velocity |
searchEvents(filters) | GET /events | { events, cursor?, found }; a list filter takes an array. See Search events |
getVisitor(visitorId) | GET /visitors/{visitorId} | The visitor (Visitor) |
deleteVisitor(visitorId) | DELETE /visitors/{visitorId} | { visitorId, deletedEvents } |
forgetExternalId(externalId) | DELETE /external-ids/{externalId} | { externalId, deletedEvents } |
linkFingerprint({ externalId, type: 'ip', value }) | POST /fingerprints | The link: an IP seen elsewhere. See Related |
getFingerprints(externalId, type?) | GET /fingerprints?externalId= | { externalId, duplicate, identified, fingerprints } |
findExternalIds(type, value, { limit?, cursor? }) | GET /fingerprints?type=&value= | { items, cursor? } |
getSharedFingerprints(type, { limit?, cursor? }) | GET /fingerprints/shared | { items, cursor? }, most shared first |
getRelated(externalId, { type?, depth? }) | GET /fingerprints/related | { externalId, type, connections, accounts } |
Three helpers work without a client:
| Function | Does |
|---|---|
verifyEvent(event, { ip, maxAge?, now? }) | Checks an event belongs to the request you're handling: from ip (for IPv6 the same /64) and at most maxAge seconds old (60). Returns { ok: true } or { ok: false, reason } with ip-mismatch or too-old. Takes getEvent results and unsealed results. See Replays. |
verifyWebhook(body, header, secret, { tolerance? }) | Checks a webhook's Fingerprint-Signature against the raw body and that it was signed in the last tolerance seconds (300); takes several v1 signatures, as during a secret rotation. Returns a boolean. |
unsealResult(sealed, key) | Opens a sealed result and returns the Event. Throws a plain Error when the result was changed, sealed with another key, or isn't a sealed result. |
The package also exports the types: Event, VerifyEventResult, TlsMismatchReason, SearchEvent, SearchFilters, SearchResult, Visitor, Fingerprints, Related, Flag, Bot, SocketProof and the rest.
Replays
A request ID is only a reference: anyone who catches one, from a page's network traffic or an earlier request, can send it to your server again. Check the event belongs to this request before deciding on it: the same IP as the request (a request ID reused from another IP fails), seconds old rather than minutes (one used later fails), and your user's externalId.
import { verifyEvent } from '@zeno-id/server'
const event = await fingerprint.getEvent(requestId)
// The visitor's own ip: behind a load balancer or proxy, the one it forwards.
const check = verifyEvent(event, { ip: visitorIp, maxAge: 60 })
if (!check.ok || event.externalId !== user.id) {
// 'ip-mismatch' or 'too-old': a request ID captured elsewhere, or used again later.
return askForSecondFactor()
}Compare the visitor's real IP, not your load balancer's: behind a load balancer, CDN or reverse proxy, the connection's address is theirs, so read the visitor's from the header it adds (its own entry in X-Forwarded-For, or CF-Connecting-IP and the like), as your framework's trusted proxy setting does. IPv6 compares the /64, as a device changes its address within its network; an IPv4 address mapped into IPv6 (Node's ::ffff:203.0.113.7) counts as the IPv4 one. If you turn on the agent's cache, a cached request ID is as old as its identification: raise maxAge to the cache's ttl, or leave the cache off.
Errors and retries
A call the API refuses throws a FingerprintError with the HTTP status, the API's error code (like event_not_found or rate_limited, see Errors) and its message.
import { FingerprintError } from '@zeno-id/server'
try {
const event = await fingerprint.getEvent(requestId)
} catch (error) {
if (error instanceof FingerprintError && error.code === 'event_not_found') {
// A made up request ID, or another project's.
} else {
throw error
}
}A 429 rate_limited means more than 20 requests a second. The SDK waits the Retry-After the API sends (a second) and sends the call again, twice by default (retries in createClient); a rate limited call was never carried out, so every call is safe to repeat. Past that it throws, with retryAfter on the error.
// A 429 is sent again twice by default, after the Retry-After the API
// gives; retries: 0 throws the first one.
const fingerprint = createClient({ secretKey: process.env.FP_SECRET_KEY, endpoint: 'https://api.zeno.id', retries: 4 })
try {
await fingerprint.searchEvents({ signal: 'tor' })
} catch (error) {
if (error instanceof FingerprintError && error.code === 'rate_limited') {
// Still limited after the retries: wait error.retryAfter seconds.
}
}Paging
let cursor: string | undefined
do {
const page = await fingerprint.searchEvents({ externalId: user.id, cursor })
handle(page.events)
cursor = page.cursor
} while (cursor)Webhooks
Hand verifyWebhook the raw body, before any JSON parser touches it. In Express:
import express from 'express'
import { verifyWebhook, type Event } from '@zeno-id/server'
// The raw body as a string: the signature covers the exact bytes.
app.post('/webhooks/fingerprint', express.text({ type: 'application/json' }), (request, response) => {
const valid = verifyWebhook(request.body, request.get('fingerprint-signature'), process.env.FP_WEBHOOK_SECRET)
if (!valid) {
return response.sendStatus(400)
}
const event = JSON.parse(request.body) as Event
response.sendStatus(204)
})Webhooks shows the raw body in other frameworks.
Without the SDK
From another language, or with plain fetch, call the API directly. What the SDK does that you'll do yourself, with where each rule is written:
- Send the secret key in
Auth-API-Key, notAuthorization(Authentication). - Encode ids in the path as a path segment:
encodeURIComponent, PHP'srawurlencode, Go'surl.PathEscape(Calls). - Hash a request body into
x-amz-content-sha256, lowercase hex, and send the same bytes; check it against the test vector (Requests with a body). - Repeat a list filter in the query for several values,
?signal=vpn&signal=tor, neversignal[]=(Search filters). - Read errors as
{ error, message }JSON and switch onerror; the CDN's 403 for a body hash isn't JSON, so fall back to the text (Errors). - Wait out a
429for itsRetry-Afterand send the call again (Rate limit). - Page with
cursoruntil an answer has none (Paging). - Verify webhook signatures over the raw body, with every
v1collected and the 5 minute window (Verify the signature, with a test vector). - Open sealed results: standard base64, version byte, IV, ciphertext, tag, no additional data (Format, with a test vector).
For AI assistants, the same API comes as an MCP server: see AI assistants.