Skip to content

Read API

Fetch your statistics programmatically: internal dashboards, reports, exports. Restricted to the Pro and Agency plans, the beta and active trials.

Authentication

Create a token in Dashboard → API tokens, then pass it as a Bearer:

curl -H "Authorization: Bearer YOUR_TOKEN" \
  "https://quietmetrics.dev/api/v1/stats/qm_pub_XXXXXXXXXXXXXXXXX/summary?period=30d"

All routes are prefixed with /api/v1/stats/{site_public_key}. A token only grants access to the sites on your account; a site that does not belong to you returns 404 (no existence leak).

A token covers either all your sites, current and future, or only the sites picked when it was created: a site on your account that it does not cover returns 403. It stops working on the expiry date shown in the token list.

Endpoints

Endpoint Content
GET /summary Metrics for the period, and for the previous period of the same length
GET /timeseries Time series: one point per day, or per hour when the period fits in a single day
GET /breakdown Ranking of the values of a dimension ({label, value})
GET /realtime Active visitors over the last 5 minutes ({"visitors": N})

Common parameters

period

A dashboard preset: today, yesterday, 7d, 30d (default), month (current month), last_month, 12m. Rolling periods end today, today included.

Or an explicit range, for example ?period=custom:2026-07-01..2026-07-31:

  • custom:YYYY-MM-DD..YYYY-MM-DD: from the first day to the second, both included, over 760 days at most. Reversed bounds are put back in order. The range may end today, not later.

Dates are those of the site's timezone.

An invalid period does not produce an error: it is replaced with 30d. This applies to an unknown preset as well as to a malformed range (any format other than YYYY-MM-DD, a date that does not exist), a range that is too long, or one that ends in the future. So check the period you were served: every response except realtime carries period.from and period.to, and summary adds period.preset, which is custom for an accepted range and 30d after a replacement.

The previous period (previous in summary) has the same number of days and ends the day before the period starts. For month, it is therefore not the whole previous month, but as many days as the current month has had so far.

filters

filters takes the same filters as dashboard clicks, in dimension:value format, separated by ;:

?filters=country:FR;device:mobile
?filters=page:/pricing
?filters=channel:search;browser:Firefox
  • The dimensions you can filter on are marked in the table below. Any other dimension, including entry and exit, gets a 422 naming it: you receive an error rather than unfiltered numbers.
  • One value per dimension: when a dimension appears twice, the last one wins (?filters=device:mobile;device:desktop keeps only desktop). Different dimensions add up: only pageviews and events that match all of them are counted.
  • filters only accepts values of at most 500 characters: beyond that the value is dropped without an error, and it then does not appear in the response's filters.
  • The filters of summary carries only the filters ACTUALLY applied, not the request received: a dimension listed there did restrict the numbers next to it.
  • Closed vocabulary: channel is one of direct, search, social, referral, email or campaign; device is one of desktop, mobile, tablet or other.
  • As soon as a filter applies, metrics are recomputed from raw events, and both bounce_rate and avg_duration are null.
  • In breakdown, a filter on the ranked dimension is ignored for that ranking: ?dimension=country&filters=country:FR returns every country, so that the filter does not shrink the ranking down to its own value.

dimension (breakdown)

dimension What value counts Usable in filters
page Pageviews yes
entry Entries: sessions that started on this page no
exit Exits: sessions that ended on this page, computed the following night (the current day has none yet, except under a filter) no
referrer Sessions, by referring domain yes
channel Sessions, by acquisition channel yes
source Sessions, by utm_source value yes
medium Sessions, by utm_medium value yes
campaign Sessions, by utm_campaign value yes
country Pageviews, by country (two-letter ISO code) yes
region Pageviews, by region yes
browser Pageviews, by browser yes
os Pageviews, by operating system yes
device Pageviews, by device type yes
lang Pageviews, by language yes
event Occurrences, by event name yes

A dimension missing from this table returns 422. label is null for visits without a value, for example without a referring domain.

