Analytics HTTP API
The seven endpoints your site and your own tools use to read visitor analytics with a key.
On this page
Your visitor 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, 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.
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:
{
"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, 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. |
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
{
"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.
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 }.
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.
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.
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:
{ "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
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.
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.
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!"
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.csvJSON 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. 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.
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 covers the tab, the key and rotating it.
- Storage HTTP API is the same kind of API for your files.
- MCP tools reads the same numbers from an AI client.