How to call gghstats from scripts, Grafana, or your own frontend (React, Svelte, β¦).
Normative contracts (status codes, sync rules): SPEC.md Β§2βΒ§3.
Operator env table: README.md.
As of v1.0.0.
export BASE=http://127.0.0.1:8080
export TOKEN=your-api-token # must match GGHSTATS_API_TOKEN
# Liveness (no auth)
curl -sS "$BASE/api/v1/healthz"
# {"status":"ok"}
# List repos (auth required)
curl -sS -H "x-api-token: $TOKEN" "$BASE/api/repos"API-only backend (no HTML dashboard):
export GGHSTATS_API_ONLY=true
export GGHSTATS_API_TOKEN=your-api-token
# Prefer an explicit browser origin when the SPA talks to the API directly:
# export GGHSTATS_CORS_ORIGINS=https://app.example.com
gghstats serve| Situation | Behavior |
|---|---|
GGHSTATS_API_TOKEN unset |
Authenticated JSON routes return 404 (API disabled). |
| Token set, header missing/wrong | 401 {"error":"unauthorized"} |
| Token set, header matches | Request proceeds |
Send the token on every protected call:
x-api-token: your-api-tokenPublic without token: GET /api/v1/healthz, GET /metrics (unless disabled), and badges when GGHSTATS_BADGE_PUBLIC=true (default).
The server filters report data before it is rendered or serialized. A repository
that is excluded by its stored report policy, or hidden by the default
private/unknown policy, is absent from lists, totals, exports, charts, H2H,
Featured, badges, metrics, and sitemap. Direct repo/badge/API paths return the
same 404 as an unknown repository; clients must not use a 404 to infer
whether a repository exists locally. See README report visibility
for policy precedence and GGHSTATS_REPORT_PRIVATE.
Browser SPAs: do not put GGHSTATS_API_TOKEN in a public bundle. Use a BFF/proxy that adds x-api-token, or restrict CORS. With GGHSTATS_API_ONLY and open CORS (*), serve logs a startup warning.
A matching x-api-token also bypasses the IP whitelist on protected paths (token is still validated).
| Env | Meaning |
|---|---|
GGHSTATS_CORS_ORIGINS empty |
Access-Control-Allow-Origin: * on authenticated JSON success (compat) |
| Comma-separated list | Echo Origin when it matches; omit the header on a mismatched Origin; if the request has no Origin (curl / server-to-server), set the header to the first configured origin |
Applies to authenticated JSON handlers (repos, traffic, dogfood, sync, β¦), not to healthz/badges.
| HTTP | Body (typical) | When |
|---|---|---|
| 401 | {"error":"unauthorized"} |
Bad/missing x-api-token |
| 404 | {"error":"not_found"} or plain 404 |
Unknown repo, or API disabled (no token configured) |
| 400 | {"error":"β¦"} |
Bad query/path (e.g. H2H missing a/b) |
| 409 | {"error":"sync_in_progress",β¦} |
Sync already running |
| 500 | {"error":"β¦"} |
Database / internal |
| Method | Path | Auth | Role |
|---|---|---|---|
| GET | /api/v1/healthz |
No | Liveness |
| GET | /api/v1/badge/{owner}/{repo} |
Optional | SVG badge |
| GET | /api/repos |
Yes | Index list + KPIs |
| GET | /api/v1/charts/index-clones |
Yes | Index clones chart series |
| GET | /api/v1/repos/{owner}/{repo} |
Yes | Repo summary + momentum |
| GET | /api/v1/repos/{owner}/{repo}/traffic |
Yes | Clones/views time series (dense=1 / download=1 optional) |
| GET | /api/v1/repos/{owner}/{repo}/stars |
Yes | Star history |
| GET | /api/v1/repos/{owner}/{repo}/popular |
Yes | Referrers + paths (14d) |
| GET | /api/v1/h2h |
Yes | Head-to-head compare |
| GET | /api/v1/featured |
Yes | Featured showcase list (metadata) |
| GET/POST | /api/v1/sync |
Yes | Sync status / trigger |
| GET | /{owner}/{repo}/traffic.json |
If token set | Chart-aligned dense download (HTML; report-scoped) |
| GET | /metrics |
No* | Prometheus |
* Off with GGHSTATS_METRICS=false.
Use this when rebuilding the dashboard in another UI.
| UI surface | Calls |
|---|---|
| Index | GET /api/repos (+ optional sort/dir/q/page) and GET /api/v1/charts/index-clones |
| Repo page | GET /api/v1/repos/{o}/{r}, β¦/traffic?days=365 (or 30; optional dense=1), β¦/stars, β¦/popular; HTML download GET /{o}/{r}/traffic.json |
| H2H | GET /api/v1/h2h?a=owner/a&b=owner/b&w=7d |
| Featured | GET /api/v1/featured (+ optional sort/dir/q/page/per_page) |
| Sync button | POST /api/v1/sync or POST /api/v1/sync?repo=owner/name; poll GET /api/v1/sync |
curl -sS "$BASE/api/v1/healthz"{"status":"ok"}List report-visible repositories with aggregate KPIs (dogfood for the index).
items, total_count, and every total exclude repositories hidden by report
policy or inherited private/unknown visibility.
Query
| Param | Default | Notes |
|---|---|---|
sort |
total_views |
name, stars, forks, total_views, total_clones, clones_1d, clones_7d, clones_30d |
dir |
desc |
asc or desc |
q |
(empty) | Case-insensitive substring on owner/repo name |
page, per_page |
β | Pagination only if either is present. Default per_page=25, max 100. Without them, all matching items are returned (compat). |
curl -sS -H "x-api-token: $TOKEN" \
"$BASE/api/repos?sort=total_clones&dir=desc&q=hrodrig&page=1&per_page=25"Response (200) β illustrative:
{
"total_count": 2,
"total_stars": 120,
"total_forks": 8,
"total_views": 9000,
"total_clones": 1500,
"sort": "total_clones",
"dir": "desc",
"q": "hrodrig",
"page": 1,
"per_page": 25,
"total_pages": 1,
"items": [
{
"name": "hrodrig/gghstats",
"description": "Self-hosted GitHub traffic stats",
"stars": 100,
"forks": 5,
"watchers": 100,
"issues": 2,
"prs": 1,
"fork": false,
"archived": false,
"total_views": 5000,
"total_uniques": 800,
"total_clones": 900,
"clone_uniques": 200,
"clones_1d": 12,
"clones_7d": 80,
"clones_30d": 300
}
]
}Aggregated daily clones across the same report scope and filter as
/api/repos (sort/dir/q). No pagination. It is an aggregate, not a
coverage audit: use a repository traffic response when a partial GitHub window
would matter.
curl -sS -H "x-api-token: $TOKEN" \
"$BASE/api/v1/charts/index-clones?q=hrodrig"{
"count": 90,
"series": [
{"date": "2026-04-01", "count": 40, "uniques": 10}
],
"sort": "total_views",
"dir": "desc",
"q": "hrodrig"
}Window is capped (~120 days ending at the newest clone date in the filtered set).
Repo summary + clone momentum (same idea as the HTML repo page).
curl -sS -H "x-api-token: $TOKEN" \
"$BASE/api/v1/repos/hrodrig/gghstats"{
"repo": {
"name": "hrodrig/gghstats",
"description": "β¦",
"stars": 100,
"forks": 5,
"watchers": 100,
"issues": 2,
"prs": 1,
"fork": false,
"archived": false,
"total_views": 5000,
"total_uniques": 800,
"total_clones": 900,
"clone_uniques": 200,
"clones_1d": 12,
"clones_7d": 80,
"clones_30d": 300
},
"momentum_7d": 0.15,
"momentum_30d": -0.05,
"momentum_7d_pct": "+15%",
"momentum_30d_pct": "-5%"
}Unknown or non-report-visible repo β 404 {"error":"not_found"}.
Daily clones and views.
| Query | Default | Notes |
|---|---|---|
days |
30 |
UTC rolling window inclusive of today. 0 = all stored days. Max 3660. |
dense |
(off) | 1 = chart-aligned series: every UTC day in [from,to]; unknown days use null count/uniques; adds "dense": true. Default remains sparse (omitted days). |
download |
(off) | 1 = dense payload + Content-Disposition attachment (gghstats-{owner}-{repo}-traffic-YYYYMMDD.json). |
curl -sS -H "x-api-token: $TOKEN" \
"$BASE/api/v1/repos/hrodrig/gghstats/traffic?days=30"Sparse example:
{
"name": "hrodrig/gghstats",
"days": 30,
"from": "2026-06-23",
"to": "2026-07-22",
"clones": [
{"date": "2026-07-01", "count": 10, "uniques": 4}
],
"views": [
{"date": "2026-07-01", "count": 50, "uniques": 20}
],
"clones_freshness": {
"metric": "clones",
"status": "fresh",
"fetched_at": "2026-07-22T12:00:00Z",
"latest_observed_day": "2026-07-21",
"latest_completed_utc_day": "2026-07-21",
"missing_completed_days": []
},
"views_freshness": {
"metric": "views",
"status": "missing",
"fetched_at": "2026-07-22T12:00:00Z",
"latest_observed_day": "2026-07-21",
"latest_completed_utc_day": "2026-07-21",
"missing_completed_days": ["2026-07-20"]
}
}Dense dogfood (dense=1 or download=1):
curl -sS -H "x-api-token: $TOKEN" \
"$BASE/api/v1/repos/hrodrig/gghstats/traffic?days=3&dense=1"{
"name": "hrodrig/gghstats",
"days": 3,
"from": "2026-07-20",
"to": "2026-07-22",
"dense": true,
"clones": [
{"date": "2026-07-20", "count": null, "uniques": null},
{"date": "2026-07-21", "count": 0, "uniques": 0},
{"date": "2026-07-22", "count": 4, "uniques": 2}
],
"views": [],
"clones_freshness": {},
"views_freshness": {}
}clones_freshness and views_freshness are independent. Their status is
fresh, delayed, missing, failed, or never; a failed metric also has
an error field. latest_completed_utc_day is UTC yesterday, so today never
counts as missing. A successful response's observed coverage span is its actual
earliest-to-latest returned UTC date: a response not reaching yesterday is
delayed, while missing means an absent day inside that span. Missing or
unconfirmed calendar days are omitted by the default sparse API (not
zero-filled): an explicit count: 0 is confirmed zero traffic, while absence is
unknown. Cached rows omitted by the latest response inside its coverage span are
not returned as confirmed traffic. The HTML detail chart and dense JSON keep all
UTC dates and send unknown values as null so they render as gaps.
HTML download: GET /{owner}/{repo}/traffic.json returns the same dense
attachment as download=1. Report-scoped (excluded β 404). Requires
x-api-token only when GGHSTATS_API_TOKEN is set; if the token is unset,
the download is public like other HTML report surfaces. The repo page button
uses fetch + session token when auth is required.
Cumulative star history (date, total).
curl -sS -H "x-api-token: $TOKEN" \
"$BASE/api/v1/repos/hrodrig/gghstats/stars"{
"name": "hrodrig/gghstats",
"stars": [
{"date": "2026-01-15", "total": 10},
{"date": "2026-03-01", "total": 42}
]
}Top referrers and paths for the last 14 UTC days (same window as the HTML repo page).
curl -sS -H "x-api-token: $TOKEN" \
"$BASE/api/v1/repos/hrodrig/gghstats/popular"{
"name": "hrodrig/gghstats",
"days": 14,
"referrers": [
{"name": "github.com", "count": 100, "uniques": 40}
],
"paths": [
{"name": "/", "count": 80, "uniques": 30}
]
}Head-to-head scores and chart series.
| Query | Required | Notes |
|---|---|---|
a |
yes | owner/repo |
b |
yes | owner/repo (must differ from a) |
w |
no | 7d (default), 30d, or total |
curl -sS -H "x-api-token: $TOKEN" \
"$BASE/api/v1/h2h?a=hrodrig/gghstats&b=hrodrig/pgwd&w=7d"{
"a": "hrodrig/gghstats",
"b": "hrodrig/pgwd",
"interval": "7d",
"result": {
"interval": "7d",
"repo_a": "hrodrig/gghstats",
"repo_b": "hrodrig/pgwd",
"score_a": 58,
"score_b": 42,
"delta_pct": 16,
"leads_a": true,
"rows": [
{
"key": "clones_7d",
"label": "Clones (7d)",
"weight_pct": 50,
"value_a": 80,
"value_b": 40,
"leads_a": true
}
],
"suggest": {
"confidence": "medium",
"rationale": "β¦",
"show": true
}
},
"charts": {
"repoA": "hrodrig/gghstats",
"repoB": "hrodrig/pgwd",
"showMomentum": true,
"cloneLabels": ["2026-07-16", "2026-07-17"],
"clonesA": [10, 12],
"clonesB": [5, 6],
"viewLabels": ["2026-07-16", "2026-07-17"],
"viewsA": [40, 44],
"viewsB": [20, 22],
"momentumLabels": ["2026-07-16"],
"momentumA": [0.1],
"momentumB": [-0.05]
}
}Scores are shares 0β100 that sum to 100 (same formula as the HTML H2H page).
Featured showcase list (dogfood for HTML /featured). Metadata only β no
traffic clones/views. The list and total_count follow the Featured catalog,
not report visibility.
| Param | Default | Notes |
|---|---|---|
sort |
sort |
Display/insertion order (sort), name, or stars (upstream stars) |
dir |
asc |
asc or desc |
q |
(empty) | Case-insensitive substring on name / upstream_full_name |
page |
1 |
1-based |
per_page |
25 |
Max 100 |
curl -sS -H "x-api-token: $TOKEN" \
"$BASE/api/v1/featured?sort=stars&dir=desc&page=1&per_page=25"{
"total_count": 1,
"sort": "stars",
"dir": "desc",
"q": "",
"page": 1,
"per_page": 25,
"total_pages": 1,
"items": [
{
"name": "hrodrig/awesome-readme",
"sort": 0,
"upstream_full_name": "matiassingers/awesome-readme",
"upstream_description": "A curated list of awesome READMEs",
"upstream_stars": 12000,
"fork": true,
"parent_full_name": "matiassingers/awesome-readme",
"meta_updated_at": "2026-08-22T12:00:00Z"
}
]
}Empty catalog β items: [], total_count: 0.
# Full sync (respects GGHSTATS_FILTER)
curl -sS -X POST -H "x-api-token: $TOKEN" "$BASE/api/v1/sync"
# 202 {"status":"started","scope":"all"}
# Single repo
curl -sS -X POST -H "x-api-token: $TOKEN" \
"$BASE/api/v1/sync?repo=hrodrig/gghstats"
# 202 {"status":"started","scope":"repo","repo":"hrodrig/gghstats"}
# Status
curl -sS -H "x-api-token: $TOKEN" "$BASE/api/v1/sync"Example status:
{
"running": false,
"scope": "",
"repo": "",
"last_started_at": "2026-07-22T12:00:00Z",
"last_finished_at": "2026-07-22T12:01:30Z",
"last_error": ""
}Only one sync at a time β 409 if already running.
SVG for README embeds. Public by default.
curl -sS "$BASE/api/v1/badge/hrodrig/gghstats?metric=clones" -o badge.svg| Query | Values | Default |
|---|---|---|
metric |
clones, clones_30d, views, stars |
clones |
style |
flat, flat-square |
flat |
label |
custom left text | metric name |
Set GGHSTATS_BADGE_PUBLIC=false to require x-api-token (breaks raw GitHub image embeds without a proxy). A non-report-visible repository returns 404 even when badges are public.
const BASE = import.meta.env.VITE_GGHSTATS_BASE; // e.g. https://stats.example.com
// Prefer a same-origin BFF that injects x-api-token β do not ship the token to the browser.
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
Accept: "application/json",
...(init?.headers ?? {}),
// Only if calling the API from a trusted server:
// "x-api-token": process.env.GGHSTATS_API_TOKEN!,
},
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return res.json() as Promise<T>;
}
const index = await api<{ items: { name: string }[] }>("/api/repos?sort=total_clones&dir=desc");
const repo = await api(`/api/v1/repos/${owner}/${name}`);
const h2h = await api(`/api/v1/h2h?a=${a}&b=${b}&w=7d`);- SPEC.md β normative HTTP + sync contracts
- README.md β install, env, HTML UI
- plan-v0.11.x.md β API-only band scope