limit (breakdown): number of rows, 10 by default, bounded between 1 and 1,000.

Time series granularity

GET /timeseries returns one point per day in general, and one point per hour as soon as the period fits in a single local day (today, yesterday, or a custom: range whose two bounds are equal). The date field then takes the form YYYY-MM-DD HH:MM, in the site's local time, instead of YYYY-MM-DD.

A local day is not always 24 hours long. On the two clock changes of the year, the series therefore carries 23, 24 or 25 points: do not rely on a fixed count.

  • Night the clocks go back: 25 points. The replayed local hour (02:00 in Paris) yields two consecutive points carrying the same date, each with its own metrics. This format carries no UTC offset: locate those points by their position in the list, never by their label.
  • Night the clocks go forward: 23 points, the skipped local hour (02:00 in Paris) is absent. No point spills over into the next day.

The visitors of an hourly point are those who were active during that hour: someone present over two hours shows up in both, so hourly points do not add up to a daily total.

Response examples

summary

GET /summary?period=30d:

{
    "site": "mysite.com",
    "period": { "preset": "30d", "from": "2026-06-17", "to": "2026-07-16" },
    "filters": {},
    "kpis": { "pageviews": 9873, "visitors": 4210, "sessions": 4630, "events": 312, "bounce_rate": 47.2, "avg_duration": 74 },
    "previous": { "pageviews": 9145, "visitors": 3877, "sessions": 4288, "events": 290, "bounce_rate": 49.1, "avg_duration": 71 }
}
kpis Content
pageviews Pageviews
visitors Unique visitors of each day, summed over the period: someone who comes back the next day is counted twice
sessions Visits
events Measured events other than pageviews
bounce_rate Bounce rate as a percentage, from 0 to 100, rounded to one decimal place: share of sessions with a single measured pageview or event. null when there is no session at all, or as soon as a filter applies
avg_duration Average session duration, in whole seconds. null when there is no session at all, or as soon as a filter applies

previous carries the same keys for the previous period. The API computes no change: derive it from the two values yourself, and handle a previous value of zero or null separately.

GET /summary?period=7d&filters=device:mobile:

{
    "site": "mysite.com",
    "period": { "preset": "7d", "from": "2026-07-10", "to": "2026-07-16" },
    "filters": { "device": "mobile" },
    "kpis": { "pageviews": 1204, "visitors": 611, "sessions": 655, "events": 38, "bounce_rate": null, "avg_duration": null },
    "previous": { "pageviews": 1139, "visitors": 590, "sessions": 622, "events": 41, "bounce_rate": null, "avg_duration": null }
}

timeseries

One point per day, days without traffic included (they are zero).

GET /timeseries?period=custom:2026-07-14..2026-07-16:

{
    "period": { "from": "2026-07-14", "to": "2026-07-16" },
    "data": [
        { "date": "2026-07-14", "pageviews": 341, "visitors": 142, "sessions": 158, "events": 9 },
        { "date": "2026-07-15", "pageviews": 298, "visitors": 131, "sessions": 144, "events": 12 },
        { "date": "2026-07-16", "pageviews": 0, "visitors": 0, "sessions": 0, "events": 0 }
    ]
}

When the period fits in a single day (today, yesterday, or a range whose two bounds are equal), the series switches to hours: one point per hour of the local day, from 00:00 to 23:00 in the site's local time (23 or 25 points on clock-change days, see “Time series granularity”), and date takes the form YYYY-MM-DD HH:MM. The visitors of an hourly point are those active during that hour: someone present over two hours shows up in both, so the points do not add up to a daily total.

GET /timeseries?period=custom:2026-07-16..2026-07-16, the first three of the 24 points:

{
    "period": { "from": "2026-07-16", "to": "2026-07-16" },
    "data": [
        { "date": "2026-07-16 00:00", "pageviews": 4, "visitors": 3, "sessions": 3, "events": 0 },
        { "date": "2026-07-16 01:00", "pageviews": 1, "visitors": 1, "sessions": 1, "events": 0 },
        { "date": "2026-07-16 02:00", "pageviews": 0, "visitors": 0, "sessions": 0, "events": 0 }
    ]
}

