# API reference

Every resource, the parameters it takes, the shape it answers with — plus errors, limits and what v1 promises.

Every endpoint here is a GET on /api/v1/<resource>, authenticated with Authorization: Bearer <your key>, and read-only. A key is scoped to the single site it was created under, so no request needs to name a site. Start at the API guide if you haven't made a key yet. Sending events instead of reading them is a different endpoint — see /docs/server-events.

## The response envelope

Every successful JSON response has the same three keys. Only data changes shape per resource; site and range echo what the request actually resolved to, which is the quickest way to check a range parameter did what you meant.

```
{
  "site": { "id": "YOUR_SITE_ID", "domain": "acme.com" },
  "range": { "since": "2026-08-24T09:12:00.000Z", "until": "2026-09-23T09:12:00.000Z" },
  "data": { … }
}
```

## Parameters every resource accepts

- days=N — how far back from now to report on. Defaults to 30, clamped to 1–365, and ignored if you also send from.
- from=&to= — an explicit range, any format Date can parse (ISO is safest). from wins over days; to defaults to now. An unparseable value is ignored rather than rejected, so a typo silently gives you the default — check the range in the response if a number looks wrong.
- format=csv — the same data as a CSV file, for resources made of rows. See each resource below for what one row is.

> **Three resources ignore the range** — revenue and goals always report on the last 30 days, and live always on the last 5 minutes. They take days/from/to without complaining and then ignore them — the range in the envelope is what you asked for, not what those three answered with.

## Resources

### GET /api/v1/site

Details of the site this API key belongs to.

- `format=csv` — not available: this resource isn't rows, so it answers 400.

Response data:
```
{
  "id": "YOUR_SITE_ID",
  "name": "Acme",
  "domain": "acme.com",
  "timezone": "Europe/London"
}
```

### GET /api/v1/stats

Headline numbers: visitors, pageviews, bounce rate, average session time, visitors online right now.

- `format=csv` — not available: this resource isn't rows, so it answers 400.

Response data:
```
{
  "visitors": 1284,
  "pageviews": 3190,
  "liveNow": 7,
  "avgSessionSeconds": 96,
  "bounceRatePct": 41,
  "primaryGoal": { "id": "cl…", "name": "Signup", "hits": 38, "ratePct": 3 }
}
```

### GET /api/v1/timeseries

Visitors and pageviews per bucket over the range. Optional granularity: day (default), hour or minute.

- `granularity` — day (default), hour or minute. Anything else falls back to day.
- `format=csv` — one row per bucket.

Response data:
```
{
  "granularity": "day",
  "points": [
    { "bucket": "2026-09-01T00:00:00.000Z", "visitors": 210, "pageviews": 540 }
  ]
}
```

### GET /api/v1/pages

Most-viewed pages (top 8) with their pageview counts.

- `format=csv` — one row per page.

Response data:
```
{
  "pages": [{ "path": "/pricing", "views": 812 }]
}
```

### GET /api/v1/referrers

Traffic sources (top 10) with their channel (direct, search, social, ai, referral) and visitor counts.

- `format=csv` — one row per source.

Response data:
```
{
  "referrers": [{ "channel": "search", "source": "google.com", "visitors": 214 }]
}
```

### GET /api/v1/countries

Visitors broken down by country.

- `format=csv` — one row per country.

Response data:
```
{
  "countries": [{ "countryCode": "US", "country": "United States", "visitors": 96 }]
}
```

### GET /api/v1/devices

Visitors broken down by device type.

- `format=csv` — one row per device type.

Response data:
```
{
  "devices": [{ "device": "desktop", "count": 742, "pct": 61 }]
}
```

### GET /api/v1/revenue

Attributed revenue totals plus breakdowns by referrer, country, device and UTM campaign.

- Ignores days/from/to — always reports on the last 30 days.
- `format=csv` — the byReferrer breakdown — the first row-shaped field in the response.

Response data:
```
{
  "totalRevenueCents": 184900,
  "refundedCents": 0,
  "netRevenueCents": 184900,
  "conversions": 23,
  "avgOrderValueCents": 8039,
  "avgDaysToConvert": 4.2,
  "revenuePerVisitorCents": 144.1,
  "currency": "usd",
  "byReferrer": [{ "referrer": "google.com", "revenueCents": 74900, "conversions": 9 }],
  "byCountry": [{ "country": "United States", "revenueCents": 99900, "conversions": 12 }],
  "byDevice": [{ "device": "desktop", "os": "macOS", "browser": "Chrome", "revenueCents": 120000 }],
  "byCampaign": [{ "campaign": "spring_launch", "revenueCents": 62000, "conversions": 7 }]
}
```

### GET /api/v1/goals

Configured goals with their completion counts and conversion rates.

- Ignores days/from/to — completions are counted over the last 30 days.
- `format=csv` — one row per goal.

Response data:
```
{
  "goals": [
    { "id": "cl…", "name": "Signup", "matchType": "event", "matchValue": "signup", "hits": 38, "ratePct": 3 }
  ]
}
```

