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
entryandexit, gets a422naming 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:desktopkeeps onlydesktop). Different dimensions add up: only pageviews and events that match all of them are counted. filtersonly 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'sfilters.- The
filtersofsummarycarries only the filters ACTUALLY applied, not the request received: a dimension listed there did restrict the numbers next to it. - Closed vocabulary:
channelis one ofdirect,search,social,referral,emailorcampaign;deviceis one ofdesktop,mobile,tabletorother. - As soon as a filter applies, metrics are recomputed from raw events, and both
bounce_rateandavg_durationarenull. - In
breakdown, a filter on the ranked dimension is ignored for that ranking:?dimension=country&filters=country:FRreturns 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:
anyis always present anddimensionsis always a list: the schema does not vary with the state of the site, so no branching is needed to read it.dimensionis the capped dimension, not the one you asked for.entryandexitare grouped by thepagecap;sourceandmediumare grouped by thecampaigncap.dayscounts the capped local days in the period;capis the highest threshold actually applied (it can vary if a raise is applied mid-period).folded_values: nulldoes NOT mean zero. The number of grouped values is only known after the nightly rebuild, which processes a day no earlier than D+2.nullmeans "cap reached, count not known yet";0means "day rebuilt, no value grouped". Showing0instead ofnullwould tell your users they are losing no detail, which is false.days_repairedsays on how many of thedaysdays the count is known. Ifdays_repaired < days,folded_valuesis 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
periodin every response before displaying a figure: an invalid period does not produce an error, it is replaced with30d. - 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.