breakdown

GET /breakdown?dimension=page&limit=3:

{
    "dimension": "page",
    "period": { "from": "2026-06-17", "to": "2026-07-16" },
    "data": [
        { "label": "/", "value": 3120 },
        { "label": "/pricing", "value": 1284 },
        { "label": "/docs", "value": 977 }
    ],
    "capped": {
        "any": true,
        "dimensions": [
            { "dimension": "page", "days": 2, "days_repaired": 1, "folded_values": 1406, "cap": 2000 }
        ]
    }
}

With no capping over the period, capped is {"any": false, "dimensions": []}.

realtime

GET /realtime:

{ "visitors": 3 }

Cardinality cap (capped)

Each site measures a bounded number of distinct values per day and per dimension. Beyond that, further values are counted together under a single label, ~autres: the detail is grouped, no visit is lost and the totals remain exact. breakdown reports this state in the capped key:

  • any is always present and dimensions is always a list: the schema does not vary with the state of the site, so no branching is needed to read it.
  • dimension is the capped dimension, not the one you asked for. entry and exit are grouped by the page cap; source and medium are grouped by the campaign cap.
  • days counts the capped local days in the period; cap is the highest threshold actually applied (it can vary if a raise is applied mid-period).
  • folded_values: null does NOT mean zero. The number of grouped values is only known after the nightly rebuild, which processes a day no earlier than D+2. null means "cap reached, count not known yet"; 0 means "day rebuilt, no value grouped". Showing 0 instead of null would tell your users they are losing no detail, which is false.
  • days_repaired says on how many of the days days the count is known. If days_repaired < days, folded_values is a partial sum, as in the example above.

capped only describes the dimension you asked for. It is also {"any": false, "dimensions": []} as soon as a filter applies to the ranking: the ranking is then read from raw events, where nothing is grouped.

summary and timeseries do not carry this key: they read no dimension and are insensitive to capping.

The dashboard CSV export carries the same indicator, but in the X-Cardinality-Capped HTTP header rather than in the file body: {"capped":false}, or {"capped":true,"dimension":"page","days":1,"days_repaired":1,"folded_values":2812,"cap":2000} scoped to the exported dimension only. The CSV body stays pure machine data, with no metadata row: no synthetic row can skew a total or break a fixed-arity parser.

This cap can be raised for your site: write to support.

PHP example

$response = json_decode(file_get_contents(
    'https://quietmetrics.dev/api/v1/stats/qm_pub_XXXX/summary?period=7d',
    false,
    stream_context_create(['http' => ['header' => "Authorization: Bearer YOUR_TOKEN\r\nAccept: application/json"]])
), true);

echo $response['kpis']['visitors'];

Errors

An error body is a JSON object whose message key describes the cause.

Code Meaning
401 Token missing, invalid, revoked or expired
403 The site is yours, but the token does not cover it, or your plan does not include the API (expired trial included)
403 Your account is suspended or awaiting approval: the API stays closed, whatever the plan
404 Unknown site, or one belonging to another account
422 Unknown breakdown dimension, or a filters dimension you cannot filter on
429 Too many requests: at most 120 per minute per token, and 300 per minute for the whole account, all tokens combined. The Retry-After header gives the wait time, in seconds

Best practices

  • Cache on your side: querying once an hour is more than enough. Be aware, though, that a closed day is not frozen for good: the nightly rebuild revisits capped days and can recompute their aggregates up to two days later. So plan for a short cache, or a re-query, on the last two days; beyond that, the values no longer move.
  • Read period in every response before displaying a figure: an invalid period does not produce an error, it is replaced with 30d.
  • One token per use case (one for the internal dashboard, one for the monthly report): revocation stays surgical, and two use cases do not compete for the same token's limit.
  • For a one-off export, every dashboard view can also be exported as CSV from the dashboard; no need to write code.

Can't find your answer?

Support replies on business days.