### GET /api/v1/live

Visitors active in the last 5 minutes, with the page each is on right now.

- Ignores days/from/to — always the last 5 minutes, 20 visitors at most.
- `format=csv` — one row per live visitor.

Response data:
```
{
  "liveNow": 7,
  "visitors": [
    {
      "id": "v_9f2…",
      "path": "/pricing",
      "country": "United States",
      "countryCode": "US",
      "device": "desktop",
      "browser": "Chrome",
      "os": "macOS",
      "referrer": "google.com",
      "secondsAgo": 12
    }
  ]
}
```

### GET /api/v1/funnels

Every funnel you've built in the dashboard, with visitor counts and drop-off percentage at each step.

- Ignores days/from/to — steps are counted over the last 30 days, same window the dashboard's Funnels page uses.
- `format=csv` — one row per funnel — the steps column is nested JSON, not one CSV row per step.

Response data:
```
{
  "funnels": [
    {
      "id": "cl…",
      "name": "Signup to paid",
      "windowMinutes": null,
      "steps": [
        { "order": 0, "matchType": "event", "matchValue": "landing_view", "count": 900, "dropoffPct": 0 },
        { "order": 1, "matchType": "event", "matchValue": "signup", "count": 210, "dropoffPct": 76.7 },
        { "order": 2, "matchType": "event", "matchValue": "payment_completed", "count": 38, "dropoffPct": 81.9 }
      ]
    }
  ]
}
```

### GET /api/v1/visitor

One visitor's full event timeline, in order — look them up by our visitorId or by the externalId you passed to identify().

- `visitorId` — VisitTrack's own visitor id (the 'id' field from the live resource, or your dashboard URL).
- `externalId` — Your own user id, for a visitor you've already called identify(visitorId, externalId) on.
- Ignores days/from/to — always returns the visitor's whole history. Pass exactly one of visitorId or externalId.
- `format=csv` — not available: this resource isn't rows, so it answers 400.

Response data:
```
{
  "id": "cl…",
  "externalId": "user_482",
  "country": "United States",
  "firstSeenAt": "2026-08-01T10:00:00.000Z",
  "isReturning": true,
  "totalSessions": 4,
  "totalEvents": 19,
  "totalPageviews": 11,
  "firstVisit": { "path": "/pricing", "createdAt": "2026-08-01T10:00:00.000Z", "referrer": "google.com" },
  "timeline": [
    {
      "sessionId": "cl…",
      "startedAt": "2026-08-01T10:00:00.000Z",
      "referrer": "google.com",
      "steps": [{ "id": "cl…", "type": "event", "name": "signup", "path": "/signup", "createdAt": "2026-08-01T10:02:00.000Z" }]
    }
  ]
}
```

## Errors

Every error is JSON with an error key, and nothing else is ever added to a successful response to signal failure — status code and error are the whole protocol.

```
{ "error": "Missing Authorization: Bearer <api key> header" }
```

```
{ "error": "Invalid API key" }
```

Those two are deliberately not distinguishable from outside: a revoked key and a key that never existed answer identically, so nobody can use the API to test which old keys were real.

```
{
  "error": "Unknown resource 'visits'",
  "available": ["site", "stats", "timeseries", "pages", "referrers", "countries", "devices", "revenue", "goals", "live"]
}
```

```
{ "error": "'stats' has no rows to tabulate — drop format=csv for this resource." }
```

A row-shaped resource that happens to have no rows in the range answers the same 400 — an empty CSV has no header to write, so there's nothing honest to return. Widen the range or drop format=csv to get an empty list back as JSON instead.

## Rate limit

- 120 requests per minute, counted per key rather than per IP — two scripts sharing a key share the budget.
- Over the limit answers 429 with {"error":"Too many requests"} and a Retry-After header, in whole seconds. Wait that long rather than retrying immediately; a retry inside the window just spends another request.
- The window is a rolling one per key, so a burst clears on its own — no daily cap sitting behind it.
- Polling every endpoint once a minute uses a tenth of this. If you're anywhere near the limit you're probably polling for something you could ask for once with a wider range.

## Versioning

The v1 in the path is a real promise, kept deliberately narrow so it can be kept. While v1 exists:

- Resources keep their names and their URLs. One won't be renamed or removed under v1.
- A field that exists keeps its name, its type and its meaning. Amounts stay in cents, timestamps stay ISO strings in UTC, percentages stay percentages.
- The envelope stays site / range / data.
- New fields and new resources can appear at any time. Parse tolerantly — ignore keys you don't recognise, and don't assume a list's length or order is part of the contract.

A change that would break a client reading v1 the way above describes — removing or renaming a field, changing a unit, changing how authentication works — is what makes a v2. It would ship at /api/v2 alongside v1 rather than replacing it in place.

> **What we're not promising** — There's no deprecation window written down, because we haven't decided one — inventing a number here would be a promise nobody has committed to. If v1 is ever going away you'll hear it from us before it does, and this page will say the date. The rate limit above is operational, not part of the version contract, and can change.
