Serveur
Search events
Filter your project's events by visitor, external ID, IP, signal, score and date, and page through them.
GET /events searches your project's events, newest first. Every filter is optional, and filters combine (AND).
curl "https://api.zeno.id/events?signal=vpn&minRiskScore=6&limit=50" \
-H "Auth-API-Key: sk_..."{
"events": [
{
"requestId": "1792578564800.yU4Ety",
"apiKeyId": "7d0c5f0e-3b6a-4c1e-9a52-1f4e8b2d6c90",
"visitorId": "q1yLOPpzvOstEQCFQQxE",
"externalId": "user-2077",
"action": "login",
"origin": "https://example.com",
"ip": "185.65.134.10",
"country": "NL",
"city": "Amsterdam",
"asn": "AS39351",
"company": "31173 Services AB",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...",
"browser": "Chrome 156",
"os": "Windows 11",
"detected": ["vpn", "abusive-ip"],
"bot": "not_detected",
"riskScore": 6,
"confidence": 0.99,
"createdAt": "2026-10-21T12:58:49.476Z"
}
],
"cursor": "WyIyMDI2LTEwLTIxVDEyOjU4OjQ5LjQ3NloiLCIxNzkyNTc4NTY0ODAwLnlVNEV0eSJd",
"found": 128
}Filters
| Filter | Example | Description |
|---|---|---|
visitorId | q1yLOPpzvOstEQCFQQxE | One visitor. |
externalId | user-2077 | The ID you attached, like an account in your app. |
ip | 185.65.134.10 | An exact IP; no CIDR ranges. |
requestId | 1792578564800.yU4Ety | One event. |
apiKeyId | 7d0c5f0e-… | The public key that identified, by its id (the event's apiKeyId). |
country | NL | Two letters, in any case. |
origin | https://example.com | The page's origin, exactly. |
asn | AS9009 | The network, with its AS. |
browser, os | Chrome 156, Chrome, macOS | A name with its version exactly as the search item shows it, or the name alone for every version. A browser has its major version (Chrome 156), an OS its version as the event's browserDetails has it (macOS 27.0.1, Windows 11). |
action | signup | The action passed to get() or set later. |
signal | vpn | Events that detected this signal. |
bot | bad | good, bad or not_detected. |
minRiskScore | 9 | A risk score of at least this. |
start, end | 2026-10-01 | ISO 8601 dates or times, or epoch milliseconds; both included. A date alone is a UTC day: 00:00 UTC for start, the whole day for end. A time is UTC unless it carries an offset: Z, or +02:00 URL-encoded as %2B02:00 (an unencoded + reads as a space and answers 400). A start after end answers 200 with nothing found. |
limit | 50 | 1 to 100, 20 by default. |
cursor | The cursor of the previous page, unchanged. |
apiKeyId, country, origin, asn, browser, os, action, signal and bot also take several values, up to 20, matching any of them: repeat the parameter, as in ?country=DE&country=NL. Python's requests does that for a list (params={"country": ["DE", "NL"]}), Go's url.Values with Add, and the SDK for an array. PHP's http_build_query, and so Laravel's Http::get($url, $array) and Guzzle's array query option, write country[0]=DE, which is refused as an unknown parameter: build the query with GuzzleHttp\Psr7\Query::build($filters), or join the name=value pairs yourself.
Names are case-sensitive. A parameter that isn't a filter, like
externalID,signalsorkey(it'sapiKeyId), answers400 invalid_requestnaming it and the filter you probably meant, rather than being ignored and matching every event.
Results
found is the exact number of events matching the filters, on every page. A search item is flat and smaller than the event:
| Field | Description |
|---|---|
requestId, apiKeyId, visitorId | As in the event. |
externalId, action, origin | As in the event; absent when it has none. Search results leave metadata out; read the event for it. |
ip, country | As in the event. |
city, asn, company | The event's ipInfo.city, ipInfo.asn and ipInfo.company. |
userAgent | As in the event. |
browser, os | Name and version in one string, like Chrome 156 and macOS 27.0.1, from the event's browserDetails. |
detected, bot, riskScore, confidence, createdAt | As in the event. |
Search items leave out visitorMatch, returning, updatedAt, expiresAt, url, referrer, browserDetails, incognito, ipTimeZone, ipInfo, ja4, ja3, socketProof, riskScoreBreakdown, signals, detectorVersion, the signals' evidence (automationMarkers, gpuRenderer, vpn, proxy, tampering, tlsMismatch, virtualMachine) and velocity; get the event for those.
Paging
Pass the cursor of each page to get the next one. The last page has no cursor field at all. A cursor is opaque: pass it back unchanged with the same filters. One that isn't a page's answers 400 invalid_request, so a loop with a broken cursor stops instead of starting over.
const FP_SECRET_KEY = process.env.FP_SECRET_KEY!
let cursor: string | undefined
do {
const query = new URLSearchParams({ externalId: 'user-2077' })
if (cursor) query.set('cursor', cursor)
const response = await fetch(`https://api.zeno.id/events?${query}`, {
headers: { 'Auth-API-Key': FP_SECRET_KEY },
})
if (!response.ok) {
throw new Error((await response.json()).message)
}
const page = await response.json()
handle(page.events)
cursor = page.cursor
} while (cursor)let cursor: string | undefined
do {
const page = await fingerprint.searchEvents({ externalId: 'user-2077', country: ['DE', 'NL'], cursor })
handle(page.events)
cursor = page.cursor
} while (cursor)import os
import requests
session = requests.Session()
session.headers["Auth-API-Key"] = os.environ["FP_SECRET_KEY"]
def search_events(**filters):
"""Every matching event, newest first, page by page."""
params = dict(filters)
while True:
# A list value becomes a repeated parameter: country=DE&country=NL.
response = session.get("https://api.zeno.id/events", params=params, timeout=10)
response.raise_for_status()
page = response.json()
yield from page["events"]
if "cursor" not in page:
return
params["cursor"] = page["cursor"]
for event in search_events(externalId="user-2077", country=["DE", "NL"]):
handle(event)use GuzzleHttp\Psr7\Query;
use Illuminate\Support\Facades\Http;
// Every matching event, newest first, page by page. Query::build repeats a
// list (country=DE&country=NL); http_build_query, and so Http::get($url,
// $array), writes country[0]=DE, which the API refuses.
function searchEvents(array $filters): Generator
{
do {
$response = Http::withHeaders(['Auth-API-Key' => config('services.fingerprint.secret_key')])
->timeout(10)
->get('https://api.zeno.id/events?' . Query::build($filters))
->throw();
yield from $response->json('events');
$filters['cursor'] = $response->json('cursor');
} while ($filters['cursor'] !== null);
}
foreach (searchEvents(['externalId' => 'user-2077', 'country' => ['DE', 'NL']]) as $event) {
handle($event);
}// url.Values repeats a list: Add twice gives country=DE&country=NL.
query := url.Values{}
query.Set("externalId", "user-2077")
query.Add("country", "DE")
query.Add("country", "NL")
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.zeno.id/events?"+query.Encode(), nil)
if err != nil {
return err
}
req.Header.Set("Auth-API-Key", os.Getenv("FP_SECRET_KEY"))
res, err := httpClient.Do(req)
if err != nil {
return err
}
var page struct {
Events []map[string]any `json:"events"`
Cursor string `json:"cursor"`
Found int `json:"found"`
}
err = json.NewDecoder(res.Body).Decode(&page)
res.Body.Close()
if err != nil {
return err // check res.StatusCode first in real code
}
handle(page.Events)
if page.Cursor == "" {
return nil
}
query.Set("cursor", page.Cursor)
}Recipes
Risky signups this week
# start: a week before today
curl "https://api.zeno.id/events?action=signup&minRiskScore=9&start=2026-10-18" \
-H "Auth-API-Key: sk_..."On the dashboard: Events, filter Action is signup and Risk score at least 9, period 7d.
Everything one browser did
?visitorId=q1yLOPpzvOstEQCFQQxE lists its events with every external ID (here user-2077 and user-5120); the visitor API answers the same in one call, for its 20 latest.
Bad bots from one network
?bot=bad&asn=AS14061, or several networks with asn repeated.
Accounts sharing a browser
Easier with Related than by search: GET /fingerprints/shared?type=visitor-id lists the visitor IDs more than one external ID was seen with.