Analytics (alpha)

One line in your pages gives you their visits, without cookies, and a feedback button if you want one. Analytics is in alpha, on the web app only.

Plug a site

In the web app, open Analytics, add the domain, and paste the line it gives you into the <head> of every page. Subdomains count for their domain. Pages on localhost are not counted.

html
<script defer src="https://zenhook.dev/z.js" data-site="zs_your_site_key"></script>

To let an AI agent do it, use Copy the instructions for your AI on the site's setup: it writes the line, the events and the check for the agent.

With a token alone, an agent does it end to end: this call adds the site and returns its key, then it writes the line, the events and the goal below.

bash
curl -X POST -H "Authorization: Bearer $ZENHOOK_TOKEN" -H "Content-Type: application/json" \
  -d '{"domain":"example.com"}' "https://zenhook.dev/api/sites"

Custom events

Pageviews are recorded without code, single-page navigation included. Record the actions that matter where they happen:

javascript
window.zenhook?.track("signup", { plan: "pro" })

Goals

A goal is what counts as a success on the site: an event it sends (signup) or a page reached (/thanks, or /blog/* for everything under it). With a goal, the site's page shows its conversions and its rate, and every list gains a conversion column, so you see which source, country or device brings the people who do it. A goal is counted from the visits already stored, so its past is there the moment you add it. In the app, the site's page proposes goals from what the site already sends; an agent adds one in a call, with the site's domain or id:

bash
curl -X POST -H "Authorization: Bearer $ZENHOOK_TOKEN" -H "Content-Type: application/json" \
  -d '{"kind":"event","match":"signup"}' "https://zenhook.dev/api/sites/example.com/goals"

GET on the same address lists the goals and suggestions taken from the site's last 30 days; DELETE /api/sites/example.com/goals/:id stops counting one. Up to 10 goals per site. Then add goal=:id to the stats query: totals gains conversions and each row its conversions.

Feedback

Turn on Feedback for the site, then ask where and when it matters. From your own interface, one call sends the answer: your design, your moment, nothing of ours on the page. Each answer arrives as a New feedback event in your Feedback channel, with the reaction, the page, the device and the email if they left one, and notifies you like any event.

javascript
// A reaction in one tap is enough; a message and an email are optional.
const { ok, error } = await window.zenhook.sendFeedback({
  reaction: "up",
  message: "The export took three clicks too many.",
  email: "[email protected]",
});

The script is not required: the same request can be written by hand.

javascript
// The same request without the script, from a page of your site only.
await fetch("https://zenhook.dev/api/f", {
  method: "POST",
  body: JSON.stringify({ site: "zs_your_site_key", reaction: "up", message: "...", url: location.href }),
});

A server (an app's backend, a bot, a game server) has no page: it posts to the webhook URL of your Feedback channel, like any Zenhook event. The address is in the channel's settings. With this title and your domain as the source, it sits in the site's feedback beside what your pages send:

bash
curl -X POST "$ZENHOOK_FEEDBACK_URL" -H "Content-Type: application/json" \
  -d '{"title":"New feedback","message":"The export took three clicks too many.","source":"example.com","actor":{"email":"[email protected]"},"fields":[{"label":"Reaction","value":"down"}]}'

The contract, from a page. reaction is the one-tap answer, "up" or "down", shown as a thumb at the start of its line; message is 4,000 characters at most, and one of the two is enough; email is optional; url is the page's full address. The answer is 200 with { "ok": true }, or { "error": "..." } that says the cause: 400 a field is wrong or missing, 403 feedback is off for the site or the request does not come from one of its pages, 404 no site has this key, 429 too many. To test, send from a page on localhost: it is filed in your Feedback channel marked as a test, and answers { "ok": true, "test": true }.

What keeps it safe. The site key in a page is public by design, so it is not what protects you. The browser's origin must be the site's own domain, which another website cannot forge in its visitors' browsers. A script outside a browser can, so the amounts are bounded: an address sends 10 messages in 10 minutes and 20 a day, a site takes 200 a day, and a stream that keeps coming reaches your phone as one digest an hour. The webhook URL of the channel is a secret: keep it on your server, in ZENHOOK_FEEDBACK_URL.

Our own button is one more word on the line: data-feedback puts it at the bottom right. Any element carrying data-zenhook-feedback, or window.zenhook.feedback(), opens the same panel without the button. The panel takes the page's language (its html lang; data-lang on the script sets another) and its theme; data-theme="light" or "dark" forces one:

html
<script defer src="https://zenhook.dev/z.js" data-site="zs_your_site_key" data-feedback></script>
<!-- or, from your own button, with no button of ours: -->
<button data-zenhook-feedback>Feedback</button>

Query the numbers

The Analytics page shows the period at a glance. The Assistant answers questions about it ("which pages brought sign-ups this week?"). Scripts and AI agents use the API with a token from Settings, API tokens:

bash
curl -H "Authorization: Bearer $ZENHOOK_TOKEN" \
  "https://zenhook.dev/api/sites/stats?site=example.com&period=7d&dimensions=page,referrer"
json
{
  "site": "example.com",
  "from": "2026-09-22T18:00:00.000Z",
  "to": "2026-09-29T18:00:00.000Z",
  "totals": { "visitors": 412, "pageviews": 1290, "events": 37 },
  "series": [{ "at": "2026-09-23T00:00:00.000Z", "visitors": 58, "pageviews": 170 }],
  "breakdowns": {
    "page": [{ "value": "/", "visitors": 300, "count": 520 }],
    "referrer": [{ "value": "google.com", "visitors": 120, "count": 180 }]
  }
}

site is a domain or a site id. period is 24h, 7d, 30d or 90d, or give from and to dates. dimensions: page, referrer, country, device, browser, os, event, utm_source, utm_campaign, utm_content, campaign (source / ad, such as meta / spring-video). Filters: page (/pricing, /blog/* for everything under it, several with commas, ! before for everything but: !/app/*), referrer (direct for none), country, device, event, utm_source, utm_campaign, utm_content (the visitors who arrived by it, with every page they saw). limit: 1 to 50 rows per list. goal: a goal's id, for conversions in total and per row. A source is where a visitor's day on the site began: the pages after the first one never count as direct. A campaign is read from the utm_ tags of the page a visitor landed on, or from an ad's click id (Google, Microsoft, TikTok, X, LinkedIn, Reddit, Snapchat, Pinterest) when the link has no tags; the id itself is never kept.

PDF report

The site's page has a PDF report button: the figures of the period shown with their change from the period before, the visitors per day and every list with its share, to send to someone who never opens Zenhook. A script or an agent gets the same file with a token, for a rolling period or a calendar month:

bash
curl -H "Authorization: Bearer $ZENHOOK_TOKEN" -o report.pdf \
  "https://zenhook.dev/api/sites/report?site=example.com&from=2026-10-01&to=2026-11-01&tz=Europe/Paris"

period (24h, 7d, 30d, 90d) or from and to, tz for the days, goal=:id for its conversions, lang=en, fr, pt or de (the account's language by default).

Privacy

No cookie, and nothing in the browser unless the visitor opts out. A visitor is a hash of the address and the browser with a salt that changes every day and is never kept with the data, so a person cannot be followed from one day to the next. Bots are not counted. Parts of a path that look like a token, an id or an email are stored as :id or :email, and the query string is never kept. Visits are kept 13 months. Browsers that send Global Privacy Control or Do Not Track are not counted, and a link with data-zenhook-optout (or zenhook.optOut()) stops counting a visitor who objects. The site's setup gives the paragraph for its privacy page.