Consuming the API & building custom UI
Everything the built-in dashboard shows, it reads from a small HTTP API — and that API is yours. With a site's secret api key you can pull any number into your own page, a custom dashboard, a report, a Slack bot. There is no separate "API plan": reading analytics is the api key, and the dashboard is just one client of it.
The api key is read server-side, only. It can read all of a site's analytics, so it
must never reach a browser — no NEXT_PUBLIC_, no client component, no fetch from the
page. Every request below runs on a server: a route handler, a server component, a cron job.
See The two keys for why.
Making a request
Two things make a request: the endpoint URL, and the api key in the X-Zenith-API-Key
header. The key names the site, so you never pass a site id — a key reads exactly one site.
curl "https://zenith.example.com/api/stats/summary" \
-H "X-Zenith-API-Key: $ZENITH_API_KEY"Every endpoint under /api/stats takes the same query parameters:
| Parameter | Meaning |
|---|---|
from, to | A date (2026-07-01) or an RFC 3339 timestamp. A bare to date covers that whole day. Pass both or neither — omit both for the default recent window. |
limit | Cap the rows in a breakdown (top pages, referrers…). |
compare=true | On summary and timeseries, also return the previous equal-length period. |
from=2026-07-01&to=2026-07-31 is all of July — a bare to date is inclusive, so the last
day is not silently dropped.
Endpoints
Every one is a GET, takes the header above, and answers JSON. Each is shown as the request
you send and the response you get back.
Summary — the headline numbers
Request
curl "https://zenith.example.com/api/stats/summary?from=2026-07-01&to=2026-07-31&compare=true" \
-H "X-Zenith-API-Key: $ZENITH_API_KEY"Response
{
"pageviews": 48100, "visitors": 12847, "sessions": 15300,
"bounce_rate": 0.38, "avg_duration": 92.4, "views_per_visit": 3.14,
"previous": { "pageviews": 42900, "visitors": 10870, "sessions": 13200,
"bounce_rate": 0.41, "avg_duration": 88.1, "views_per_visit": 3.24 },
"change": { "pageviews": 12.1, "visitors": 18.2, "sessions": 15.9 }
}bounce_rate is 0–1, avg_duration is seconds. previous and change appear only with
compare=true; a change field is null where the previous period was zero.
Timeseries — traffic over time
Request
curl "https://zenith.example.com/api/stats/timeseries?from=2026-07-01&to=2026-07-07&granularity=day" \
-H "X-Zenith-API-Key: $ZENITH_API_KEY"Response
{
"granularity": "day",
"buckets": [
{ "ts": "2026-07-01T00:00:00Z", "pageviews": 1820, "visitors": 640 },
{ "ts": "2026-07-02T00:00:00Z", "pageviews": 1755, "visitors": 610 }
]
}granularity is hour | day | week | month and defaults to the range. Empty buckets are
filled with zeroes, so a quiet day is a point at zero, not a gap.
Pages — top, entry, and exit
Request
curl "https://zenith.example.com/api/stats/pages?limit=5" \
-H "X-Zenith-API-Key: $ZENITH_API_KEY"Response
{
"top": [{ "label": "/pricing", "visitors": 3102, "pageviews": 4200 }],
"entry": [{ "label": "/", "visitors": 5210, "pageviews": 5210 }],
"exit": [{ "label": "/checkout", "visitors": 210, "pageviews": 240 }]
}Referrers — sources and UTM
Request
curl "https://zenith.example.com/api/stats/referrers" \
-H "X-Zenith-API-Key: $ZENITH_API_KEY"Response
{
"sources": [{ "label": "news.ycombinator.com", "visitors": 1204, "pageviews": 1500 }],
"utm": {
"source": [{ "label": "newsletter", "visitors": 320, "pageviews": 410 }],
"medium": [], "campaign": [], "term": [], "content": []
}
}Geo — countries
Request
curl "https://zenith.example.com/api/stats/geo" \
-H "X-Zenith-API-Key: $ZENITH_API_KEY"Response
{ "countries": [{ "label": "UG", "visitors": 2, "pageviews": 5 }] }label is an ISO country code — resolve it to a name in your own UI.
Tech — devices, browsers, OS
Request
curl "https://zenith.example.com/api/stats/tech" \
-H "X-Zenith-API-Key: $ZENITH_API_KEY"Response
{
"devices": [{ "label": "desktop", "visitors": 8200, "pageviews": 31400 }],
"browsers": [{ "label": "Chrome", "visitors": 6100, "pageviews": 22800 }],
"os": [{ "label": "macOS", "visitors": 3400, "pageviews": 12900 }]
}Events — your custom events
The counts behind everything you send with track().
Request
curl "https://zenith.example.com/api/stats/events" \
-H "X-Zenith-API-Key: $ZENITH_API_KEY"Response
{ "events": [{ "name": "download", "count": 1204, "visitors": 980 }] }Add ?name=download to also get that event's property breakdown — the values you passed as
the second argument to track():
Request
curl "https://zenith.example.com/api/stats/events?name=download" \
-H "X-Zenith-API-Key: $ZENITH_API_KEY"Response
{
"events": [{ "name": "download", "count": 1204, "visitors": 980 }],
"props": [{ "key": "file", "value": "guide.pdf", "count": 812 }]
}Realtime — visitors right now
Request
curl "https://zenith.example.com/api/stats/realtime" \
-H "X-Zenith-API-Key: $ZENITH_API_KEY"Response
{ "visitors": 34, "window_seconds": 300 }Show a download count on the page
Here is the whole loop: fire the event on click, read its count on the server, render it into the page. Start with the button — this is the only part that runs in the browser, and it holds no key:
"use client";
import { track } from "zenith-analytics/client";
export function DownloadButton() {
return (
<a href="/guide.pdf" download onClick={() => track("download", { file: "guide.pdf" })}>
Download the guide
</a>
);
}Then read the count in a server component. The request carries the api key; because it runs on the server, the key never reaches the browser, and the number is baked into the HTML:
import { DownloadButton } from "@/components/download-button";
async function downloadCount(): Promise<number> {
const res = await fetch(`${process.env.ZENITH_URL}/api/stats/events`, {
headers: { "X-Zenith-API-Key": process.env.ZENITH_API_KEY! },
// Re-fetch at most every 5 minutes, not on every request.
next: { revalidate: 300 },
});
if (!res.ok) return 0;
const { events } = (await res.json()) as {
events: { name: string; count: number }[];
};
return events.find((e) => e.name === "download")?.count ?? 0;
}
export default async function GuidePage() {
const count = await downloadCount();
return (
<section>
<DownloadButton />
<p>Downloaded {count.toLocaleString()} times</p>
</section>
);
}Want the number to climb the instant someone clicks, without a reload? Keep the server-rendered count as the starting value and optimistically add one in local state on click. The next page load reconciles with the real total — and the api key still never leaves the server.
On the Pages Router, or any other framework, the shape is identical: fetch from a server
route — getServerSideProps, an API route, a server action — never the browser.
Building a whole dashboard
Nothing about the built-in dashboard is privileged; it calls exactly these endpoints. To build your own UI, fetch them server-side and render them however you like. A route handler that fans out to several at once is a clean base for a client-side dashboard to read from — your route holds the key, the browser only sees the numbers:
const base = process.env.ZENITH_URL;
const headers = { "X-Zenith-API-Key": process.env.ZENITH_API_KEY! };
export async function GET() {
const [summary, pages, referrers, events] = await Promise.all(
["summary", "pages", "referrers", "events"].map((p) =>
fetch(`${base}/api/stats/${p}?from=2026-07-01&to=2026-07-31`, { headers }).then((r) =>
r.json(),
),
),
);
return Response.json({ summary, pages, referrers, events });
}Your client components then fetch("/api/analytics") — same-origin, no key in sight — and
draw the charts and tables you want.
If instead you want to hand a client their dashboard on their own domain — password-gated, with no api key of your own to manage — that is the domain-native dashboard, which proxies these same endpoints for you. This page is for when you want to build the UI yourself.