fivebar

Stats API

Copy page
View Markdown
Open with
Connect MCP
Cursor VS Code

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:

AddressReturns
/api/example.com/statsEverything the dashboard shows over a range, beside the period before
/api/example.com/sourcesOne pane in full, by its name, a page of rows at a time
/api/example.com/realtimeHow 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:

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:

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:

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"
}

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
}

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:

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