Zeno ドキュメント
APIキーを取得

サーバー

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

Shell
bun add @zeno-id/server
TypeScript
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)
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

FunctionCallReturns
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 /fingerprintsThe 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:

FunctionDoes
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.

TypeScript
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.

TypeScript
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.

TypeScript
// 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

TypeScript
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:

TypeScript
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:

  1. Send the secret key in Auth-API-Key, not Authorization (Authentication).
  2. Encode ids in the path as a path segment: encodeURIComponent, PHP's rawurlencode, Go's url.PathEscape (Calls).
  3. 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).
  4. Repeat a list filter in the query for several values, ?signal=vpn&signal=tor, never signal[]= (Search filters).
  5. Read errors as { error, message } JSON and switch on error; the CDN's 403 for a body hash isn't JSON, so fall back to the text (Errors).
  6. Wait out a 429 for its Retry-After and send the call again (Rate limit).
  7. Page with cursor until an answer has none (Paging).
  8. Verify webhook signatures over the raw body, with every v1 collected and the 5 minute window (Verify the signature, with a test vector).
  9. 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.