Going further
API reference
The tracking script is a thin wrapper over one endpoint. If you need to send events from a server, a mobile app, or an environment where JavaScript isn't an option, post to it directly.
POST /api/collect
Accepts JSON, returns 202 on success. Requests are fire-and-forget: the script uses navigator.sendBeacon where available so a pageview survives the page unloading.
curl -X POST https://numberhill.com/api/collect \
-H "Content-Type: application/json" \
-d '{
"site": "YOUR_SITE_ID",
"visitor": "9f2c4a1b8e7d6c5f4a3b2c1d0e9f8a7b",
"type": "pageview",
"url": "/pricing",
"hostname": "example.com",
"referrer": "https://github.com/some/repo"
}'Request body
| Field | Type | Description |
|---|---|---|
| site | string | Required. Your site ID. |
| visitor | string | Required. Stable identifier for this browser. |
| type | string | One of pageview (default), goal, identify, engagement, outbound, download. Anything else is discarded — see below. |
| eid | string? | 8–32 hex characters, generated when the event is created. Sending the same eid twice for one site records it once, which is what makes a retry safe. |
| session | string? | Session identifier; groups a single visit. |
| url | string? | Path and query string, e.g. /pricing?ref=x. |
| hostname | string? | Hostname the event happened on. |
| referrer | string? | Full referrer URL; resolved to a named channel server-side. |
| utm_source | string? | Overrides referrer-based attribution when present. |
| utm_medium | string? | Detects email traffic, and marks a click as paid: cpc, ppc, paid_social and similar resolve to the paid form of the channel. |
| utm_campaign | string? | Campaign name, surfaced on the Campaigns page. |
| utm_term | string? | Search term a paid click was bought against. |
| utm_content | string? | Creative or placement identifier. |
| click_source | string? | Which ad-platform parameter carried the click: gclid, gbraid, wbraid, dclid, msclkid, ttclid, twclid, li_fat_id, irclickid, fbclid or epik. Any other value is ignored, and ignoring it also drops click_id. |
| click_id | string? | The click parameter's value. |
| goal | string? | Goal name. Required when type is goal. |
| meta | object? | Arbitrary JSON attached to a goal. Up to 4KB serialized. |
| traits | object? | Arbitrary JSON attached by identify. Same 4KB budget. |
| screen | string? | Physical display, e.g. 1920x1080. |
| viewport | string? | Browser window, same format. Preferred over screen for device class. |
| scroll_depth | number? | 0–100. Only on engagement. Outside that range it is stored as null rather than clamped. |
| engagement_ms | number? | Accumulated visible milliseconds on the page. Only on engagement. |
| target | string? | Destination URL. Only on outbound and download. |
| lang | string? | Browser language tag. |
| cookieless | boolean? | Marks the event as coming from a cookieless install. |
Every string field has a length ceiling and is truncated rather than rejected — losing the tail of a URL is recoverable, losing the visit is not. The exceptions are eid, screen and viewport, which must match their shape exactly: an over-long eid is discarded rather than shortened, because truncating it could make two different events collide and silently drop one.
Responses
| Status | Body | Meaning |
|---|---|---|
| 202 | { "ok": true } | Event accepted. |
| 202 | { "ok": true, "duplicate": true } | This eid was already recorded for this site. The row exists; nothing was written twice. Treat it as success — it is what lets a client retry safely. |
| 202 | { "ok": true, "filtered": "bot" } | Recognised as a bot — tallied in the bot report, excluded from analytics. |
| 202 | { "ok": true, "filtered": "unsupported type" } | A type outside the list above. Not stored. A 202 rather than a 400 so a misconfigured snippet never logs errors on a customer's site. |
| 400 | { "ok": false, "error": … } | Malformed JSON, or missing site/visitor. |
| 429 | { "ok": false, "error": … } | Too many events for this site this minute. The limit sits far above real traffic; a client seeing this should stop and retry later rather than immediately. |
| 503 | { "ok": false, "error": … } | Backend not configured. Nothing is stored — check /api/health. |
Location resolution
Country, region and city come from CDN edge headers (x-vercel-ip-country, x-vercel-ip-country-region, x-vercel-ip-city, or their Cloudflare equivalents), not from the request body, and no IP address is stored. Server-to-server calls that skip the edge will have no location attached.GET /api/live
Public aggregate feed for the site's own live counter: unique visitors per minute for the last 60 minutes, plus a country split. Counts only — no visitor IDs or URLs.
{
"total": 88,
"buckets": [3, 2, 4, 3, …], // 60 entries, oldest first
"countries": [{ "code": "US", "visitors": 27 }],
"generated_at": "2026-07-21T09:02:55.807Z"
}Environment variables
| Variable | Required | Purpose |
|---|---|---|
| NEXT_PUBLIC_SUPABASE_URL | yes | Supabase project URL. |
| NEXT_PUBLIC_SUPABASE_ANON_KEY | yes | Public key used for sign-in and session refresh only. It ships in the browser bundle, so it grants no access to any analytics table. |
| SUPABASE_SERVICE_ROLE_KEY | yes | Server-only key. Every read and every write — ingest included — goes through it, because the analytics tables are RLS-closed to the anon key. Never expose it to the browser. |
| NEXT_PUBLIC_NUMBERHILL_SITE_ID | optional | Site ID this deployment tracks itself under. |
| — | n/a | Payment webhook signing secrets are not environment variables. They are stored per site and pasted in through Dashboard → Settings → Payments. |
Limits
- Events are accepted asynchronously; expect a second or two before they appear.
- The live endpoint is uncached (
no-store) — poll it no more than a few times a minute. - Duplicate payments are collapsed on
(processor, external_id).
GET /api/export
Downloads a dataset as CSV for the window you ask for. Authenticated with your dashboard session, so it works from the browser you are signed in to — the buttons on the Overview, Revenue, Goals, Bots and AI pages are links to this endpoint with the range you have selected already applied.
# every payment in March, with its attributed channel
/api/export?siteId=SITE_UUID&dataset=payments&from=2026-03-01&to=2026-03-31
# channel performance for the last 90 days
/api/export?siteId=SITE_UUID&dataset=sources&days=90Parameters
| Field | Type | Description |
|---|---|---|
| siteId | string | Required. The site's UUID, as shown on the Websites page. |
| dataset | string | payments, sources, pages, countries, devices, browsers, campaigns, referrers, engagement, outbound, goals, bots, ai-referrals or daily. Defaults to payments. |
| days | number | Trailing window, 1–731 days. Defaults to 30. Mutually exclusive with from/to. |
| from / to | string | An exact window as YYYY-MM-DD. Both days are included, read in the site's timezone. Must be given together. |
reported_amount in the site's reporting currency, which is what every dashboard figure sums, and charged_amount in the currency the processor actually took — so the file reconciles against both the dashboard and the processor's own statement. Refunds carry a negative amount.