API & MCP Reference
Analytics Platform is a privacy-focused alternative to Google Analytics. Everything you can do in the dashboard you can also do programmatically: read traffic, revenue, retention and Web Vitals data, and manage sites, goals, funnels, segments, alerts, webhooks, annotations and scheduled reports.
There are two ways in. A conventional REST API under /api/v2, and a remote MCP server that exposes the same capabilities as 55 tools your AI assistant can call directly. Both use the same API keys and the same permission rules.
REST base URL
https://analytics.appfor.you/api/v2MCP endpoint
https://analytics.appfor.you/api/mcpYour first request
# 1. Create a key in the dashboard, then export it export ANALYTICS_API_KEY="ap_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # 2. Find the sites you can access curl -H "Authorization: Bearer $ANALYTICS_API_KEY" \ "https://analytics.appfor.you/api/v2/sites" # 3. Pull last month's headline numbers for one of them curl -H "Authorization: Bearer $ANALYTICS_API_KEY" \ "https://analytics.appfor.you/api/v2/sites/SITE_ID/stats?range=30d"
Authentication & scopes
Every request — REST and MCP alike — is authenticated with an API key sent as a bearer token. Create one at Dashboard → Settings → API Keys. The raw key is displayed once, at creation time; only a SHA-256 hash is stored, so a lost key cannot be recovered — revoke it and issue a new one.
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://analytics.appfor.you/api/v2/sites"
Scopes
Scopes are hierarchical: admin implies write, which implies read. Grant the narrowest scope that does the job — a reporting integration only ever needs read.
| Scope | Grants | Notes |
|---|---|---|
| scope: read | Every GET endpoint. | Read-only access to sites and analytics. This is the default: API keys created before scopes existed, or created without an explicit scope list, behave exactly as read-only keys. |
| scope: write | Everything `read` grants, plus POST and PATCH. No DELETE at all. | Create and modify sites, goals, funnels, segments, alerts, webhooks, annotations and scheduled reports. Every DELETE in this API needs `admin` — a `write` key can build and edit, but can never remove. |
| scope: admin | Everything `write` grants, plus every DELETE. | Required to delete a site, goal, funnel, segment, alert, webhook, annotation or scheduled report. Every one of those also requires an explicit `confirm` value repeating the resource id — the scope alone is never enough. |
A request whose key lacks the required scope returns 403 with a message naming both the scope needed and the scopes granted.
Site restriction
A key can optionally be limited to specific sites. A restricted key sees only those sites in GET /api/v2/sites, receives 403 on any other site, and cannot create new sites. A key with no restriction covers every site its owner can access, including sites they joined as a team member.
The restriction may name any site you can reach — sites you own and sites you joined as an accepted team member — which matches both the site picker in the dashboard and the access check applied at request time. Naming a site you cannot reach, or one that no longer exists, is rejected with 400 and a deliberately opaque message that does not reveal which of the two it was.
Deleting a site does notrewrite keys restricted to it. Emptying a key's site list would mean "every site the owner has", so removing the last entry would silently widen the key instead of narrowing it; a stale id simply never matches again.
Expiry & revocation
Keys may carry an optional hard expiry. Once the expiry passes — or once the key is revoked from the dashboard — every request with it fails with 401. Rotation is therefore: create the new key, deploy it, revoke the old one.
["read"] and keep working exactly as before — no migration, no breakage, but also no write access until you issue a new key.Conventions
- Site identifiers. Anywhere a
{siteId}is accepted you may pass either the Mongo_id(returned assiteId) or the publictrackingIdfrom the tracking snippet. - Pagination. List endpoints accept
limit(default 50, max 500) andoffset, and returntotal— the full count before pagination. - Time windows. Most analytics endpoints take a
rangepreset plus optionalfrom/tooverrides. The accepted preset values differ per endpoint — each one lists its own set on its resource page — and an endpoint that echoes the window back does so as arangeobject:{ since, until }on most of them and{ preset, since, until }on/statsand/goals. The single-goal and funnel-stats endpoints are the two that still return a flatrangestring alongside top-levelsince/until. - Date-only bounds cover whole days.
from=YYYY-MM-DDstarts atT00:00:00.000Zandto=YYYY-MM-DDruns throughT23:59:59.999Z, so the final day is included andfrom=X&to=Xis a full 24 hours — it used to be a zero-width window that always came back empty. A value carrying a time is an instant and is used verbatim. An upper bound in the future is clamped to now, except where clamping would invert the window. A window lying wholly in the future is rejected with400everywhere, because it can never contain data. - Unusable windows. All 11 endpoints that accept a
from/toreject a window they cannot use — an unparseable bound, or one that ends before it starts — with400. Nothing falls back to itsrangepreset any more, so a successful response always describes the window you asked for. Relative to/api/v2/sites/{siteId}, those are:GET /goals,GET /goals/{goalId},GET /funnels/{funnelId}/stats,GET /channels,GET /journeys,GET /vitals,GET /profiles,GET /revenue,GET /revenue/sources,GET /revenue/products,GET /revenue/campaigns.- GET /retention and GET /annotations validate their own from/to and answer 400 with their own wording.
- GET /stats, GET /pageviews and GET /events accept only a range preset, and GET /realtime has a fixed 5-minute window, so none of them has a from/to to reject.
- An unrecognised range preset is a 400 everywhere; no endpoint silently substitutes its default any more.
- Rate-limit headers. Every successful response carries
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset, and so does a429. Other error responses carry none of them — see Rate limits. - Errors. Always
{ "error": string }with a meaningful HTTP status. Success responses are the resource itself. - Partial updates. Every
PATCHtakes only the fields you want to change, and requires at least one. Array fields such as funnelsteps, webhookeventsand segmentfiltersare replaced, not merged. - Team roles. Sites shared with you carry a role.
vieweris read-only and receives403on writes; only theownermay delete a site, and only the owner may changeisPublic. APATCHcarrying that field from a team member —adminrole included — is403, checked before any field is applied, so a mixed PATCH cannot partially succeed. Publishing puts the site's analytics on an unauthenticated/share/<slug>URL. - Per-user resources.Alerts, webhooks, scheduled reports and segments belong to the user who created them — your key never lists another user's. Annotations are the exception: everyone with site access reads them all, but only the author can edit or delete one.
- CORS. Every endpoint answers
OPTIONSpreflight and allows any origin. That is for server-to-server and tooling convenience — never ship an API key to a browser, because anyone who loads the page can read it.
Destructive operations
Operations that permanently destroy data require two independent things: the admin scope and an explicit confirm value. Having the scope alone is never enough — this is what stops an autonomous agent from deleting something on its own initiative.
Repeat the resource identifier from the URL as the confirmation, either as a query parameter or as a confirm field in the JSON body. Omit it and you get 400 with a message naming the exact value to send; send the wrong value and you get 400 reporting the mismatch.
# Both forms are accepted.
curl -X DELETE "https://analytics.appfor.you/api/v2/sites/SITE_ID?confirm=SITE_ID" \
-H "Authorization: Bearer $ANALYTICS_API_KEY"
curl -X DELETE "https://analytics.appfor.you/api/v2/sites/SITE_ID" \
-H "Authorization: Bearer $ANALYTICS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"confirm":"SITE_ID"}'Which calls are destructive
| Operation | Scope | Confirmation |
|---|---|---|
| DELETE /api/v2/sites/{siteId} | admin | confirm={siteId} |
| DELETE /api/v2/sites/{siteId}/goals/{goalId} | admin | confirm={goalId} |
| DELETE /api/v2/sites/{siteId}/funnels/{funnelId} | admin | confirm={funnelId} |
| DELETE /api/v2/sites/{siteId}/segments/{segmentId} | admin | confirm={segmentId} |
| DELETE /api/v2/sites/{siteId}/alerts/{alertId} | admin | confirm={alertId} |
| DELETE /api/v2/sites/{siteId}/webhooks/{webhookId} | admin | confirm={webhookId} |
| DELETE /api/v2/sites/{siteId}/annotations/{annotationId} | admin | confirm={annotationId} |
| DELETE /api/v2/sites/{siteId}/reports/{reportId} | admin | confirm={reportId} |
There are no unguarded deletes
Every DELETE in this API is in the table above — goals, funnels and segments included. Those three previously needed only write and no confirmation; they now require admin plus a confirm equal to the resource's own id. A write key can create and edit, but can never remove.
The confirmation value is always the identifier as it appears in the URL — the 24-character id, not the resource's name. The one exception is DELETE /api/v2/sites/{siteId}, which also accepts the site's trackingId or Mongo _id, since either may be used in the path.
Ordering matters if you are writing a client: the confirmation is checked after the scope and site-access checks but before the id is validated. A wrong confirmation on a well-formed but non-existent id therefore returns 400, never 404 — an unconfirmed call never reveals whether the resource exists.
Where a reversible alternative exists, prefer it: alerts, webhooks and scheduled reports can all be paused with PATCH … { "enabled": false } instead of being deleted.
Rate limits
Requests are limited to 100 requests per minute, counted per API-key ownerrather than per key — all of an account's keys share one budget, and REST and MCP traffic draw from the same pool.
- 100 requests per minute, counted per API-KEY OWNER rather than per key: every key on an account draws from one budget, and REST and MCP traffic share it.
- Counters live in MongoDB — one document per (bucket, one-minute window), incremented with a single atomic upsert and removed by a TTL index. The budget is therefore shared by every application replica and survives a redeploy; it is not a per-process counter.
- If the database is unreachable or slow the limiter degrades to an in-process counter instead of failing the request. While degraded the limit is still enforced, but per replica — so the effective ceiling across a multi-replica deployment can briefly be higher than the documented number. It never fails open entirely.
- Every SUCCESSFUL response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`, and so does the 429 you get once the budget is gone — which additionally carries `Retry-After` in seconds. The other error responses (400, 401, 403, 404, 500, 503) are raised outside the limiter and deliberately carry none of them, so track your budget from a success or from the 429 itself. `X-RateLimit-Reset` is a Unix timestamp in MILLISECONDS.
- Failed authentication has its own, much tighter budget keyed on the client IP: 20 failures per minute, or 200 for the shared `unknown` bucket used when no usable client address reaches the app. The budget is CONSUMED only once a credential has been looked up and found bad, so a request presenting a valid key is never charged for a neighbour behind the same address; a request carrying no credential at all is rejected up front as soon as that bucket is empty, with zero I/O. What this budget does NOT do: it does not eliminate the per-guess key lookup, and once an address is over budget a wrong key still answers 429 while a correct one still authenticates. It is not a defence against a determined guesser — API-key entropy is, backed by the owner-level budget that applies the moment a key is valid.
- The address a failure is charged to is taken from `x-forwarded-for` counting TRUSTED_PROXY_HOPS entries from the END (default 1 — the address written by the reverse proxy directly in front of the app), and clamping to the leftmost entry (with a one-time warning logged) when the chain is shorter than the configured hop count. `x-real-ip` is consulted only when the deployment sets `TRUST_X_REAL_IP=true` (default false) — it is otherwise plain client input that would let a caller pin its bucket to a victim address. Anything that is not a valid IP address is discarded into the shared `unknown` bucket instead of becoming a key, and IPv6 addresses are canonicalized (RFC 5952) before keying so alternative spellings of one address share one bucket. This governs failed-auth accounting only; the client address is never an access-control decision.
- An MCP `tools/call` consumes TWO units — one at the MCP transport and one inside the /api/v2 route it re-enters. `initialize`, `ping` and `tools/list` consume one. In a JSON-RPC batch the FIRST element rides on the unit the transport already charged and every further element charges one more, on top of whatever its own method costs — so a batch of ten tool calls still costs twenty. The `X-RateLimit-*` headers on an MCP response report the budget observed AFTER all of that work, not the snapshot taken before it.
A successful response advertises what is left:
HTTP/1.1 200 OK X-RateLimit-Limit: 100 X-RateLimit-Remaining: 87 X-RateLimit-Reset: 1711612800000
Exceeding the limit returns 429, which adds Retry-After in seconds:
HTTP/1.1 429 Too Many Requests
Retry-After: 37
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1711612800000
{"error":"Rate limit exceeded — 100 requests per minute"}X-RateLimit-Reset is a Unix timestamp in milliseconds. Back off until then rather than retrying immediately. Those two cases — a success and the 429 itself — are the only responses that carry the headers; a 400, 401, 403, 404, 500 or 503 is raised outside the limiter and reports no budget.
tools/call consumes two units of the budget — one at the MCP transport and one inside the REST route it re-enters. initialize, ping and tools/list consume one. In a JSON-RPC batch the first element rides on the unit the transport already charged and every further element charges one more on top of its own method, so a batch of ten tool calls still costs twenty. The X-RateLimit-* headers on an MCP response report the budget observed after that work, not the snapshot taken before it.401 will lock you out for the rest of the minute. Fix the key instead of retrying.Error codes
Every error is a JSON object with a single error field describing what went wrong.
| Status | Meaning | When |
|---|---|---|
| 400 | Bad Request | Validation failed, or a destructive call is missing/mismatching its `confirm` value. The `error` string names the exact problem. |
| 401 | Unauthorized | Missing, malformed, revoked or expired API key. A 401 from the MCP endpoint additionally carries `WWW-Authenticate: Bearer realm="analytics-platform", error="invalid_token"`. |
| 403 | Forbidden | The key lacks the required scope, is restricted to other sites, or your role on the site is read-only (`viewer`). |
| 404 | Not Found | The site or resource does not exist — or you have no access to it. Access failures are deliberately reported as 404 so other users’ data is never revealed. |
| 429 | Too Many Requests | Rate limit exceeded — 100 requests per minute per API-key owner, or the tighter per-IP budget for failed authentications. Check the Retry-After and X-RateLimit-Reset headers. |
| 500 | Internal Server Error | Unexpected failure. Safe to retry with backoff. |
| 503 | Service Unavailable | The database was unreachable while authenticating. Retry with backoff. |
{
"error": "Forbidden — this API key lacks the 'write' scope (granted: read)"
}MCP server
The Model Context Protocol (MCP) is an open standard that lets AI assistants call tools on remote servers. This platform hosts one, so an assistant connected to it can answer "how did the pricing page do last month?" or "set up a checkout funnel" by calling the same endpoints documented in the REST reference — no glue code, no scraping.
Endpoint
https://analytics.appfor.you/api/mcpAuthentication
Authorization: Bearer <your API key>Connect a client
Claude Code needs a single command. Configuration for Claude Desktop, Cursor and raw JSON-RPC clients, the full protocol contract and the self-hosting notes are on the MCP server page; all 55 tools are listed in the MCP tool reference.
claude mcp add --transport http analytics https://analytics.appfor.you/api/mcp \ --header "Authorization: Bearer YOUR_API_KEY"
For AI agents & code generators
These pages have machine-readable counterparts. Point your client generator, agent or retrieval pipeline at them instead of scraping the HTML.
application/json./llms.txtA compact llmstxt.org index: what the product is, how to authenticate, every endpoint and every MCP tool in one plain-text file.# Generate a typed client from the spec npx openapi-typescript https://analytics.appfor.you/openapi.json -o analytics-api.d.ts # Or hand the whole surface to an agent in one fetch curl https://analytics.appfor.you/llms.txt