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:

ParameterMeaning
from, toA 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.
limitCap the rows in a breakdown (top pages, referrers…).
compare=trueOn 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.