Querying events
This page covers reading data back out of Tidings programmatically — for exports, scheduled reports, or your own tooling. Everything you send is readable over the API, the same data the explorer, dashboards, and user profiles are built on. Note that querying uses a different credential from ingestion.
A different auth
Ingestion uses the X-API-Key key (see Authentication). The read API is different: it authenticates as a logged-in user with a token in the Authorization header — Authorization: Token <key>. Don't send an ingestion key here; the two schemes are separate.
Personal access tokens
The login token above is the one your browser session uses. For anything that is not a browser — a script, a scheduled report, a CI job, an AI agent — create a personal access token instead, under Account → Personal access tokens in the app. Tokens are prefixed tk_, shown once and hashed at rest, can carry an expiry date, and are revocable one at a time. They travel in the same header under a different scheme:
Authorization: Bearer tk_your_tokenTwo scopes: read covers everything read-only on this page, and read_write adds creating annotations — and nothing else. No scope can create API keys, invite members, create projects, or delete anything, which is the point: a token you leave on a build server is a narrower thing to lose than a login. Prefer one over the login token whenever the credential will outlive the sitting you created it in, and give each consumer its own so revoking one does not sign you out everywhere.
The same token is what connects an AI agent to your data over MCP — see Connect AI agents.
Endpoints
All paths are under /api/v1/ and scoped to a project you belong to. A project is addressed by its organization slug and its own slug — both are in the URL you see in the app, and GET /api/v1/projects/ lists them. Below, PROJECT stands for organizations/{org_slug}/projects/{project_slug}.
GET PROJECT/events/
List events, newest first, 50 per page. Filters: name, distinct_id, search, start, end, and repeated prop=key:value (up to five).
GET PROJECT/events/names/
The distinct event names seen in the project — the vocabulary for filtering.
GET PROJECT/events/properties/
Property keys in use; properties/{key}/values/ lists the values for one key.
GET PROJECT/events/live/?after_id={id}
Tail new events since a cursor — powers the live feed.
GET PROJECT/events/{event_id}/
A single event by id.
GET PROJECT/users/
Distinct users (by distinct_id); users/{distinct_id}/ returns one user's history.
Sessions
A session is one person's run of events with no gap longer than 30 minutes — the same rule the explorer, paths, and the charts all use, echoed back as gap_minutes. Visits are built from the events that survive your filters, not from the visit that contained them: filter to name=checkout and a session is that person's run of checkouts. Two endpoints read them — one lists the visits, one counts them.
GET PROJECT/events/sessions/
The explorer's rows, grouped into visits — the same filters as events/ (name, distinct_id, search, start, end, prop), but paged by session: page and page_size (20 by default, 50 at most). An unqualified request reads the last 7 days. Sessions come newest first, each with started_at, ended_at, duration_seconds, event_count and its events inline (up to 200, past which the session says truncated). The properties every event of a visit shares are lifted once into constant_properties — the build, the device, the release channel — and the events keep only what actually varied from step to step.
GET PROJECT/analytics/sessions/
The same visits, counted. Takes the charts' range parameters (days and interval, or start/end; 30 days by default) plus a saved segment and JSON filters, which pick the events to sessionise. Returns a summary (sessions, users, sessions per user, median and average duration, bounce rate, events per session), a series of the same numbers per bucket, and a distribution of session lengths across seven bins. The equivalent previous period comes back as previous unless you send compare=false (or the range reaches past your retention, where a comparison would quietly be a lie).
Two definitions to know before you plot any of it. The headline duration is the median_seconds, with avg_seconds reported alongside rather than in its place: session length is a skewed distribution, and one abandoned tab open all afternoon moves the average and not the median. And a one-event session has no span at all, so it is left out of both durations and out of the distribution — counted instead as bounces and reported as bounce_rate. Sessions are placed in a bucket by the time they started, and a visit already under way when the window opens begins at that edge.
Past 500,000 events in the window the answer is sampled — by user hash, one user in ten, kept whole so that no session is cut in half and no bucket of the series is emptied. The payload says so with sampled: true and sample_rate: 0.1. The counts (sessions, users, bounces, the distribution) are scaled back up by that factor and are therefore estimates; the medians, averages and rates are not scaled, because sampling whole people does not change what a typical visit looks like.
Heatmaps
Where people clicked on a page and how far down it they got, built from the $click and $scroll events the browser snippet records when heatmap capture is on. All of these take the charts' range parameters (days, or start/end; 30 days by default).
GET PROJECT/analytics/heatmap/pages/
The pages worth looking at: up to 50 (host, path) pairs ranked by clicks, each with its clicks, pageviews and scroll_samples. An activity block comes alongside them — page views in the window, the hosts they came from, the newest one's timestamp, and how many of them the browser snippet sent — so an empty list can tell no traffic from no capture.
GET PROJECT/analytics/heatmap/?path=&host=&device=
One page's heatmap. path is required — there is no heatmap of a project — host is optional and means any host, and device is all (the default), desktop, tablet or mobile. Returns a grid of [col, row, count] cells — 40 proportional columns across, 25-pixel rows down, because x is a fraction of the page width and y is a page pixel — the 25 most-clicked elements with their share, and a scroll curve of the share of views that reached each tenth of the page, with avg_dwell_ms.
GET PROJECT/analytics/heatmap/snapshots/?path=&host=
The status of the screenshot behind the heatmap, one entry per device (captured_at, requested_at, width, height, error), null where nothing has been asked for yet.
POST PROJECT/analytics/heatmap/snapshots/
{ path, host, device } asks for a capture, or re-asks for one that already exists; 202 with the row's status, since the screenshot itself is taken by a background job within the next few minutes. Only https pages on a host your own visitors have reported page views from can be captured.
GET PROJECT/analytics/heatmap/snapshot/?path=&host=&device=
The screenshot itself, as image/jpeg; 404 until the first capture lands.
POST PROJECT/analytics/heatmap/overlay-token/
{ path, host, device } plus the range, and back comes { token, url, expires_in } — a ten-minute link that opens your own page with the heatmap painted over it, for the screens a screenshot can never reach.
Two things to know about the numbers. Each query reads the newest 200,000 clicks on that page and says so with sampled: true when it hit the cap — a picture of the last 200,000 clicks is the same picture as one of all of them, because the question is where clicks land and not how many there were. And the device bands are read off the viewport width the browser reported (vw), not off a user agent: mobile under 768 pixels, tablet 768 to 1024, desktop from 1024 — the layout a click landed on is a function of the viewport, which is the thing that was measured. pageviews is counted from real page_view events, so clicks per view stays honest on a site that switched capture on halfway through the window.
Annotations
An annotation is a dated note pinned to a project's timeline — it shows up as a marker on the charts covering that day, so a spike arrives with its explanation attached. The obvious use is a deploy marker: have your release pipeline POST one when it ships.
$ curl -X POST $TIDINGS_API/api/v1/organizations/<org>/projects/<project>/annotations/ \
-H 'Authorization: Token <your token>' \
-H 'Content-Type: application/json' \
-d '{"date": "2026-09-15", "text": "v1.5 shipped"}'A user token, not an ingestion key — annotations are written by your team, not by your app. date is a day, not a timestamp, and text is at most 200 characters. GET PROJECT/annotations/ lists them, narrowed by the same days or start/end range as the charts, and GET organizations/{org_slug}/annotations/ merges every project in the organization.