HTTP API
Every route the server exposes, with authentication, parameters, filters and status codes.
All paths are relative to basePath (default /api). Dates are ISO 8601, or Unix milliseconds on the Query objects used in code. Time zones are IANA names and default to the site’s time zone. Intervals are hour, day, week or month.
Public routes
CORS is open and text/plain bodies are accepted, so the browser sends events without a preflight request.
| Method and path | Auth | Description |
|---|---|---|
POST /event |
none | Tracker payload, see below. 202 on accept and on a filtered bot. 400, 403, 404 or 413 otherwise. 429 with a Retry-After header when one client sends more than 300 events a minute to a site (per server instance). 8 KB at most. |
POST /attribution |
none | Returns { visitorId, sessionId } computed on the server for this request. Rate limited per hash. |
POST /v1/payments |
Bearer site API key | Body: PaymentInput. Idempotent on (siteId, id). Answers { ok, duplicate, payment, attributed }. |
POST /v1/bots |
Bearer site API key | Body: { requests: [{ userAgent, path, ts? }] }, at most 500. Answers { ok, recorded, skipped }. See Bot traffic. |
POST /v1/annotations |
Bearer site API key | Body: { text, ts? }. Adds a dated note to the charts. ts is Unix ms or an ISO 8601 date, default now; text at most 200 characters. Answers 201 { ok, note }. See Notes on the chart. |
POST /v1/goals |
Bearer site API key | Body: name, optional props, and visitorId or email. |
POST /webhooks/:provider/:siteId |
signature | provider is stripe, dodo, lemonsqueezy, paddle, polar, razorpay, creem or gumroad. Verified with the site’s webhook secret. |
POST /v1/cron |
Bearer cron secret | Runs the scheduled email work: the summary if due and tracker-down alerts. Answers { ok, summary, down }. 401 without the email.cronSecret. GET works too. See Email notifications. |
GET /health |
none | Health check. Answers { ok, dialect }, or 503 with ok: false when the database cannot be reached. The failure error message is added only for a logged-in admin or when debug is on. Unknown paths answer 404 not found (routes are under <basePath>). |
Event body
The body is a TrackerPayload, compact on purpose, to keep the request small:
| Key | Type | Meaning |
|---|---|---|
w |
string | Site id. Required. |
t |
pageview, goal, outbound, ping, identify or revenue |
Event type. Required. |
u |
string | Page URL. Required. |
r |
string | Referrer. |
vid |
string | Visitor id. Only needed in persistent mode. |
sid |
string | Session id. Only needed in persistent mode. |
sw |
number | Screen width. |
n |
string | Goal or event name. |
p |
object | Properties: strings, numbers, booleans or null. |
d |
number | Engaged time in milliseconds. |
a |
number | Revenue amount, major units (19.99). |
c |
string | Currency. |
o |
string | Order id. revenue events are de-duplicated by it. |
m |
strict, enriched or persistent |
Privacy mode of the tracker. Without it the event is treated as strict, and vid/sid are ignored. |
curl -X POST https://analytics.example.com/api/event \
-H 'Content-Type: text/plain' \
-d '{"w":"SITE_ID","t":"goal","u":"https://example.com/pricing","n":"signup","p":{"plan":"pro"}}'
Admin routes
Admin routes need a session cookie from POST /auth/login, or Authorization: Bearer <admin token>. Without one they answer 401.
Most of them also accept an API key as Authorization: Bearer anl_..., within the key’s scope and sites (see API keys). Every route a key may use is served under /v1 as well (/v1/sites/:id/overview), with identical behavior; the /v1 address is the stable one for scripts. GET /v1/openapi.json describes them for tools, see OpenAPI document.
Auth
| Method and path | Description |
|---|---|
POST /auth/login |
Body { "password": "..." }. Sets the session cookie. |
POST /auth/logout |
Clears it. |
GET /auth/me |
Whether the current request is authenticated. |
| Method and path | Description |
|---|---|
GET /email |
{ configured, to, settings }: whether the server has an email sender, its default recipient and the saved EmailSettings (to, payments, summary, hour, down, timezone). |
PUT /email |
Saves the fields sent and keeps the rest. 400 for an invalid address, frequency (off, daily, weekly), hour (0 to 23) or time zone. Answers the saved settings. |
POST /email/test |
Sends a test message to to from the body, else the saved or default recipient. Answers { ok, to }; 400 without a sender or address, 502 with the sender’s error message when it refuses. |
Preferences
| Method and path | Description |
|---|---|
GET /preferences |
The dashboard settings (overview layout, summary numbers and chart, live refresh) as one JSON object; {} before any are saved. |
PUT /preferences |
Replaces them with the JSON object in the body. The server stores it as is; the dashboard checks each value when it reads it. |
Sites
| Method and path | Description |
| –––––––––––––– | —————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————————— | –––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– |
| GET /sites | List sites. |
| GET /summary | Every site’s visitors, revenue and orders in from–to (ISO dates, default the last 24 hours) and who is online now, as SiteSummary[]. One request for the sites overview, however many sites there are. series=hour | dayaddstraffic(visitors) andsales(revenue) arrays with one point per bucket fromfrom; compare=1addsprevious, the totals of the window before. |
| GET /my-ip | The address this request comes from as the server sees it: { ip }, or null when no trusted proxy header says. The dashboard’s “Add my current IP” uses it. |
| GET /version | The running version and the newest release on npm: { current, latest }; latest is null when the server could not look it up or updateCheck is off. The server asks the registry at most every six hours. The dashboard uses it for its update notice. |
| GET /sites/:id/favicon | The site’s stored icon; the first request for a site that has none reads the site (see POST /sites/:id/sync). An icon older than 30 days is returned at once and fetched again behind it. 404 when there is none; a site that could not be read is not asked again for an hour. |
| POST /sites/:id/sync | Reads the site’s home page and returns what it found against what is stored: { reachable, tracker, changes }, each change { field, current, found, changed } for name, color and icon (a data: URL), and manual: true on a name that was typed by hand. tracker is found, not-found or unknown. Changes nothing. Generated test sites answer { skipped: "test-site" }. |
| POST /sites/:id/sync/apply | Body { "fields": ["name", "color", "icon"] }. Reads the page again and takes over the listed fields it found; answers { site, applied }. 502 when the page cannot be read. |
| POST /sites | Create a site. Only domain is required (a real domain such as example.com; localhost is rejected). Other fields: name, timezone, currency, privacyMode, retentionDays, allowLocalhost, ignoredIps (addresses or CIDR ranges), ignoreAdmins, vitalsSample (percent of page views, 0 to 100, that measure Core Web Vitals; 0 is off), targets (up to 20 of { goal, target, period }, period week, month or quarter), shareToken. Answers 201 with { site, apiKey }; the key is shown once. |
| GET /sites/:id | One site. Secrets are never returned: webhooks says which providers have a secret, and shareToken is the current share token or null. |
| PATCH /sites/:id | Change settings, including webhook secrets (webhookSecrets: { stripe: "..." }, null removes one) and the share link (shareToken: true creates a token, null turns sharing off). paused: true stops tracking until set back to false: tracker events and server goals are dropped (202 with filtered: "paused"), payments are still recorded. |
| DELETE /sites/:id | Delete the site and its data. The body proves it is meant: { "confirm": "example.com", "password": "..." }, the site’s domain and the admin password. When admins sign in through verify() there is no password, and the domain alone is enough. A wrong domain answers 400; a wrong password 403, and it counts toward the login limit. |
| POST /sites/:id/rotate-key | Issue a new site API key. The old one stops working. |
Statistics
All take from, to, interval, tz and filters. GET /sites/:id/realtime takes none of them.
overview, timeseries and breakdown also take compare=previous (the period of the same length just before) or compare=year (the same dates a year earlier). The comparison comes back in the same response: every TimeseriesPoint and BreakdownRow gets a previous object (the bucket at the same position, matched by index; the row’s numbers, all 0 when the value had no visits), and overview measures its changes against that period. Without compare nothing extra is read, and with it every one of these requests reads the data twice, so leave it off on a dashboard you keep open all day on a database that bills rows read.
| Method and path | Returns |
|---|---|
GET /sites/:id/overview |
Overview. Revenue counts only the site’s currency; the rest is in otherCurrencies. |
GET /sites/:id/timeseries |
TimeseriesPoint[]: traffic, revenue, orders and rates per bucket |
GET /sites/:id/breakdown?dimension= |
BreakdownRow[] |
GET /sites/:id/properties?name=&key= |
PropertiesResult: the keys of the goal name (up to 20, with event counts), or with key its values with visitors and events (up to 50, the rest in other). Goal events only |
GET /sites/:id/bots |
BotStats: crawler requests, filtered by repeated crawler, category and page |
GET /sites/:id/vitals |
VitalsResult: the 75th percentile of Core Web Vitals for the site and its pages with the most reports, over the range (admin only) |
GET /sites/:id/journeys |
JourneysResult: where sessions go over up to four steps, from the newest 5,000 sessions of the last 30 days of the range (admin only) |
GET /sites/:id/retention |
RetentionResult: weekly cohorts of new visitors and how many came back, at most 12 cohorts (admin only; empty unless the site uses the persistent privacy mode) |
GET /sites/:id/targets |
TargetsResult: progress of the site’s goal targets in their own periods (admin only; takes no range) |
GET /sites/:id/realtime |
Realtime: visitors in the last 5 minutes, recent events and their visits on the map |
GET /sites/:id/sessions |
Page<SessionSummary>, see Lists |
GET /sites/:id/sessions/:sid |
One session with its visitor profile, events and payments (admin only) |
GET /sites/:id/events?name=&type= (alias /event-log) |
Page<EventRow>, see Lists |
GET /sites/:id/payments |
Page<Payment>, see Lists (admin only) |
Dimensions for breakdown: channel, source, referrer, campaign, medium, term, content, ref, page, entry, exit, country, region, city, browser, os, device, event, outbound, goal.
Every stats route also takes property filters, written prop:<key>=<value> (URL-encode the colon): they keep sessions with a goal event whose property equals the value, and when goal or event is also set, the same event has to match both. Keys use 1 to 64 letters, digits, _, . and -; other keys are ignored.
Lists
Sessions, events and payments answer one page at a time, newest first: { items, nextCursor, total }. total counts every match, not just this page.
| Parameter | Meaning |
|---|---|
limit |
Page size, up to 100. |
cursor |
The previous page’s nextCursor. Only for the default order. |
offset |
Skip this many matches, for numbered pages. nextCursor is then null. |
sort, order |
Sort key and asc or desc (default desc). nextCursor is then null. Empty values sort last. |
search |
Case-insensitive substring match over the list’s text columns. |
visitor |
Only the rows of this visitor id. |
Sort keys: sessions time duration pageviews source country entry revenue; events time type name page; payments time amount provider customer attribution. Sessions search pages, sources, channels, places, browsers, systems and visitor ids; events search names, paths and types; payments search ids, emails, names, customer, order and subscription ids and the provider.
Events also take name and type (pageview, goal, outbound, revenue, identify). Payments take kind (payment or refund), provider and attributed (true or false). An unknown sort, order or filter value answers 400, and so does a cursor combined with sort or offset.
curl -b cookies.txt \
"https://analytics.example.com/api/sites/SITE_ID/payments?kind=refund&sort=amount&order=desc&offset=25&limit=25"
Filters
Filters use the dimension names as query keys: country=DE&channel=Organic%20Search&page=/pricing. Repeat a key to match any of several values.
curl -b cookies.txt \
"https://analytics.example.com/api/sites/SITE_ID/breakdown?dimension=source&from=2026-10-01&to=2026-11-01&country=DE"
Share links
Admin routes, per site:
| Method and path | Description |
|---|---|
POST /sites/:id/share-links |
Body: optional label (80 characters), views (any of overview, sources, pages, events, sessions, bots, funnels, realtime; empty means all) expiresInDays (1 to 3650) and ipAllowlist (see below). Answers 201 { link, token }. The token is shown only now. At most 50 active links per site. |
GET /sites/:id/share-links |
ShareLink[], newest first, revoked ones included, without tokens. |
DELETE /sites/:id/share-links/:lid |
Revokes the link at once. 404 for an unknown link. |
A request that carries a token, as ?share=TOKEN, an x-analytik-share: TOKEN header or Authorization: Bearer TOKEN, can read these GET routes without logging in: shared (the site’s name, domain, time zone and currency, and the link’s views), overview, timeseries, breakdown, properties, realtime, sessions, events, bots, and the funnel list and funnel results. A link limited to some views reads only the routes behind them; any other route answers 401, as does a token for another site, a revoked or an expired one. That is the aggregate numbers and the session list, including visitor ids, countries, cities, browsers, pages, referrers, and event names and properties. Payments, single-session details, the site settings, export and every write, including the share-link routes, stay admin only.
Only a token in the address is cached publicly (a header or bearer token is never): those answers carry Cache-Control: public, s-maxage=60 (10 seconds for realtime). Tokens are stored as SHA-256 hashes.
The older single token still works: PATCH /sites/:id with { "shareToken": true } returns a plain shareToken that opens every shareable route, uncached, until { "shareToken": null }. Prefer share links.
API keys
Created and managed by the signed-in admin only; a key can never read or change keys. Creating, rolling, enabling and deleting need password (the admin password) in the body unless admins sign in through verify(). A wrong password answers 403 and counts toward the sign-in limit (429 after 10 in 15 minutes). See the guide.
| Method and path | Description |
|---|---|
GET /api-keys |
ApiKey[], newest first, never with a secret: id, label, prefix, scope, siteIds, createdAt, expiresAt, lastUsedAt, disabledAt, previousUntil, ipAllowlist, state (active, disabled, expired). |
POST /api-keys |
Body: password, optional label (80 characters), scope (read default, or write), siteIds (empty means all sites) and expiresInDays (1 to 3650). Answers 201 { key, secret }. The secret starts with anl_ and is shown only now. At most 20 keys. |
PATCH /api-keys/:id |
Body: password and ipAllowlist, a list of at most 20 IPv4 or IPv6 addresses or CIDR ranges that replaces the key’s list; [] clears it and allows any address. Entries are stored in canonical form and duplicates dropped. A bad entry answers 400 naming it and saves nothing. Answers the ApiKey. 404 for an unknown key. |
POST /api-keys/:id/roll |
Body: password and optional graceHours (0 to 168, default 0). Answers { key, secret }. The old secret works for the grace period, a second roll ends it. |
POST /api-keys/:id/disable |
Needs no password. Every request with the key answers 401 until it is enabled. |
POST /api-keys/:id/enable |
Body: password. |
DELETE /api-keys/:id |
Body: password. The key stops working at once. 404 for an unknown key. |
GET /api-keys/events |
The newest 200 events: created, rolled, disabled, enabled, deleted, denied (a call refused for its scope or site, recorded once a minute per key and route), denied_ip (a call refused for its address, with the address or unknown, once a minute per key and address) and allowlist_changed (the number of entries before and after), with time, key and label. |
GET /whoami |
For a key: { kind: "key", id, label, prefix, scope, siteIds, expiresAt, rateLimit }; for an admin { kind: "admin" }. |
What a key may call:
read: everyGETof its sites that is a statistic or a list:/sites,/sites/:id,overview,timeseries,breakdown,properties,bots,vitals,journeys,retention,targets,realtime,sessions,sessions/:sid,events,event-log,funnels,payments,export,imports,shared,favicon, and/summary.write: all of the above plusPATCH /sites/:id, annotations (create, change, delete), funnels (create, delete),POST /sites/:id/rematch,POST /sites/:id/importandDELETE /sites/:id/imports/:source.- Never: deleting a site,
rotate-key,sync, share links, webhooks, deleting data, e-mail, preferences, defaults, debug, sign-in and the key routes above.
A key with an ipAllowlist answers 403 this key cannot be used from this address for any other address, and when the address is unknown; the check comes before scope and site and does not count toward the 30 unknown-key limit. A key limited to some sites answers 403 on others and sees only its sites in /sites and /summary. Errors: 401 (unusable key), 403 (not allowed, the message says why), 429 (limit). Each key may send 120 requests a minute and 12 a minute to journeys, retention, export, import and sessions?search=; answers carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, and a 429 adds Retry-After. The limits and where they are counted (keyRateLimit.perMinute, heavy, store: 'database' | 'memory') are server options. Unknown keys are counted per address: 30 in 15 minutes, then 429. Answers to a key are never cached (Cache-Control: no-store).
Outgoing webhooks
Admin only; a share link cannot read or change them. See the guide.
| Method and path | Description |
|---|---|
GET /sites/:id/webhooks |
Webhook[] with the host instead of the address, no secrets. |
POST /sites/:id/webhooks |
Body: url (https, public host), events (goal, payment) and format (json or chat). Answers 201 { webhook, secret }; the secret is shown only now. At most 5 per site. |
PATCH /sites/:id/webhooks/:wid |
Change events, format or paused. |
DELETE /sites/:id/webhooks/:wid |
Remove it. |
POST /sites/:id/webhooks/:wid/test |
Send one test message. Answers { ok, status }. |
Annotations
| Method and path | Description |
|---|---|
POST /sites/:id/annotations |
Body: { text, ts? }, see POST /v1/annotations. Answers 201 with the note. |
PATCH /sites/:id/annotations/:nid |
Body: text and/or ts. Answers the note, or 404. |
DELETE /sites/:id/annotations/:nid |
404 for an unknown note. |
Notes are read with GET /sites/:id/timeseries: each point carries the notes dated in its bucket as notes: [{ id, ts, text, createdAt }], at most 100 for the range, left out when there are none.
Funnels
| Method and path | Description |
|---|---|
GET /sites/:id/funnels |
List funnels. |
POST /sites/:id/funnels |
Create one: { name, steps: [{ type, match }] }. |
DELETE /sites/:id/funnels/:fid |
Delete one. |
GET /sites/:id/funnels/:fid |
FunnelResult. |
Maintenance
| Method and path | Description |
|---|---|
POST /sites/:id/rematch |
Retry attribution for payments without a match. |
GET /sites/:id/export?format= |
json: everything for the site in one file. csv: one table at a time, chosen with table=visitors|sessions|events|payments (default events). |
DELETE /sites/:id/data?before= |
Delete older data. before is an ISO 8601 date or Unix milliseconds. Answers with how many events, sessions and visitors were removed. |
The purge deletes events, sessions, return-page claims and visitors left without sessions. Payments are kept so revenue numbers stay correct, but their email, name, customer id, visitor id and session id are cleared. See Retention.
Debug
Test data for trying the dashboard with many sites, long histories and large numbers. GET /debug always answers; the other routes answer 404 unless the server was created with debug: true.
| Method and path | Description |
|---|---|
GET /debug |
{ enabled }: whether the debug routes are on. |
POST /debug/generate |
Writes made-up visits, events, goals and payments (TestDataInput). With siteId they go into that site; otherwise into sites new sites with ids starting debug-. days (0 to 730), visitorsPerDay (0 to 100,000; a zero in either makes empty sites), priceScale and currency set the amounts. With mix: true the new sites differ the way real ones do (an empty one, a quiet blog, a busy shop, one just launched, a paused one and more), and visitorsPerDay and priceScale don’t apply. One call writes at most 1,000,000 visits. Answers with TestDataResult: the sites, counts and how long it took. |
DELETE /debug/sites |
Deletes every site whose id starts with debug-, with its data. Answers { deleted }. |
Payments
POST /v1/payments and analytik.recordPayment() take a PaymentInput. Amounts are minor units. A refund has kind: "refund" and the positive amount. For a refund without an id of its own, send refundedTotal, the total refunded for the order so far, and the server records only the difference; a retried request adds nothing.
Revenue events from the tracker are stored as payments with the provider client and the id client:<orderId>. When a verified payment from a provider webhook or your server arrives for the same order, it replaces the client record instead of counting twice.