# Analytics HTTP API

Source: https://modulify.ai/docs/grow/analytics-http-api

The seven endpoints your site and your own tools use to read visitor analytics with a key.

Your [visitor analytics](https://modulify.ai/docs/grow/analytics) can be read from code as well as from the Analytics tab. It is a small, read-only REST API: every method is a `POST` with a JSON body and an analytics API key header. It answers with the same numbers the tab shows, because the tab is built by the same code, and like the tab it counts the published site only.

Your own address, and a copy of this reference with that address filled in, live in the editor's **Analytics** tab under **Settings**, in the **API access** section. Expand **Using the analytics API**, switch to the **External** tab, and use **Copy guide** to take the whole thing away as plain text. The examples on this page use `https://your-cdn-link/analytics`, and your site reads its own address from `process.env.MODULIFY_ANALYTICS_URL`.

The base address is your site's own CDN address followed by `/analytics`, such as `https://your-cdn-link/analytics`, once that address has been checked, and a direct address on the Modulify API until then. Both keep working, and a published site keeps the address it was published with until its next publish. The CDN address follows your [free subdomain](https://modulify.ai/docs/publish/your-web-address), and the old one stops working when the subdomain changes. After a change, copy the address from **API access** again into any outside tool that calls the API, and publish again so your site picks it up.

## Authentication

Send the key as an `X-Analytics-Key` header on every request.

```bash
curl -X POST https://your-cdn-link/analytics/stats \
  -H "X-Analytics-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"period":"30d"}'
```

Keys start with `mak_`. Your site reads its own key from `process.env.MODULIFY_ANALYTICS_KEY` and the base URL from `process.env.MODULIFY_ANALYTICS_URL`. Both are server side only. A missing, malformed or unknown key returns `401` with "A valid analytics API key is required in the X-Analytics-Key header!"

A key reads exactly one site's analytics and nothing else. It cannot turn collection off, clear data or change anything. Rotating it from the Analytics tab invalidates the old one immediately, so a request with the old key returns the same `401`.

The header is the only place a key is read. A key in the address or the query string is refused with `400` and "Send the analytics API key in the X-Analytics-Key header, never in the URL!", because an address can be recorded by any server it passes through. Treat that key as leaked and rotate it. A key in the JSON body or in an `Authorization` header is not read at all, so that request returns `401`.

### Server side only

Call the API from server code: an API route, `getServerSideProps`, `getStaticProps`, a script or another service. A browser cannot call it, because the request fails at its preflight, and a key placed in a page would hand all of your analytics to everyone who opens it.

## Response envelope

Every response except a CSV export has the same shape. This is `/stats` for the last 30 days:

```json
{
    "success": true,
    "message": "Analytics loaded successfully.",
    "data": {
        "range": { "period": "30d", "from": null, "to": null },
        "published": true,
        "visitors": 1284,
        "visits": 1630,
        "pageviews": 4112,
        "viewsPerVisit": 2.52,
        "bounceRate": 41,
        "visitDuration": 146
    },
    "code": 200
}
```

Treat a call as failed unless the HTTP status is ok **and** `success` is `true`. Read the payload from `data`. `published` says whether the site is live right now. Every response also carries a `version` field naming the API build, which you can ignore.

A number in `data` is always a real number. When the numbers cannot be read, the request fails with `502` or `503` instead of answering zeros, see [Errors](https://modulify.ai/docs/grow/analytics-http-api#errors), so a zero really means no visits.

## Ranges

`/stats`, `/timeseries`, `/breakdown` and `/overview` read their range from the same fields. Leave them all out for the last 7 days.

| Field | Value |
|---|---|
| `period` | `day`, `24h`, `7d`, `30d`, `90d`, `12mo` or `all`. Defaults to `7d`. |
| `from` and `to` | A custom range as two `YYYY-MM-DD` dates, sent together, both days included, at most 3,660 days apart. Leave `period` out, or send it as `custom`. |
| `filters` | Conditions that narrow every number, see [Filters](https://modulify.ai/docs/grow/analytics-http-api#filters). |

These are the periods every date picker in Modulify offers. `day` is today so far, from midnight UTC. `24h` is the current hour and the 23 before it. `7d`, `30d` and `90d` are the last 7, 30 and 90 days counting today, `12mo` is the current month and the 11 before it, and `all` is all time. Days start and end at midnight UTC, and every time the API returns is in UTC.

Every answer that reads a range echoes it back as `range`: `{ "period": "30d", "from": null, "to": null }` for a named period, and `{ "period": "custom", "from": "2026-09-01", "to": "2026-09-28" }` for a custom one.

A range the API cannot read returns `400`:

| Request | Message |
|---|---|
| `from` without `to`, or `to` without `from` | "Send both from and to, or neither!" |
| A `period` of `custom` with no dates | "A custom period needs both from and to!" |
| A named period and dates together | "Send either a period or from and to, not both!" |
| A date that is not a real `YYYY-MM-DD` date | "The from and to dates must be real dates written as YYYY-MM-DD!" |
| `from` after `to` | "The from date cannot be after the to date!" |
| Dates more than 3,660 days apart | "A custom range can span at most 3,660 days!" |
| Any other `period` | "The period must be one of day, 24h, 7d, 30d, 90d, 12mo, all!" |

### Filters

```json
{
    "period": "30d",
    "filters": [
        { "dimension": "country", "value": ["DE", "AT"] },
        { "dimension": "page", "operator": "contains", "value": "/blog/" }
    ]
}
```

| Field | Value |
|---|---|
| `dimension` | `page`, `source`, `country`, `browser`, `os` or `device` |
| `operator` | `is`, `isNot` or `contains`. Optional, defaults to `is`. |
| `value` | A text, or a list of up to 20. Each is at most 500 characters. A country is its two-letter code, such as `DE`. |

Up to 10 filters per request, and every number in the answer is narrowed to the visits that match all of them. With a list of values, `is` and `contains` match a visit that matches any of them, and `isNot` a visit that matches none. Country codes are upper-cased for you, so `de` works too.

More than 10 filters returns `400` with "The filters must be a list of at most 10 entries!" A filter that breaks the rules above returns `400` naming its position in the list, counting from 0.

## Metrics

| Metric | What it counts |
|---|---|
| `visitors` | Distinct people |
| `visits` | Sessions, so one person returning later counts twice |
| `pageviews` | Every page load, including repeat views in one visit |
| `viewsPerVisit` | Pageviews divided by visits, to two decimals |
| `bounceRate` | Percentage of visits that left after a single page |
| `visitDuration` | Average visit length, in seconds |

`/stats` returns all six unless `metrics` narrows it, for example `"metrics": ["visitors", "pageviews"]`. `/breakdown` returns `visitors` unless `metrics` asks for others from `visitors`, `visits`, `pageviews`, `bounceRate` and `visitDuration`, and ranks its rows by the first one in the list. `viewsPerVisit` is not offered there. `/timeseries` always returns all six on every point, and `/overview` all six in `totals`. A metric outside the allowed list returns `400` naming the allowed ones.

When a `page` filter is set, `viewsPerVisit` is worked out as pageviews divided by visits.

## Endpoints

| Method | Path | Body | Returns |
|---|---|---|---|
| POST | `/status` | `{}` | `{ enabled, collecting, installed, published, lastPublishedAt }` |
| POST | `/realtime` | `{}` | `{ visitors, published }` |
| POST | `/stats` | `{ period, from, to, metrics, filters }` | `{ range, published }` and the metrics |
| POST | `/timeseries` | `{ period, from, to, interval, filters }` | `{ range, published, interval, partialIndex, points }` |
| POST | `/breakdown` | `{ dimension, period, from, to, metrics, limit, page, filters }` | `{ range, published, dimension, page, limit, hasMore, rows }` |
| POST | `/overview` | `{ period, from, to, interval, limit, filters }` | `{ range, published, interval, totals, series, breakdowns, realtime, status, failed }` |
| POST | `/export` | `{ format }` | `{ period, published, pages, sources, countries, browsers, operatingSystems, devices }`, or a CSV file |

Every body field is optional except `dimension` on `/breakdown`, and an empty body counts as `{}`. A body that is not a JSON object returns `400` with "The request body must be a JSON object!" A field an endpoint does not take returns `400` naming it, for example "The field "metric" is not accepted here!"

### /status

Whether analytics is set up and collecting. It makes no lookup and is never cached.

```bash
curl -X POST https://your-cdn-link/analytics/status \
  -H "X-Analytics-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

| Field | Meaning |
|---|---|
| `enabled` | The **Collect visitor analytics** switch in the Settings sub-tab is on |
| `installed` | The tracking script shipped with the last publish |
| `published` | The site is live right now |
| `collecting` | `published` and `installed` are both `true`, so new visits are being counted |
| `lastPublishedAt` | When the site was last published, as an ISO timestamp, or `null` while it is not live |

### /realtime

How many people are on the published site right now, counted over the last five minutes, as `{ visitors, published }`.

```bash
curl -X POST https://your-cdn-link/analytics/realtime \
  -H "X-Analytics-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### /stats

The headline numbers for a range: `range` and `published`, plus the metrics you asked for, all six by default.

```bash
curl -X POST https://your-cdn-link/analytics/stats \
  -H "X-Analytics-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"period":"30d","metrics":["visitors","pageviews"]}'
```

### /timeseries

All six numbers per bucket over a range. `interval` sets the bucket size, and it must fit the range:

| Range | `interval` allowed | Default |
|---|---|---|
| `day`, `24h` | `hour` | `hour` |
| `7d`, `30d` | `hour`, `day` | `day` |
| `90d` | `day` | `day` |
| `12mo` | `day`, `month` | `month` |
| `all` | `month` | `month` |
| Custom, dates at most 1 day apart | `hour` | `hour` |
| Custom, up to 31 days apart | `hour`, `day` | `day` |
| Custom, up to 92 days apart | `day` | `day` |
| Custom, up to 366 days apart | `day`, `month` | `month` |
| Custom, longer | `month` | `month` |

An `interval` that does not fit returns `400` listing the ones that do.

```bash
curl -X POST https://your-cdn-link/analytics/timeseries \
  -H "X-Analytics-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"period":"7d","interval":"day"}'
```

The answer is `{ range, published, interval, partialIndex, points }`, and each point looks like this:

```json
{ "time": "2026-09-28T00:00:00.000Z", "visitors": 42, "visits": 51, "pageviews": 130, "viewsPerVisit": 2.55, "bounceRate": 38, "visitDuration": 95, "partial": true }
```

`time` is the start of the bucket in UTC as an ISO timestamp, such as `2026-09-28T13:00:00.000Z` for the hour from 13:00. `viewsPerVisit` is worked out per bucket to two decimals, and is `0` for a bucket without visits. Buckets in the future are dropped, except the one right after the current bucket. `partial` is `true` on the bucket still in progress and on that next one, so a half-finished hour never reads as a drop. `partialIndex` is the point where the Analytics tab starts its dashed line, and `-1` when every bucket is complete.

### /breakdown

The top values of one dimension, ranked. `dimension` is required: `page`, `source`, `country`, `browser`, `os` or `device`.

```bash
curl -X POST https://your-cdn-link/analytics/breakdown \
  -H "X-Analytics-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"dimension":"page","period":"30d","limit":10}'
```

`limit` is 1 to 100 rows, 8 by default, and `page` is 1 to 1,000, 1 by default. The answer is `{ range, published, dimension, page, limit, hasMore, rows }`. Each row is `{ value, ... }` with the metrics you asked for, where `value` is the path, source, browser, operating system or device. A country row's `value` is its two-letter code, and the row adds `name`, the full country name. Rows are sorted by the first metric, highest first, and rows that tie are sorted by `value`, so every page uses the same order. When `hasMore` is `true` there is another page, so send the same request with `page` one higher.

### /overview

Everything the Analytics tab shows, in one call.

```bash
curl -X POST https://your-cdn-link/analytics/overview \
  -H "X-Analytics-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"period":"24h"}'
```

Beside `range`, `published` and `interval`, the answer carries:

| Field | Meaning |
|---|---|
| `totals` | The six metrics for the range |
| `series` | `{ interval, partialIndex, points }`, exactly as `/timeseries` returns them |
| `breakdowns` | `page`, `source`, `country`, `browser`, `os` and `device`, each a list of `{ value, name, visitors }` rows, with `name` on country rows only |
| `realtime` | `{ visitors }`, the live count |
| `status` | The same fields as `/status` |
| `failed` | The parts that could not be loaded |

`limit` sets the rows per breakdown, 1 to 25, 8 by default.

A breakdown or the live count that could not be loaded comes back as `null` and is named in `failed`, as `realtime` or the dimension name, while everything else in the answer is still real. If the totals or the series cannot be loaded, the whole request fails. So does a request from a site over its limits, or one that arrives while analytics is busy, with the same `429` or `503` the other endpoints return.

`/overview` costs nine lookups but only one request against the per minute limit, so prefer it for a dashboard over calling each endpoint on its own.

### /export

All-time visitors and pageviews for every page, source, country, browser, operating system and device, up to 1,000 rows per list. It is the same data as **Export .csv** in the Settings sub-tab, and it needs a paid plan: on a Free workspace it returns `403` with "A paid plan is required to export!"

`format` is `json`, the default, or `csv`. Export always covers all time, so sending `period`, `from` or `to` returns `400` with "Export always covers all time, so period, from and to are not accepted!"

```bash
curl -X POST https://your-cdn-link/analytics/export \
  -H "X-Analytics-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"format":"csv"}' \
  -o my-site-analytics.csv
```

JSON answers `{ period: "all", published, pages, sources, countries, browsers, operatingSystems, devices }`, each list made of `{ name, visitors, pageviews }` rows, with countries by their full name. CSV answers with the file itself and no envelope, named after your site's address, such as `my-site-analytics.csv`. It holds the six lists one after another, each with a Visitors and a Pageviews column. A name that starts with `=`, `+`, `-`, `@`, a tab or a carriage return gets a leading `'`, so a spreadsheet shows it as text instead of running it as a formula.

## Sites that are not published yet

A site that has never been published has nothing to count. Every endpoint except `/status` still returns `200`, with zeros and empty lists, `published` set to `false` and the message "This site has not been published yet, so there is no analytics data." Such a request makes no lookup. `/export` still needs a paid plan first.

A site taken offline keeps answering with its history, with `published` set to `false`.

## Caching

Answers are cached, so repeating a request is cheap.

| Answer | Cached for |
|---|---|
| The live count | 15 seconds |
| A range that includes today | 60 seconds |
| A range entirely in the past | 10 minutes |
| `/export` | 5 minutes |

A successful answer carries `Cache-Control: private, max-age=<seconds>`, the time it has left in the cache. `/status`, errors, answers for a site never published and an `/overview` with anything in `failed` carry `Cache-Control: no-store`. Clearing analytics in the Analytics tab empties the cache at once, so the next request reads the empty history.

## Limits

| Limit | Value |
|---|---|
| Requests per minute, per site | 120 |
| Analytics lookups per hour, per site | 1,200 |
| Request body | 16 KB |
| Filters per request | 10 |
| Values per filter | 20 |
| Characters per filter value | 500 |
| Rows per `/breakdown` page | 100 |
| Rows per breakdown in `/overview` | 25 |
| Rows per list in `/export` | 1,000 |
| Custom range | 3,660 days |

Every request that carries a valid key counts toward the per minute limit, cached or not. Only the answers that are not cached yet count toward the hourly lookups:

| Endpoint | Lookups |
|---|---|
| `/status` | 0 |
| `/realtime`, `/stats`, `/timeseries`, `/breakdown` | 1 |
| `/export` | 6 |
| `/overview` | 9 |
| Any answer already cached | 0 |

The limits belong to the site, not the key, so rotating the key does not reset them. Over either one a request returns `429` with a `Retry-After` header giving the seconds to wait, repeated in `data.retryAfter`. AI clients connected over MCP have their own 1,200 lookups an hour for the site and never spend these, see [Rate limits and result size](https://modulify.ai/docs/mcp/tokens-and-scopes#rate-limits-and-result-size). Fetch once and reuse the answer rather than calling on every page view: a page built with `getStaticProps` and `revalidate` makes one request per rebuild, however many people visit.

## Errors

| Status | Cause | Message |
|---|---|---|
| `400` | The body is not JSON, a field is not accepted, or a value is out of range | Names the problem, for example "The field "metric" is not accepted here!" |
| `400` | The key was sent in the URL | "Send the analytics API key in the X-Analytics-Key header, never in the URL!" |
| `400` | The analytics service rejected the request | "Analytics could not answer this request:" followed by its reason |
| `401` | The key is missing, malformed, unknown or rotated | "A valid analytics API key is required in the X-Analytics-Key header!" |
| `403` | `/export` on a Free workspace | "A paid plan is required to export!" |
| `413` | The body is over 16 KB | "The request body is too large!" |
| `429` | More than 120 requests in a minute | "This site made more than 120 analytics requests in a minute. Wait and try again!" |
| `429` | All 1,200 lookups used in the hour | "This site used its 1,200 analytics lookups for the hour. Wait and try again!" |
| `502` | The numbers could not be read | "Analytics could not be loaded right now. Try again shortly!" |
| `503` | Analytics is receiving too many requests | "Analytics is receiving too many requests right now. Try again shortly!" |
| `503` | Analytics is busy | "Analytics is busy right now. Try again shortly!" |
| `500` | Something unexpected went wrong while checking the key | "Something went wrong while authorizing the analytics request!" |
| `500` | Something unexpected went wrong while loading the numbers | "Something went wrong while loading analytics!" |

`429`, `502` and `503` are worth retrying after a pause. `429` and `503` set a `Retry-After` header with the seconds to wait, repeated in `data.retryAfter`. Never retry in a tight loop, because every retry counts toward the per minute limit.

## From your site

Sites created from the Modulify starter ship with a server only helper at `src/lib/modulify/analytics`, so you rarely call these endpoints by hand. It has one function per endpoint: `getStatus()`, `getRealtimeVisitors()`, which returns the count as a plain number, `getStats(options)`, `getTimeseries(options)`, `getBreakdown(dimension, options)`, `getOverview(options)` and `exportAnalytics(options)`, which returns the CSV as text when `format` is `csv`. The options are the body fields above.

```typescript
import { getBreakdown, getStats } from '@/lib/modulify/analytics'

export const getStaticProps = async () => {
    try {
        const stats = await getStats({ period: '30d' })
        const pages = await getBreakdown('page', { period: '30d', limit: 5 })

        return { props: { visitors: stats.visitors, topPages: pages.rows }, revalidate: 300 }
    }

    catch {
        return { props: { visitors: null, topPages: [] }, revalidate: 60 }
    }
}
```

That page asks at most once every five minutes however many people open it, and it falls back on ANY error, so a missing key or a busy moment never fails the build.

Import it only from API routes, `getServerSideProps`, `getStaticProps` or other server code. It throws in the browser by design.

Every function throws an `AnalyticsError` when the API refuses or fails the request. It carries `message`, `status`, the HTTP status the API answered or `0` when the API could not be reached or did not answer within 30 seconds, `data`, what the API sent with its refusal, `retryAfter`, the seconds to wait when the API said so, and `retryable`, which is `true` for `0`, `429`, `502` and `503`. Misusing the helper itself, an import from the browser, an option a function does not take, or a site the key has not reached yet, throws a plain `Error` instead.

The key and the address reach the preview when it next starts, and the published site on its next publish. Until then every call throws that plain `Error`. A site created before the helper existed has no `src/lib/modulify/analytics.ts`, so ask in chat for something that reads your analytics and the AI adds it first. The **Modulify** tab under **Using the analytics API** has an **Add it with AI** button that drops a ready made prompt into the chat for you to review and send.

## Next

- [Visitor analytics](https://modulify.ai/docs/grow/analytics) covers the tab, the key and rotating it.
- [Storage HTTP API](https://modulify.ai/docs/data/storage-http-api) is the same kind of API for your files.
- [MCP tools](https://modulify.ai/docs/mcp/tools#analytics) reads the same numbers from an AI client.