SaaS Pro Max
Sign inStart free
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:

Text
?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#

EndpointScopePermissionWhat it does
GET /apps/{app}/analytics/overviewreadanalytics.readHeadline metrics with a comparison window
GET /apps/{app}/analytics/timeseriesreadanalytics.readOne metric bucketed over a range
GET /apps/{app}/analytics/breakdownreadanalytics.readTop values for one dimension
GET /apps/{app}/analytics/retentionreadanalytics.readCohort retention by week or month
GET /apps/{app}/analytics/active-usersreadanalytics.readDAU, WAU, MAU and stickiness
GET /apps/{app}/analytics/goalsreadanalytics.readConversions per goal over a range
GET /apps/{app}/analytics/livereadanalytics.readVisitors active in the last five minutes
GET /apps/{app}/eventsreadevents.readThe raw event feed, newest first
GET /apps/{app}/events/namesreadevents.readEvent names seen in a range, with counts
GET /apps/{app}/funnelsreadanalytics.readList saved funnels
POST /apps/{app}/funnelswriteanalytics.writeSave a funnel
GET /apps/{app}/funnels/{id}readanalytics.readRead one funnel
PUT /apps/{app}/funnels/{id}writeanalytics.writeEdit a funnel with an expected revision
GET /apps/{app}/funnels/{id}/resultsreadanalytics.readStep-by-step conversion
DELETE /apps/{app}/funnels/{id}writeanalytics.writeDelete a funnel
GET /apps/{app}/goalsreadanalytics.readList conversion goals
POST /apps/{app}/goalswriteanalytics.writeCreate a conversion goal
PUT /apps/{app}/goals/{id}writeanalytics.writeEdit a goal with an expected revision
DELETE /apps/{app}/goals/{id}writeanalytics.writeDelete a conversion goal

Overview#

Text
GET /apps/{app}/analytics/overview?range=7d&env=production
JSON
{
  "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#

Text
?metric=visitors|pageviews|sessions|events|conversions   (default visitors)
?interval=hour|day|week|month                            (default: the range's grain)
JSON
{ "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#

Text
?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.

JSON
{ "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#

Text
?cohortBy=week|month&event=<optional>&range=&filter=
JSON
{ "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#

JSON
{
  "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#

Text
?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#

Text
GET /apps/{app}/events/names?range=7d&env=production&filter=
JSON
{
  "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#

JSON
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:

JSON
{
  "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.

Every funnel carries a revision. To edit one in place, send the whole definition with the revision you read:

JSON
PUT /apps/{app}/funnels/{id}
{ "revision": 3, "name": "Signup", "steps": [ … ], "windowHours": 72 }

The answer is the funnel with its next revision. If someone saved it after you read it, the answer is 409 conflict and nothing changes; read it again and reapply your edit. The id never changes, so dashboards and the Meta ads view that follow the funnel keep following it. Deleting a funnel needs the member's passkey approval through the analytics.funnel-delete command.

Goals#

JSON
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.

PUT /apps/{app}/goals/{id} edits a goal in place with the same body plus the revision you read. A stale revision is a 409; matching what another goal already counts is a 400.

GET /apps/{app}/goals lists the definitions. What they converted is a separate read:

Text
GET /apps/{app}/analytics/goals?range=7d&env=production&filter=
JSON
{
  "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:

Text
?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.

Embedding a share#

/embed/{shareId} takes the same query string plus three presentation-only parameters, each checked against a fixed allowlist and otherwise left at its default:

Text
&theme=auto|light|dark                   (default auto, the reader's system setting)
&bg=canvas|transparent                   (default canvas)
&layout=full|compact                     (default full)

They only change how the report is drawn: they never reach the query layer and are not part of the share's cache key. Settings → Public sharing builds the embed code for any published share, with these options as controls:

HTML
<iframe src="https://saaspro.dev/embed/{shareId}?layout=compact" title="Acme traffic" width="100%" height="520" loading="lazy" referrerpolicy="no-referrer" style="border:0"></iframe>
<script src="https://saaspro.dev/spm-embed.js" async></script>

/spm-embed.js sizes each iframe to its report so it never scrolls inside the host page. The report posts one message to its parent, { type: "spm:embed-height", height }, and nothing else; the script accepts it only from the origin it was served from and only from an iframe on the same page. Without the script the frame keeps the height in the embed code (1100 for full, 520 for compact). The report header names the application by its host and favicon; no live visitor count is published.