Documentation menu

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.