# Analytics API

<a id="analytics-api"></a>

The Analytics API reads the traffic numbers and request logs that the Approximated dashboard shows for your cluster. Use it to build your own reports, show traffic inside your app, or find the requests behind an error. Every endpoint is a read-only `GET` request.

If you would rather ask questions in plain language, connect an AI assistant with [MCP](./mcp.md#mcp). With the right permissions it can read the same traffic data and request logs for you.

The [OpenAPI spec](https://approximated.app/openapi.yaml) has the machine-readable contract for every endpoint on this page.

**Endpoints**

GET

https://cloud.approximated.app/api/vhosts/:id/analytics

Traffic over time for one virtual host.

GET

https://cloud.approximated.app/api/vhosts/:id/analytics/top

Top countries, status codes, methods, or origins for one virtual host.

GET

https://cloud.approximated.app/api/proxy-servers/:id/analytics

Traffic over time for the whole cluster.

GET

https://cloud.approximated.app/api/proxy-servers/:id/analytics/top

Top countries, status codes, methods, or origins for the whole cluster.

GET

https://cloud.approximated.app/api/proxy-servers/:id/logs

Recent requests across the whole cluster, newest first.

GET

https://cloud.approximated.app/api/vhosts/:id/logs

Recent requests for one virtual host, newest first.

<a id="analytics-api-auth"></a>

## Authentication and Scope

Send your API key in the `api-key` header, the same as the [Virtual Hosts API](./virtual-hosts-api.md#virtual-hosts-api). Each key belongs to one proxy cluster, and these endpoints only ever read that cluster's data.

- Virtual host endpoints take the virtual host's `id`, as returned by the Virtual Hosts API. A virtual host on another cluster returns a 404, exactly like an id that doesn't exist.
- Cluster endpoints take your cluster's id, and it must be the cluster your key belongs to. Any other id returns a 404. Virtual host analytics responses include your cluster's id as `proxy_server_id`.
- A missing or unknown key returns a 401.

<a id="analytics-api-rate-limits"></a>

## Rate Limits

Every call counts toward the general API budget of 240 requests per minute per key. Analytics and request logs also have their own budgets of **60 requests per minute per key** each. The two budgets are separate, so paging through logs does not use up your analytics budget.

Each budget is counted on each Approximated application server, not across all of them. Your requests can be spread across servers, so you may be able to send more than these numbers before you see a 429. Don't rely on that.

Going over the analytics or logs budget returns a 429 with `{"error": "rate_limit_exceeded"}`. Going over the general budget also returns a 429, as plain text that says how many seconds to wait.

<a id="analytics-api-windows"></a>

## Time Windows and Granularity

The four analytics endpoints read a time window set by `start` and `end`. Both are RFC 3339 timestamps that include a time zone, such as `2026-10-28T00:00:00Z`. `end` must be after `start`.

`end` defaults to now and `start` defaults to 24 hours before now. The `start` default is counted from now, not from `end`, so set both when you ask for an older window.

The API picks the finest data that covers the whole window and reports it as `granularity`:

**Granularity**

**`raw_1m`**

_Minute data_

Windows of 48 hours or less that start within the last 7 days.

**`h_1h`**

_Hourly data_

Windows of 30 days or less that start within the last 90 days.

**`d_1d`**

_Daily data_

Everything else. Daily data goes back 730 days, and older windows return no data.

When a window is too long or too old for finer data, the API steps down to coarser data instead of leaving out part of the window. Daily data is the last step. A window that reaches back further than 730 days returns only the part from the last 730 days, with no warning.

Data is stored in whole rows of one minute, one hour, or one day, each labeled with its start time. A row is counted when its start time is at or after `start` and before `end`. So if `start` falls partway through an hour or a day, that partial hour or day is left out. The hour or day that contains `end` is counted in full. For exact totals, set `start` and `end` on whole hours or whole UTC days to match the granularity.

<a id="analytics-api-series"></a>

## Traffic Over Time

GET

https://cloud.approximated.app/api/vhosts/:id/analytics

GET

https://cloud.approximated.app/api/proxy-servers/:id/analytics

Returns request counts and bandwidth in time buckets. The only parameters are `start` and `end`. The bucket size follows the window length:

- Up to 1 hour: 1-minute buckets.
- Up to 6 hours: 5-minute buckets.
- Up to 24 hours: 15-minute buckets.
- Up to 48 hours: 30-minute buckets.
- Up to 7 days: 1-hour buckets.
- Up to 30 days: 6-hour buckets.
- Longer: 1-day buckets.

Buckets are never finer than the granularity. For example, a 24-hour window from 10 days ago uses `h_1h`, so its buckets are 1 hour long.

```bash
curl "https://cloud.approximated.app/api/vhosts/1234/analytics?start=2026-10-28T00:00:00Z&end=2026-10-29T00:00:00Z" \
  -H "api-key: your-cluster-api-key"
```

The response, trimmed to one bucket:

```json
{
  "data": {
    "proxy_server_id": 42,
    "vhost_id": 1234,
    "window": {"start": "2026-10-28T00:00:00Z", "end": "2026-10-29T00:00:00Z"},
    "granularity": "raw_1m",
    "buckets": [
      {
        "ts": "2026-10-28T00:00:00Z",
        "request_count": 1820,
        "bytes_in": 912340,
        "bytes_out": 48211022,
        "bytes_egress": 48902311,
        "by_origin": {"upstream": 1790, "cluster": 12, "cluster_blocked": 15, "cluster_proxy_error": 3},
        "by_status_class": {"2xx": 1702, "3xx": 61, "4xx": 52, "5xx": 5}
      }
    ]
  }
}
```

**Bucket fields**

**`ts`**

_String_

The bucket's start time in UTC. Bucket times line up with the bucket size, so the first bucket can be labeled a little before `start`.

**`request_count`**

_Integer_

All requests in the bucket that reached a virtual host. Requests stopped before virtual host routing, such as by cluster-level Defense block or Edge Verify rules, are not counted.

**`bytes_in`**

_Integer_

Bytes received from clients, counting the request line, headers, and body.

**`bytes_out`**

_Integer_

Bytes sent back to clients, counting the status line, headers, and body.

**`bytes_egress`**

_Integer_

Everything the cluster sent out: `bytes_out` plus the request bytes it forwarded to your upstream. Use it for trends. It is close to, but not exactly, the bytes on the wire.

**`by_origin`**

_Object_

Requests split by what answered them:

- `upstream`: your upstream target answered.
- `cluster`: the cluster answered by itself, for example with a redirect or a catch-all 404.
- `cluster_blocked`: the cluster refused the request, for example with a firewall block or a rate limit.
- `cluster_proxy_error`: the cluster could not get a response from your upstream.

**`by_status_class`**

_Object_

Requests by response status: `2xx`, `3xx`, `4xx`, and `5xx`.

Buckets with no requests are left out, so fill gaps with zero when you draw a chart. Cluster responses leave out `vhost_id`.

<a id="analytics-api-top"></a>

## Top Values

GET

https://cloud.approximated.app/api/vhosts/:id/analytics/top

GET

https://cloud.approximated.app/api/proxy-servers/:id/analytics/top

Returns the values with the most requests in the window, highest first. Ties are sorted by value.

**Query parameters**

**start, end**

_RFC 3339, optional_

The window, as described in [Time Windows and Granularity](#analytics-api-windows).

**`dim`**

_String, optional_

What to group by: `country`, `status`, `method`, or `origin`. Defaults to `country`. Any other value returns a 400.

**`limit`**

_Integer, optional_

How many values to return, from 1 to 100. Defaults to 10. Numbers outside that range are moved to the nearest end, and anything that isn't a whole number counts as 10.

```bash
curl "https://cloud.approximated.app/api/proxy-servers/42/analytics/top?dim=status&limit=3" \
  -H "api-key: your-cluster-api-key"
```

```json
{
  "data": {
    "proxy_server_id": 42,
    "window": {"start": "2026-10-28T15:04:05.123456Z", "end": "2026-10-29T15:04:05.123456Z"},
    "granularity": "raw_1m",
    "dim": "status",
    "items": [
      {"value": 200, "request_count": 162004, "bytes_in": 81002000, "bytes_out": 4211450210},
      {"value": 304, "request_count": 9120, "bytes_in": 4560000, "bytes_out": 1824000},
      {"value": 404, "request_count": 2210, "bytes_in": 1105000, "bytes_out": 2652000}
    ]
  }
}
```

`value` is a number for `status` and a string for the other dimensions. Countries are two-letter codes. Requests with no known country are left out of the list, so a country query can return fewer items than `limit`. Origins use the same names as `by_origin` above.

<a id="analytics-api-logs"></a>

## Request Logs

GET

https://cloud.approximated.app/api/proxy-servers/:id/logs

GET

https://cloud.approximated.app/api/vhosts/:id/logs

Returns individual requests, newest first. Request logs are turned on per cluster. When they are off for your cluster, both log endpoints return a 404, the same as an unknown id.

**Query parameters**

**`window`**

_Integer, optional_

How many seconds to look back from now. Defaults to 3600 (one hour). An empty value also uses the default. Any other value must be a positive whole number, or the request returns a 400. A window longer than your cluster's log retention is cut back to it.

**`limit`**

_Integer, optional_

Rows per page, from 1 to 1000. Defaults to 100. An empty value also uses the default. Numbers outside that range are moved to the nearest end. Any other value that isn't a whole number returns a 400.

**`cursor`**

_String, optional_

The `next_cursor` from the previous page.

**`host`**

_String, optional_

Only requests for this domain. Send the host name alone, such as `shop.example.com`, with no path. The match ignores upper and lower case and surrounding spaces. An empty or blank value means no filter.

It matches requests routed to the virtual host with this host name, plus any row whose logged `host` is this name, such as challenge rows with `vhost_id` 0. After a rename, requests routed to the virtual host are found by the new name, including older ones. Challenge rows keep the name that was requested, so older ones are still found by the old name and not the new one. Requests for a deleted virtual host can't be found this way, but unfiltered cluster logs still list them.

**`status`**

_Integer, optional_

Only this exact status code, such as `502`.

**`status_class`**

_Integer, optional_

Only one class of status: `2`, `3`, `4`, or `5`. For example, `5` means 500 to 599.

**`method`**

_String, optional_

Only this exact HTTP method. Use upper case, such as `POST`.

**`disposition`**

_String, optional_

Only requests the edge handled this way. The values are listed under [Log Fields](#analytics-api-log-fields).

**`outcome`**

_String, optional_

Like `disposition`, plus `upstream_failure` for requests where your upstream could not be reached. `outcome=served` leaves those failures out, while `disposition=served` includes them.

**`path_prefix`**

_String, optional_

Only paths that start with this text, such as `/api/`. Every character is matched literally.

**`client_ip`**

_String, optional_

Only requests from this exact IPv4 or IPv6 address, such as `203.0.113.24` or `2001:db8::1`. Any spelling of the same IPv6 address matches. Leading zeros in an IPv4 address are read as decimal, so `192.168.001.001` is `192.168.1.1`. An empty or blank value means no filter. Anything else, including a shortened IPv4 address such as `127.1`, returns a 400 with `invalid_client_ip`.

A row must match every filter you send. Any whole number for `status` is matched exactly, so `status=999` returns no rows. A `status` that isn't a whole number is ignored. Unrecognized values for `status_class`, `disposition`, and `outcome` are also ignored rather than rejected, so check your spelling if a filter seems to do nothing.

```bash
curl "https://cloud.approximated.app/api/vhosts/1234/logs?window=21600&status_class=5&limit=50" \
  -H "api-key: your-cluster-api-key"
```

Log responses have no `data` wrapper. The response, trimmed to one row:

```json
{
  "logs": [
    {
      "ts": "2026-10-29T14:03:12.481",
      "vhost_id": 1234,
      "host": "shop.example.com",
      "method": "GET",
      "path": "/checkout",
      "status": 502,
      "http_version": "2",
      "client_ip": "203.0.113.24",
      "country": "CA",
      "ua": "curl/8.7.1",
      "bytes_in": 812,
      "bytes_out": 165,
      "duration_us": 10003211,
      "disposition": "served",
      "outcome": "upstream_failure",
      "upstream_failure_reason": "connect_timeout",
      "request_id": "6f1c2a9e-1b7d-4c55-9a52-2f0d3c1e8b44",
      "sample_rate": 1
    }
  ],
  "next_cursor": "g2gEbgcAQGr1ZqoEBm0AAAAEZnJhMWEMbgUAt0j0kgE="
}
```

To get the next, older page, send `next_cursor` back as `cursor` with the same window and filters. `next_cursor` is `null` when a page has fewer rows than `limit`. A full last page can still return a cursor that leads to an empty page. The window is counted back from the time of each call, and a cursor the API can't read is ignored, which starts you again from the newest page.

<a id="analytics-api-log-fields"></a>

## Log Fields

**`ts`**

_String_

When the request happened, in UTC with millisecond precision. It has no time zone suffix, so read it as UTC.

**`vhost_id`**

_Integer_

The virtual host that handled the request.

Requests that never reach a virtual host are not logged. That includes requests that match no virtual host and requests stopped by cluster-level Defense block or Edge Verify rules before virtual host routing. A `0` marks a challenge the edge served before it knew the virtual host. Those rows appear in cluster logs only, because `/api/vhosts/:id/logs` returns rows for that one id.

**host, method, path, status, http_version**

_Strings and an integer_

The request's host name, method, path, response status, and HTTP version.

`host` is the virtual host's current host name, with no path, such as `shop.example.com`. After a rename, older rows show the new name. Rows for a deleted virtual host have an empty `host`. A row the edge logged with its own host name keeps it as logged, which can differ in case. Challenge rows with `vhost_id` 0 are like this, so after a rename older ones still show the old name.

`http_version` is `1.1`, `2`, `3`, or `other` for any other version, such as HTTP/1.0.

**client_ip, country, ua**

_Strings_

The client's IP address, its two-letter country code when known, and its User-Agent header.

**bytes_in, bytes_out, duration_us**

_Integers_

Request size, response size, and how long the request took in microseconds.

**`disposition`**

_String_

What the edge did with the request:

- `served`: no firewall, rate-limit, or challenge decision was recorded. This includes responses the edge made itself, such as redirects and static block responses from rules inside a virtual host.
- `waf_blocked`: blocked by the firewall.
- `rate_limited`: refused by a rate limit.
- `challenge_issued`: sent a browser challenge.
- `challenge_passed`: passed a challenge on this request.
- `challenge_failed`: failed a challenge.
- `challenge_passed_prior`: let through because the client had already passed a challenge.

**`outcome`**

_String_

The same as `disposition`, except `upstream_failure` when the cluster could not get a response from your upstream and answered with a 5xx status.

**`upstream_failure_reason`**

_String_

Why an `upstream_failure` happened. Empty for every other outcome, and for older failures recorded before reasons were captured. The values are `dns_timeout`, `dns_error`, `connect_timeout`, `connection_refused`, `connection_reset`, `tls_certificate_error`, `tls_handshake_timeout`, `tls_handshake_error`, `request_write_timeout`, `request_write_error`, `response_header_timeout`, `upstream_eof`, `no_upstreams_available`, and `unknown`.

**`request_id`**

_String_

The request's ID as a UUID, or empty when none was recorded.

**`sample_rate`**

_Integer_

How many requests this row stands for. Usually `1`. A row with `sample_rate` 10 stands for about 10 requests.

Served rows are kept unless a machine runs short of memory. Blocked and challenge rows can be sampled under heavy traffic. Rows dropped when a machine hits its row limit or runs short of memory are not made up for.

Use logs to inspect requests, not to count them. For totals, use the [analytics endpoints](#analytics-api-series).

<a id="analytics-api-data"></a>

## What Is and Isn't Included

- Log rows contain only the fields listed above. Request and response bodies are never logged. Other request headers, forwarded-for addresses, network owner details, and internal server names are never returned.
- Analytics are counts and byte totals. They never include individual IP addresses or paths.
- Logs only go back as far as your cluster's request-log retention. Daily analytics go back 730 days. See the [data retention policy](/data-retention/) for how long each kind of data is kept.
- All times are UTC.

<a id="analytics-api-errors"></a>

## Errors

Errors other than 401 and the general 429 return JSON with an `error` code. 400 responses also carry a `message`:

```json
{"error": "invalid_window", "message": "start and end must be RFC 3339 with end > start"}
```

**400**

`invalid_window`: an analytics `start` or `end` that isn't an RFC 3339 time with a time zone, or an `end` that isn't after `start`. For logs, a `window` that isn't a positive whole number. `invalid_dim`: an unknown `dim`. `invalid_limit`: a log `limit` that isn't a whole number. `invalid_client_ip`: a log `client_ip` that isn't a full IPv4 or IPv6 address.

**401**

The API key is missing or unknown. The body is the JSON string `"Unauthorized"`.

**404**

`not_found`: the id doesn't exist, belongs to another cluster, or isn't a number. The log endpoints also return it when request logs are off for your cluster.

**429**

`rate_limit_exceeded`: the analytics or logs budget was used up. The general budget returns plain text instead. See [Rate Limits](#analytics-api-rate-limits).

**500**

`internal_error`: the analytics query failed. Retry after a short wait.

**503**

`logs_unavailable`: the log query failed or took too long. Try a shorter `window` or add filters, then retry.
