Analytics, events, funnels and goals
Module: analytics. See README.md for authentication, ranges,
environments and pagination.
Every number here comes from the same query layer the console renders, so an answer from the API and a number on a dashboard can never disagree.
Filters
overview, timeseries, breakdown, retention, active-users, goals and
events/names accept a repeatable filter parameter:
?filter=country:DE&filter=source:google
The first colon ends the dimension, so a value may contain colons; the
dimensions are the ones breakdown lists. An unknown dimension, or a pair with
no value, is a 400. Repeating a dimension keeps the last value — filters are
one exact value per dimension, combined with AND.
A filtered read cannot be served from the daily rollup, so it scans raw events
and covers at most the last 90 days of the range. Every response that accepts
filters carries capped: true means the window was shortened and the numbers
describe those 90 days, not the range you asked for. breakdown ignores a filter
on the dimension it is breaking down, and events/names ignores an event
filter, because either would leave a single row.
Endpoints
| Endpoint | Scope | Permission | What it does |
|---|---|---|---|
GET /apps/{app}/analytics/overview |
read | analytics.read |
Headline metrics with a comparison window |
GET /apps/{app}/analytics/timeseries |
read | analytics.read |
One metric bucketed over a range |
GET /apps/{app}/analytics/breakdown |
read | analytics.read |
Top values for one dimension |
GET /apps/{app}/analytics/retention |
read | analytics.read |
Cohort retention by week or month |
GET /apps/{app}/analytics/active-users |
read | analytics.read |
DAU, WAU, MAU and stickiness |
GET /apps/{app}/analytics/goals |
read | analytics.read |
Conversions per goal over a range |
GET /apps/{app}/analytics/live |
read | analytics.read |
Visitors active in the last five minutes |
GET /apps/{app}/events |
read | events.read |
The raw event feed, newest first |
GET /apps/{app}/events/names |
read | events.read |
Event names seen in a range, with counts |
GET /apps/{app}/funnels |
read | analytics.read |
List saved funnels |
POST /apps/{app}/funnels |
write | analytics.write |
Save a funnel |
GET /apps/{app}/funnels/{id} |
read | analytics.read |
Read one funnel |
GET /apps/{app}/funnels/{id}/results |
read | analytics.read |
Step-by-step conversion |
DELETE /apps/{app}/funnels/{id} |
write | analytics.write |
Delete a funnel |
GET /apps/{app}/goals |
read | analytics.read |
List conversion goals |
POST /apps/{app}/goals |
write | analytics.write |
Create a conversion goal |
DELETE /apps/{app}/goals/{id} |
write | analytics.write |
Delete a conversion goal |
Overview
GET /apps/{app}/analytics/overview?range=7d&env=production
{
"range": { "from": "2026-08-28T00:00:00.000Z", "to": "2026-09-03T…", "interval": "day" },
"visitors": 4210,
"pageviews": 9877,
"sessions": 5104,
"bounceRate": 0.41,
"avgDurationMs": 96000,
"conversions": 132,
"capped": false,
"comparison": {
"visitors": 3980, "pageviews": 9210, "sessions": 4870,
"bounceRate": 0.44, "avgDurationMs": 91000, "conversions": 118,
"delta": { "visitors": 5.8, "pageviews": 7.2, "sessions": 4.8, "bounceRate": -6.8, "avgDurationMs": 5.5, "conversions": 11.9 }
}
}
comparison is the equally long window immediately before the range. Each
delta is a percentage; null means the previous window was zero, which is not
the same as no change.
visitors is a per-day unique count summed over the range. For deduplicated
uniques across the whole window use active-users.
Timeseries
?metric=visitors|pageviews|sessions|events|conversions (default visitors)
?interval=hour|day|week|month (default: the range's grain)
{ "metric": "visitors", "interval": "day", "capped": false, "points": [{ "t": "2026-08-28T00:00:00.000Z", "value": 604 }] }
The series is dense: a day with no traffic is a zero, never a missing point, so a chart drawn from it cannot imply a gap that is not there.
Breakdown
?dimension=<required>&limit=25&search=&range=&env=&filter=
Dimensions: path, entry_path, exit_path, referrer_domain, source,
medium, utm_source, utm_medium, utm_campaign, utm_term, utm_content,
country, region, city, device, browser, os, event.
{ "data": [{ "value": "/pricing", "visitors": 810, "pageviews": 1204, "sessions": 900, "bounceRate": 0.32, "conversions": 41 }], "nextCursor": null, "capped": false }
Rows are ranked by visitors, then pageviews, then value. An unknown dimension is
a 400 that lists the valid ones.
Retention
?cohortBy=week|month&event=<optional>&range=&filter=
{ "cohortBy": "week", "capped": false, "cohorts": [{ "cohort": "2026-08-03", "size": 412, "periods": [412, 121, 84, 61] }] }
A cohort is formed from a visitor's first ever event in this environment.
Period 0 is the cohort's own week, so it equals the cohort size. Passing event
counts only that event as activity, which is how you measure retention of a real
action rather than of a pageview.
Active users
{
"capped": false,
"dau": [{ "t": "…", "value": 610 }],
"wau": [{ "t": "…", "value": 2100 }],
"mau": [{ "t": "…", "value": 6400 }],
"stickiness": [{ "t": "…", "value": 0.095 }]
}
These are true rolling uniques, deduplicated across each window; stickiness is
DAU/MAU on the same day.
Events
?id=&name=&personId=&sessionId=&search=&limit=50&cursor=&env=
id names one event, which is how a deep link opens an event without paging to
it. It answers a page of one row, or of none when this environment holds no such
event. It is ANDed with the other filters rather than overriding them, so send it
on its own.
search matches over name, path, url, distinct id and the serialized
properties. Rows carry the full enriched event: attribution, UTM, click ids,
geo, device, value and currency, and properties.
Pagination is keyset over (ts, id), so the page boundary stays stable while
events are being written underneath it.
Event names
GET /apps/{app}/events/names?range=7d&env=production&filter=
{
"data": [{ "name": "$pageview", "count": 9877, "users": 4210, "lastSeenAt": "2026-09-03T11:04:22.000Z" }],
"nextCursor": null,
"capped": false
}
lastSeenAt is the most recent occurrence inside the range, and is null
only when the name has no events in it.
Funnels
POST /apps/{app}/funnels
{
"name": "Signup",
"steps": [
{ "kind": "path", "match": "/pricing" },
{ "kind": "event", "match": "signup_started" },
{ "kind": "event", "match": "signup_completed" }
],
"windowHours": 168
}
Two to twelve steps. A step matches an event name or an exact path, optionally
narrowed by filters ({ property, op, value }, where property is a column
like source or country, or property.<key> for a custom property).
GET /funnels/{id}/results?range= answers:
{
"funnel": { "id": "f_1", "name": "Signup", "steps": [ … ], "windowHours": 168 },
"steps": [
{ "step": 1, "name": "Viewed pricing", "entered": 800, "converted": 240, "conversionRate": 0.30, "dropOff": 560 },
{ "step": 2, "name": "Started signup", "entered": 240, "converted": 180, "conversionRate": 0.75, "dropOff": 60 },
{ "step": 3, "name": "Completed signup","entered": 180, "converted": 180, "conversionRate": 1, "dropOff": 0 }
]
}
The funnel is ordered: step n+1 only counts a person who did it after
their step n and inside the window. converted + dropOff = entered on every
row, so the largest dropOff is the step to investigate.
Goals
POST /apps/{app}/goals
{ "name": "Signed up", "kind": "event", "match": "signup_completed", "value": 12 }
A goal is what conversions counts in the overview, the timeseries and every
breakdown. (kind, match) is unique per application; a duplicate is a 400.
value is the monetary value attributed to one conversion.
GET /apps/{app}/goals lists the definitions. What they converted is a separate
read:
GET /apps/{app}/analytics/goals?range=7d&env=production&filter=
{
"data": [{ "goalId": "g_1", "name": "Signed up", "kind": "event", "match": "signup_completed", "conversions": 132, "visitors": 118 }],
"nextCursor": null,
"capped": false
}
Rows are ordered by conversions. conversions counts one per goal an event
matched, so an event matching two goals appears in both rows — which is why
these do not sum to the overview's conversions, which counts events. A goal
deleted since its conversions were recorded keeps its row, with name, kind
and match null.
Public traffic share
An administrator may publish an Application overview share in application
Settings → Public sharing. It serves /s/{shareId} and /embed/{shareId} as
HTML, where shareId is 24 opaque hex characters that name neither the
organization nor the application. Shares published under the earlier
org-app slug still resolve on that slug.
The whole view lives in the query string, so a reader can bookmark or send the exact page they are looking at:
?range=24h|7d|30d|90d (default 30d)
&granularity=hour|day|week|month (default depends on the range)
&metric=visitors|pageviews|sessions (default visitors)
&compare=1 (previous equal-length period)
&pages=path|entry_path|exit_path
&sources=source|medium|referrer_domain
&campaigns=utm_source|utm_medium|utm_campaign
&locations=country|region|city
&devices=device|browser|os
Every value is checked against the allowlist above and falls back to the default rather than reaching the query layer, so an anonymous reader cannot ask for a scan the rollup does not already hold. Pages, campaigns, locations and browsers are a second, separately audited decision and stay private until the owner turns them on; until then their panels offer only the first dimension. There is no cross-dimension filter, because the intersection of two ranked lists is where an aggregate stops being anonymous.
The share needs no management or ingest token and has no JSON endpoint. Disabling it immediately denies subsequent reads, including cached ones. Authorization is resolved before the one-minute cache is consulted. Responses expose no individual events, identities or paths to private pages, and ingest keys retain their write-only capabilities.