Stats API
Every figure on a site’s dashboard can also be read as JSON, from an address under /api/ and the site’s domain.
Use it to put your figures where you already look, such as a team dashboard, a spreadsheet or a report your own script sends. It reads what the dashboard shows by the same code, so the two agree, and takes the same ranges and filters, so a view set up on the dashboard is a short step from a request. A script needs only a token made in Settings.
The addresses
Each site’s API is under https://fiveb.ar/api/ and its domain, written as in its dashboard’s
address:
| Address | Returns |
|---|---|
/api/example.com/stats | Everything the dashboard shows over a range, beside the period before |
/api/example.com/sources | One pane in full, by its name, a page of rows at a time |
/api/example.com/realtime | How many are on the site now |
Each answers a GET, and nothing else. They take the same ranges and filters as the dashboard, so an address copied from it asks for the same view.
Who it answers
The API answers:
- An API token, for a script: all your sites, or the ones you made it for, while they are on your Sites page.
- Your browser, while you’re signed in to fivebar: the sites on your Sites page.
- Anyone at all, for a site whose stats are public: see Public stats.
Any other site is not found. The API sends no CORS headers, so a page on another site can’t read it from its visitors’ browsers.
To reach your stats from an app such as Claude or an editor, connect it over MCP instead: see Connecting apps. The API doesn’t take an app’s MCP token.
Making a token
In Settings, under API tokens, make one. It reads all your sites, including ones you add later, unless you choose only some of them: a box to find a site helps when you have many. It takes a name of up to 50 characters if you like, such as what will use it, to tell your tokens apart. The token is shown once, as you make it: copy it then, and keep it as you would a password. You can have up to 20.
Sending a token
Send it in the Authorization header of every request, over https:
Authorization: Bearer fivebar_…It’s never read from the address, so it stays out of logs and browser histories. When the header is there, it alone decides: a browser’s sign-in beside it isn’t read. A request over plain http is sent on to https, but a token sent with it has already crossed the network readable, so if that ever happens, revoke it and make another.
What a token reads
A token reads all your sites, or the ones you chose for it, while they are on your Sites page, and any site whose stats are public, as anyone can. A site you delete is no longer read. Your tokens together can make 60 requests a minute.
Revoking a token
A token lasts until you revoke it in Settings, and anything using it stops at once. Settings shows when each was made and last used, so one nothing uses any more is easy to spot. Deleting your account revokes every token you made.
Parameters
Each address but realtime takes the dashboard’s own parameters, read by the same code:
- A date range, such as
range=last-monthorfrom=2026-09-01&to=2026-09-14, in the site’s own days. The last 7 days if left out. See Date ranges. f, a filter: a dimension, a colon and a value, such asf=source:Google. Repeat it for up to 6, and every one must hold. See Filters.dir, the folder the content drilldown opens, such asdir=/blog/.
Filters
A filter’s dimension is one of path, source, referrer,
country, region, city, device, browser,
os, utm_medium, utm_campaign, utm_content,
utm_term, asn, entry, exit, goal and
channel, or prop: and a custom property’s name, as in f=prop:plan:pro. A
value is matched exactly: a country is its two-letter code, such as f=country:GB, a region its name,
a network its AS number alone, such as f=asn:2856, and a channel its name as the Channels tab spells
it, such as f=channel:AI+Assistants. The simplest way to get one right
is to copy it, from the dashboard’s address once you have clicked it, or from the filter of a
pane’s row.
Parameters for a pane
A pane’s address also takes:
q, a search: only the rows whose label holds it, ignoring case, up to 100 characters. A country is found by its name or its code, a network by its name or its number, and a search term by its page too.limit, how many rows, 1 to 500, 100 if left out.offset, how many rows to skip, up to 10,000, for the pages after the first.trend=0, to leave out each row’s counts across the range.status, forerrorsalone, a status from 400 to 599, such asstatus=404: only the errors answered with it, as the Errors page shows them filtered by it. The answer says which asstatus, or null for every one.
What it cannot read
A parameter the API can’t read is never an error. A range it does not know is the last 7 days, a filter by a dimension it does not know, or past the sixth, is left out, and a number too big or too small is the nearest it takes.
What each returns
Every answer is JSON, error or not, and is sent with Cache-Control: no-store, so nothing on the
way keeps a copy.
Stats
/api/example.com/stats?range=last-week returns what the dashboard shows over last week. Shortened,
it looks like this:
{
"totals": { "pageviews": 1250, "visits": 610, "visitors": 540 },
"bucket": "day",
"series": [
{ "day": "2026-09-14", "n": 190, "visits": 92, "visitors": 81 }
],
"paths": [{ "label": "/", "n": 420 }],
"sources": [{ "label": "Google", "n": 540 }],
"counts": { "path": 57, "source": 12 },
"visit": { "visits": 604, "bounces": 263, "duration": 41860 },
"previous": {
"from": "2026-09-07", "to": "2026-09-13", "hour": null, "minute": null,
"totals": { "pageviews": 1102, "visits": 575, "visitors": 509 },
"visit": { "visits": 571, "bounces": 260, "duration": 37115 }
},
"range": { "key": "last-week", "from": "2026-09-14", "to": "2026-09-20", "timezone": "Europe/London" },
"source": "postgres"
}totalsare the pageviews, visits and visitors over the range. Visitors are visitor-days: see Metrics.seriesis the chart.bucketsays what each column is:hour,day,weekormonth. By the hour, each row is an hour with its pageviews asn; otherwise each is a day, with its pageviews asn, its visits and its visitors.paths,channels,sources,referrers,countries,regions,cities,networks,devices,browsers,osesand the fourutm_lists are each pane’s top 8, with their pageviews asn. A network’slabelis its AS number, and itsnamethe one the dashboard shows, or null where it has none. A channel is worked out from each visit’s source and tags: see Sources and campaigns.countssays how many values each has in all, and a pane’s own address returns the rest.visitis the visits that have ended, how many of them bounced and their seconds on screen: bounce rate isbouncesovervisits, and visit durationdurationovervisits.entriesandexitsare the Entry and Exit pages.content,properties,goals,outbound,downloads,searchesandengagementare the content drilldown, the custom properties, the Goals pane, and the Links, Site search and Engagement sections, each search term with itspage, the page it was searched from, andnone, its searches there that found nothing.searchPagesis how many pages the searches came from, andsearchTermshow many terms they are, each once however many pages it came from.weekis the Hours section:nhas a list for each day of the week, Monday first, of the pageviews in its 24 hours from midnight, anddayshow many of each hour the range held fromfrom, the first day it counted by the hour, so an hour’snover itsdaysis its average.speedhas the six measures, the status mix and the errors that the Speed and Errors pages show, witherrorTotalthe errors in all.previousis the period before, which each figure’s change is from: its dates, itstotalsand itsvisit. For a range that runs to today, its last day is cut at the same time of day, andhourandminutesay when; they are null where it counts whole. It is null for all time. See Metrics.rangeis the dates read, and the time zone they are the site’s days in.
Under a filter, what the dashboard leaves out is null or not there. Filtered by a goal, the answer is the
goal’s: its goal, totals of its conversions, its events and the visits its
conversion rate is of, and a series of conversions by day. See
Filters.
Panes in full
A pane’s address returns every row of it, a page at a time, as its full view lists them. The panes are
named as in their full views’ addresses: content-drilldown, top-pages,
entry-pages, exit-pages, channels, sources, referrers,
countries, regions, cities, networks, medium,
campaign, content, term, goals, devices,
browsers, operating-systems, outbound-clicks, file-downloads,
searches, errors, time-on-page, scroll-depth,
slowest-pages-lcp and slowest-pages-inp. A custom property’s is property-
and its name, such as property-plan, and the data an event goal was sent with is data-
and the data’s name, filtered by that goal.
/api/example.com/sources?range=last-week&limit=2&trend=0 returns:
{
"site": "example.com",
"facet": "sources",
"title": "Sources",
"dimension": "source",
"unit": "pageviews",
"range": { "key": "last-week", "from": "2026-09-14", "to": "2026-09-20", "days": 7 },
"filters": [],
"available": true,
"source": "postgres",
"q": "",
"limit": 2,
"offset": 0,
"total": 1250,
"count": 12,
"more": 10,
"rows": [
{ "value": "Google", "label": "Google", "n": 540, "share": 0.432, "filter": "source:Google" },
{ "value": "Direct", "label": "Direct", "n": 310, "share": 0.248, "filter": "source:Direct" }
],
"ms": 38
}unitis what each row’sncounts: pageviews for most panes, visits for entry and exit pages, conversions for goals and under a goal filter, and clicks, downloads, searches, page loads, readings or events for the rest.totalis what every row counts to, before any search, and a row’sshareits part of that.countis how many rows there are, or withqhow many match, andmorehow many come after this page.- A row’s
valueis as stored and itslabelas the dashboard shows it. Itsfilteris thefits link on the dashboard adds, or null where the pane’s rows aren’t links. Some panes add more, such as a country’scodeandname, a network’sasnandname, an entry page’sbouncesandduration, or a search term’spage,noneandnoneShare, the share of its searches that found nothing. - A search term is a row for each page it was searched from, as the dashboard lists them: its
valueandlabelare the term, and itspagethe page as stored, astop-pagesgives it, sof=path:and the page lists that page’s terms alone.pagesis how many pages the searches came from, andtermshow many terms the rows are, or withqthose that match, each once however many pages it came from. - With trends,
trendlists the range’s columns, a day, a week from Monday or a month each, and each row has its counts in them astrend. availableis false, with no rows, where the dashboard says the pane isn’t available under the filters.
On the site now
/api/example.com/realtime returns how many are
on the site now, as the top of its dashboard shows them:
{ "current": 3 }Examples
Each example reads your token from the FIVEBAR_TOKEN environment variable, so it’s never
written into the code.
curl
curl -H "Authorization: Bearer $FIVEBAR_TOKEN" "https://fiveb.ar/api/example.com/stats?range=last-month"A pane, such as the top 20 sources:
curl -H "Authorization: Bearer $FIVEBAR_TOKEN" "https://fiveb.ar/api/example.com/sources?range=last-month&limit=20"JavaScript
For Node 18 or later. Save it as stats.mjs, since it uses await outside a
function, and run node stats.mjs:
const res = await fetch('https://fiveb.ar/api/example.com/stats?range=last-month', {
headers: { Authorization: 'Bearer ' + process.env.FIVEBAR_TOKEN },
});
const data = await res.json();
if (!res.ok) throw new Error(res.status + ' ' + data.error);
console.log(data.range.from, data.range.to, data.totals);Python
With the standard library alone:
import json, os, urllib.error, urllib.request
req = urllib.request.Request(
'https://fiveb.ar/api/example.com/stats?range=last-month',
headers={'Authorization': 'Bearer ' + os.environ['FIVEBAR_TOKEN']},
)
try:
with urllib.request.urlopen(req) as res:
data = json.load(res)
except urllib.error.HTTPError as e:
raise SystemExit(str(e.code) + ' ' + json.load(e)['error'])
print(data['range']['from'], data['range']['to'], data['totals'])PHP
<?php
$context = stream_context_create(['http' => [
'header' => ['Authorization: Bearer ' . getenv('FIVEBAR_TOKEN')],
'ignore_errors' => true,
]]);
$body = file_get_contents('https://fiveb.ar/api/example.com/stats?range=last-month', false, $context);
$data = json_decode($body, true);
if (isset($data['error'])) {
fwrite(STDERR, $data['error'] . PHP_EOL);
exit(1);
}
print_r($data['totals']);ignore_errors lets PHP read an error’s answer rather than stopping at its status.
Paging through a pane
A pane gives up to 500 rows at a time. Ask for the next page with offset until
more is 0. offset goes no further than 10,000, so the loop stops there too: to read
past row 10,500, narrow the pane with a filter or q. In JavaScript, saved as
pages.mjs:
const url = 'https://fiveb.ar/api/example.com/top-pages?range=last-month&trend=0&limit=500';
const headers = { Authorization: 'Bearer ' + process.env.FIVEBAR_TOKEN };
const rows = [];
for (let offset = 0; offset <= 10000; offset += 500) {
const res = await fetch(url + '&offset=' + offset, { headers });
const page = await res.json();
if (!res.ok) throw new Error(res.status + ' ' + page.error);
rows.push(...page.rows);
if (page.more === 0) break;
}
console.log(rows.length + ' rows');Errors
An error has a status to match:
| Status | Answer | When |
|---|---|---|
| 401 | { "error": "invalid token" } | The Authorization header holds
no token of yours: it isn’t one, or the token is unknown or revoked. Its WWW-Authenticate header
says so too. |
| 403 | { "error": "https required" } | The address starts http://. The token went unencrypted, so revoke it in Settings, make another, and use https://. |
| 404 | { "error": "unknown site" } | The site doesn’t exist, is someone else’s, is private and you aren’t signed in, or isn’t one your token reads. All are answered alike. |
| 404 | { "error": "unknown facet" } | The address names no pane. The answer
also lists every pane’s name, as facets. |
| 405 | { "error": "method not allowed" } | A request other than a GET. |
| 429 | { "error": "too many requests" } | Your tokens have made 60 requests in a
minute. Wait a minute: Retry-After says how long. |
| 502 | { "error": "stats could not be read" } | The stats could not be read just now. Try again in a moment. |