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.

bash
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

FieldTypeDescription
sitestringRequired. Your site ID.
visitorstringRequired. Stable identifier for this browser.
typestringpageview (default), goal, or identify.
sessionstring?Session identifier; groups a single visit.
urlstring?Path and query string, e.g. /pricing?ref=x.
hostnamestring?Hostname the event happened on.
referrerstring?Full referrer URL; resolved to a named channel server-side.
utm_sourcestring?Overrides referrer-based attribution when present.
utm_mediumstring?Used to detect email/newsletter traffic.
utm_campaignstring?Campaign name, surfaced in the Campaign tab.
goalstring?Goal name. Required when type is goal.
metaobject?Arbitrary JSON attached to a goal.
traitsobject?Arbitrary JSON attached by identify.
screenstring?e.g. 1920x1080.
langstring?Browser language tag.
cookielessboolean?Marks the event as coming from the cookieless script.

Responses

StatusBodyMeaning
202{ "ok": true }Event accepted.
202{ "ok": true, "filtered": "bot" }Recognised as a bot — tallied in the bot report, excluded from analytics.
400{ "ok": false, "error": … }Malformed JSON, or missing site/visitor.
503{ "ok": false, "error": … }Backend not configured. Nothing is stored — check /api/health.

Country resolution

Country comes from CDN edge headers (x-vercel-ip-country, cf-ipcountry), not from the request body. Server-to-server calls that skip the edge will have no country attached.

GET /api/live

Public aggregate feed for the site's own live counter: unique visitors per minute for the last 30 minutes, plus a country split. Counts only — no visitor IDs or URLs.

json
{
  "total": 88,
  "buckets": [3, 2, 4, 3, …],   // 30 entries, oldest first
  "countries": [{ "code": "US", "visitors": 27 }],
  "generated_at": "2026-07-21T09:02:55.807Z"
}

Environment variables

VariableRequiredPurpose
NEXT_PUBLIC_SUPABASE_URLyesSupabase project URL.
NEXT_PUBLIC_SUPABASE_ANON_KEYyesPublic key used by the ingest route. Insert-only under RLS.
SUPABASE_SERVICE_ROLE_KEYyesServer-only key for dashboard reads and payment writes, which are RLS-closed to the anon key. Never expose it to the browser.
NEXT_PUBLIC_NUMBERHILL_SITE_IDoptionalSite ID this deployment tracks itself under.
n/aPayment webhook signing secrets are not environment variables. They are stored per site and pasted in through Dashboard → Settings → Payment webhooks.

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.

bash
# 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=90

Parameters

FieldTypeDescription
siteIdstringRequired. The site's UUID, as shown on the Websites page.
datasetstringpayments, sources, pages, countries, devices, goals, bots, ai-crawlers or daily. Defaults to payments.
daysnumberTrailing window, 1–731 days. Defaults to 30. Mutually exclusive with from/to.
from / tostringAn exact window as YYYY-MM-DD. Both days are included, read in the site's timezone. Must be given together.
Money columns come in two pairs: 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.