> ## Documentation Index
> Fetch the complete documentation index at: https://developers.meshapi.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Analytics API

> Group your usage by any dimension, over any range — with every answer stating the population it counted, the store it came from, and how much of the traffic each figure could be computed over.

The analytics endpoints answer one question in many shapes: **break my traffic
down by X, over range Y, measuring Z.** A breakdown grid, a route table, a
chargeback report and a time series are the same `GROUP BY` with a different
`group_by` and a different `metric` list.

They are a separate surface from [the usage endpoints](https://developers.meshapi.ai/docs/reference/usage-api.md):

| | Usage API | Analytics API |
| - | - | - |
| Shape | Fixed responses — a summary, an event list, a rate-limit snapshot | One generic grouped query |
| Grain | Per request | Pre-aggregated, grouped |
| Breakdown | By model | By any of 13 dimensions, two at a time |
| Measures | A fixed set | Any 12 of 31 |
| Range | Your full account history | Up to 366 days per query, over 24 months of retained history |

Reach for the usage endpoints when you want individual requests, and these when
you want a shape of the traffic.

> [!NOTE]
>
> These endpoints are not in the **API Reference** tab. They are served by a
> separate process that publishes no OpenAPI document, so this page is the
> contract. Field names and query parameters here were read off the running
> service — if something on this page disagrees with what the API returns, the API
> is right and we want to hear about it.

## Authentication and scope

Base URL `https://api.meshapi.ai/v1/analytics`. Every endpoint is a `GET`, and
every one takes an `rsk_` API key with the `usage:read` scope:

```
Authorization: Bearer rsk_<key>
```

**A key reads its own traffic and nothing else.** The scope is derived
server-side from the key that authenticated, so `org_id`, `team_id` and
`member_user_id` are ignored rather than trusted — a key cannot widen itself by
naming an org.

These same endpoints back the **Analytics** screens in the dashboard, where the
answer is scoped by your role instead: owners and admins see the whole org,
members see their own rows, and a team lead sees their own rows plus the teams
they lead. Your role is re-resolved on every request against the membership
record rather than read out of the token, through a cache with a few minutes'
TTL — so a revoked role stops reading within that window. The org-wide view is a
dashboard surface; from your own code you get your key's rows.

A key caller's scope also narrows what it can *ask* for. See
[Dimensions](#dimensions) and [`labels`](#labels) below — `key_id` and
`member_user_id` are already fixed for you, so grouping by either is refused.

```bash theme={null}
curl -G "https://api.meshapi.ai/v1/analytics/usage/summary" \
  -H "Authorization: Bearer rsk_<key>" \
  --data-urlencode "since=2026-10-01T00:00:00Z" \
  --data-urlencode "group_by=model" \
  --data-urlencode "metric=requests" \
  --data-urlencode "metric=spend_usd" \
  --data-urlencode "rank_by=spend_usd"
```

## The endpoints

| Endpoint | What it answers |
| - | - |
| `GET /usage/summary` | Grouped usage. The general one — everything below is about this. |
| `GET /usage/summary.csv` | The same grouped rows, streamed as CSV. |
| `GET /usage/summary.xlsx` | The same grouped rows as an Excel workbook. |
| `GET /spend/summary` | Where the money went, what was wasted, and what moved since the prior window. |
| `GET /reliability/attribution` | Outcome shares, the two latency legs, and what retries recovered. |
| `GET /findings` | Five fixed sections, each with a verdict rather than a chart. |
| `GET /meta` | What this surface accepts, enumerated for your key. |

`/spend/summary`, `/reliability/attribution` and `/findings` are fixed-shape
panels: their shape is the answer, so none takes
`metric`, `group_by`, `rollup`, `rank_by`, `sort`, `limit` or `cursor`. They do
**not** all take the same filters as `/usage/summary`, and an unknown query
parameter is ignored rather than refused — so a filter that does not apply
returns the *unfiltered* panel:

| Panel | Filters it accepts | Also |
| - | - | - |
| `/spend/summary` | `model`, `provider`, `key_id`, `is_byok`, `stream`, `include_internal` | Computes its own prior window; no `include_retries`. |
| `/reliability/attribution` | `model`, `provider`, `status`, `key_id`, `is_byok`, `stream`, `include_internal` | `bucket` (`hour`/`day`/`week`/`month`) adds a stacked series. No `include_retries`. |
| `/findings` | `model`, `provider`, `key_id`, `is_byok`, `stream`, `include_internal`, `include_retries` | |

> [!WARNING]
>
> `fault_owner` and `end_user_id` are **not** filters on any of the three, and
> `status` is not one on `/spend/summary` or `/findings`. Passing one is silently dropped, and the panel
> you get back describes all of your traffic. Check your parameter against the
> table above before billing or alerting from the result.

All three also take the scope parameters — `org_id`, `team_id` and
`member_user_id` — on the same terms as `/usage/summary`.

`/reliability/attribution` also refuses `stream=true&stream=false` with a `400`:
its latency block can only measure one half of the traffic, so honouring both
would report three figures over two populations.

## What the surface accepts — `GET /meta`

The dimensions, the measures, each panel's pin filters, the caps and the
raw-window boundary are also served as JSON, generated from the same definitions
the query layer uses. A client that reads `meta` at startup cannot drift from the
API the way a hard-coded list can, so prefer it over the tables here if the two
ever disagree.

It does not carry everything a request may send: the two population switches
(`include_internal`, `include_retries`) and `/reliability/attribution`'s `bucket`
are not in `panels[].filters`, which lists the repeatable pin filters only.

```bash theme={null}
curl https://api.meshapi.ai/v1/analytics/meta \
  -H "Authorization: Bearer rsk_<key>"
```

Abridged — the real `dimensions` and `measures` arrays carry every one of them,
and the other three panels appear beside `/usage/summary`:

```json theme={null}
{
  "dimensions": [
    { "name": "model", "value_type": "string", "filterable": true,
      "groupable": true, "pinned_by_scope": false, "fact_only": false }
  ],
  "measures": [
    { "name": "spend_usd", "rankable": true, "needs_stream_split": false,
      "money": true, "reports_coverage": false, "estimator": null }
  ],
  "default_metrics": ["requests", "errors", "error_rate", "spend_usd", "total_tokens"],
  "rollups": ["hour", "day", "week", "month"],
  "panels": [
    { "path": "/v1/analytics/usage/summary",
      "filters": ["model", "provider", "status", "fault_owner", "key_id",
                  "is_byok", "stream", "end_user_id"] }
  ],
  "limits": { "range_days": 366, "group_by": 2, "metric": 12, "limit": 200,
              "series_points": 20000, "export_rows": 50000 },
  "raw_window_since": "2026-09-26T17:00:00Z"
}
```

**The answer is specific to your key**, which is why it needs one. `groupable`
says whether *you* may name that dimension in `group_by`: an API key reads only
its own traffic, so `key_id` and `member_user_id` come back
`groupable: false` with `pinned_by_scope: true` — your scope has already fixed
them, and asking for that breakdown is a `400`. It describes the request you are
about to send, not one you have narrowed: an org-wide reader is told
`member_user_id` is groupable, and adding `member_user_id=X` to that query then
fixes it and refuses the breakdown. Build your controls from this rather than
from the full dimension list, or you will offer a filter that cannot be used.

`filterable` has a narrow meaning: *accepted as a repeatable pin parameter on
`/usage/summary`*. It says nothing about `org_id`, `team_id` and
`member_user_id`, which are scope parameters rather than pins — `team_id` and
`member_user_id` come back `filterable: false` and are still settable by a
dashboard caller on the terms in the query-parameter table below.
`value_type` is `string`, `uuid` or `boolean`.

The per-measure flags answer the questions the rest of this page explains in
prose: `rankable` (one SQL aggregate — may be named in `rank_by`, and may be
added across groups or buckets), `needs_stream_split` (pin or group by `stream`
first), `money` (returned as a decimal string), `reports_coverage` (carries a
`metadata.coverage_pct` key) and `estimator`. `estimator` names the derivation
only where a measure is read off something other than its own columns; `null`
covers both a plain sum and a ratio of two sums, so it does **not** mean
additive — `rankable` is that signal.

`fact_only` marks a dimension the hourly cube does not carry, so naming it pins
the whole range to the raw tier — see [The two tiers](#the-two-tiers). When
`raw_window_since` is `null` the raw tier is switched off entirely: no range may
name such a dimension, so it comes back `filterable: false` and
`groupable: false` and drops out of `panels[].filters` altogether.

`limit` and `export_rows` are deployment settings rather than fixed constants, so
read them from `meta` instead of hard-coding the numbers above. `export_rows`
bounds a range-total export; an export carrying a `rollup` is bounded by
`series_points` — see [CSV and Excel export](#csv-and-excel-export).

`/meta` takes `org_id` and `team_id` on the same terms as the other endpoints,
and is the one that does not take `member_user_id` — it answers for your reach,
not for a query you have not sent yet.

## Every answer states what it counted

A figure read without the set it was measured over is not a figure, so every
response — and every row of every export — carries its `population`:

```json theme={null}
"population": { "traffic_type": "external", "retries": "excluded" }
```

Two defaults are doing work there:

* **`traffic_type: external`.** Health checks and any other internal traffic
  class are out. Pass `include_internal=true` to lift it; the echo becomes
  `"all"`.
* **`retries: excluded`.** A request that took three attempts counts once, as
  the attempt that served it. Pass `include_retries=true` to count every
  attempt; the echo becomes `"included"`.

You rarely want `include_retries`. Retry multiplicity is reported anyway, on
every response, measured with the retried rows in scope while the money and
token totals stay on the declared population:

```json theme={null}
"metadata": { "attempts_per_request": 1.08 }
```

> [!WARNING]
>
> `traffic_type` and `is_retry_attempt` are set by `include_internal` and
> `include_retries`, **not** by a filter parameter. There is no
> `?traffic_type=external` here — a filter that contradicted the population would
> silently match nothing.

## Grouped usage — `GET /usage/summary`

### Parameters

| Parameter | Notes |
| - | - |
| `since` / `until` | ISO 8601, UTC. `until` is exclusive. Defaults to the last 7 days. Maximum range 366 days. |
| `metric` | Repeat for several. Up to 12 per request. Defaults to `requests`, `errors`, `error_rate`, `spend_usd`, `total_tokens`. |
| `group_by` | Repeat for at most two — a breakdown axis and one subgroup. Derived from your unset filters **and your reach** when omitted, and always echoed back. |
| `rollup` | `hour`, `day`, `week` or `month`. Omit it for a range total; there is no `rollup=total`. |
| `rank_by` | Which measure the page is ranked by. Defaults to `requests`. Must be one of the rankable measures below. |
| `sort` | `top` (default) or `bottom`. |
| `limit` | Groups per page. Default 50, maximum 200. |
| `cursor` | Keyset page token from `next_cursor`. |
| `org_id` / `team_id` | Dashboard callers only — ignored for an API key. |
| `member_user_id` | Dashboard callers with org-wide reach — owner, admin, viewer, or a custom role carrying org scope. Ignored for an API key. |
| `include_internal` / `include_retries` | The two population switches above. |
| `model`, `provider`, `status`, `fault_owner`, `key_id`, `is_byok`, `stream`, `end_user_id` | Filters. Repeat a parameter for several values; they combine as OR within a parameter and AND across them. `end_user_id` carries the raw-window restriction below. |

### Dimensions

Thirteen, and they are not all equally reachable:

| Dimension | Group by | Filter on |
| - | - | - |
| `model` | Yes | Yes |
| `provider` | Yes | Yes |
| `status` | Yes | Yes |
| `fault_owner` | Yes | Yes — `caller`, `provider`, `gateway`, or `none` |
| `is_byok` | Yes | Yes — `true` / `false` |
| `stream` | Yes | Yes — `true` / `false` |
| `modality` | Yes | No |
| `team_id` | Yes — but see below | Through the `team_id` scope parameter |
| `member_user_id` | Dashboard callers whose reach passes their own rows: org-wide (owner, admin, viewer, or a custom role carrying org scope), or over the teams they lead (team admin or team viewer) | Through the `member_user_id` scope parameter — org-wide reach only |
| `key_id` | Dashboard callers | Yes |
| `traffic_type` | Yes | Through `include_internal` |
| `is_retry_attempt` | Yes | Through `include_retries` |
| `end_user_id` | Yes — inside the raw window | Yes — inside the raw window |

> [!WARNING]
>
> **With an `rsk_` key, `group_by=key_id` and `group_by=member_user_id` are a
> `400`.** Your scope already fixes both to a single value — the key that
> authenticated, and its owner — and the rule below applies to a value fixed by
> your scope exactly as it does to one you passed yourself. A per-key chargeback
> breakdown is a dashboard report, not an API-key query. From a key, group by
> `model`, `provider`, `modality`, `status`, `fault_owner`, `is_byok`, `stream`,
> or `end_user_id` inside the raw window.
> `group_by=team_id` is accepted, but a key belongs to one team, so it returns a
> single group.

Two rules the API enforces rather than guesses at:

* **Grouping by a dimension already fixed to one value is refused.** It would be
  a one-row chart. This counts values fixed by your scope as well as ones you
  filtered on. Two pinned values still compare two things, so only a single
  fixed value is rejected.
* **Omitting `group_by` picks an axis from what is not already fixed, and from
  how far you reach.** A team-by-team comparison is nonsense once you have
  already picked a team; so is a per-member one for a caller who only ever reads
  one member. With nothing pinned at all, a team lead is broken down by member
  where an org-wide reader is broken down by team — and naming one of your teams
  fixes `team_id`, so the axis falls back to the ordinary order (`model`). The
  chosen axis comes back in the response's `group_by`, so render what you are
  given rather than assuming.

### `end_user_id` is raw-window only

`end_user_id` is the `safety_identifier` — or the deprecated `user` — you sent
on the request. It is kept per request and is **not** in the hourly cube, so it
can only be grouped or filtered on inside the raw window: the last 31 days, the
boundary `metadata.raw_window_since` reports.

A range reaching older than that is **refused with a `400`**, not answered over
the part that is covered. A partial answer under the same name would be a
different, smaller population than the one you asked for, and nothing in the
response would say so. When `metadata.raw_window_since` is `null` the raw tier
is switched off entirely and the dimension is unavailable at any range —
narrowing the range will not recover it.

An end user you have erased through `POST /v1/end-users/erasures` has their
identifier cleared from these rows too, so their traffic folds into the `null`
group rather than disappearing.

It is never chosen as the automatic breakdown axis when you omit `group_by`, so
a query that did not ask for it cannot be pushed into the raw tier by accident.

It is a dimension on `/usage/summary` and its CSV and Excel exports only.
`/spend/summary`, `/reliability/attribution` and `/findings` do not accept it —
see the panel table above, where an unrecognised filter is ignored rather than
refused.

### `labels`

`key_id` and `team_id` are UUIDs on a row. For a **dashboard** caller they
resolve to display names, returned once per response rather than repeated on
every row:

```json theme={null}
"labels": { "key_id": { "0190a1c0-…": "production-web" } }
```

`member_user_id` is never labelled, on any caller: its values are auth subject
ids, and resolving them would put members' identities in an analytics response.
A breakdown on that axis — which is what a team lead gets by default — therefore
renders raw ids unless you map them yourself.

For an API-key caller `labels` is always `{}` — label lookup is scoped to the
org the rows were read under, and a key read carries no org. Resolve a key's
name through `GET /v1/keys` with a `mak_` admin key, or keep your own mapping.

### Measures

Up to 12 per request, from these 31.

**Volume**

| Measure | Meaning |
| - | - |
| `requests` | Served requests. One per request however many attempts it took. |
| `attempts` | Upstream attempts. See the warning below. |
| `errors` | Served requests that ended in an error. |
| `error_rate` | `errors / requests`. Null when nothing was counted. |
| `attempts_per_request` | `attempts / requests`. See the warning below. |

> [!WARNING]
>
> **The `attempts` and `attempts_per_request` *measures* only say something under
> `include_retries=true`.** The default population drops the retried rows, so what
> is left is one attempt per served request: `attempts` equals `requests` and
> `attempts_per_request` is exactly `1.0`, whatever your retry rate.
>
> Read `metadata.attempts_per_request` instead. It is on every response, measured
> with the retried rows in scope while your money and token totals stay on the
> declared population — which is the figure you actually want, and is why the
> measure is rarely worth asking for.

**Money.** Returned as decimal **strings**, never floats.

| Measure | Meaning |
| - | - |
| `spend_usd` | What you were charged — inference on our credential, plus the platform fee. |
| `cost_usd` | Notional inference cost over every row, BYOK included. |
| `input_cost_usd` | The input share of `cost_usd`. |
| `output_cost_usd` | The output share of `cost_usd`. |
| `tool_cost_usd` | The hosted-tool share of `cost_usd`. |
| `platform_fee_usd` | The platform fee alone. |

> [!WARNING]
>
> **`spend_usd` and the cost components are measured over different rows.**
> `spend_usd` excludes inference billed on your own BYOK credential; `cost_usd`,
> `input_cost_usd`, `output_cost_usd` and `tool_cost_usd` are column sums over
> every row, BYOK included. On an org with BYOK traffic the parts can exceed the
> whole. To decompose spend, group by `is_byok` and read the components off the
> `is_byok=false` rows.

**Tokens**

| Measure | Meaning |
| - | - |
| `prompt_tokens` | |
| `completion_tokens` | |
| `total_tokens` | |
| `cached_tokens` | Tokens the provider served from its own prompt cache. Not bounded by `prompt_tokens` — some providers report more cached tokens than the prompt carried, so a share computed from the pair can exceed 100%. |
| `reasoning_tokens` | |
| `images_generated` | |

**Caching and routing**

| Measure | Meaning |
| - | - |
| `cache_hit_rate` | Share of requests that hit any cache — a replayed response, or a provider prompt-cache read. |
| `response_cache_hits` | Requests answered from Mesh's own response cache. |
| `gateway_cache_prompt_tokens` | Prompt tokens on those replayed responses. |
| `gateway_cache_completion_tokens` | Completion tokens on those replayed responses. |
| `served_as_asked_rate` | Share served on the model **and** provider you asked for. |
| `retry_recovered` | Requests that needed more than one attempt and then succeeded. |
| `failed_after_retry` | Requests that needed more than one attempt and still failed. |

**Latency.** All in milliseconds.

| Measure | Meaning |
| - | - |
| `latency_ms_avg` | Mean end-to-end latency. |
| `ttft_ms_avg` | Mean time to first token. |
| `ttft_p50_ms` | Median time to first token. |
| `ttft_p95_ms` | 95th percentile time to first token. |
| `ttft_p99_ms` | 99th percentile time to first token. |
| `provider_latency_ms_avg` | Mean time the upstream provider took. |
| `gateway_overhead_ms_avg` | Mean time Mesh added on top of it. |

### Rankable measures

A page is ordered and keyset-paged by `rank_by`, so it has to be one SQL
aggregate. Rates and averages are not: ranking a reliability table by
`error_rate` would put a three-request group at the top.

`requests`, `attempts`, `errors`, `cost_usd`, `input_cost_usd`,
`output_cost_usd`, `tool_cost_usd`, `platform_fee_usd`, `spend_usd`,
`prompt_tokens`, `completion_tokens`, `total_tokens`, `cached_tokens`,
`reasoning_tokens`, `images_generated`, `retry_recovered`,
`failed_after_retry`, `response_cache_hits`, `gateway_cache_prompt_tokens`,
`gateway_cache_completion_tokens`.

A real measure that is not on that list is a `400` naming the whole list. A name
that is not a measure at all is a `422`.

### Response

```json theme={null}
{
  "population": { "traffic_type": "external", "retries": "excluded" },
  "range": { "since": "2026-10-01T00:00:00Z", "until": "2026-10-27T00:00:00Z", "rollup": "day" },
  "group_by": ["model"],
  "totals": { "requests": 128400, "spend_usd": "412.88310" },
  "groups": [
    {
      "key": { "model": "openai/gpt-4o-mini" },
      "values": { "requests": 90120, "spend_usd": "188.40210" },
      "tier": "mixed",
      "coverage_pct": null,
      "series": [
        { "bucket": "2026-10-01T00:00:00Z", "values": { "requests": 3100, "spend_usd": "6.41200" }, "tier": "cube" }
      ]
    }
  ],
  "total_series": [
    { "bucket": "2026-10-01T00:00:00Z", "values": { "requests": 4800, "spend_usd": "9.80100" }, "tier": "cube" }
  ],
  "labels": {},
  "metadata": {
    "row_count": 1,
    "query_time_ms": 184,
    "truncated": false,
    "cached_at": null,
    "attempts_per_request": 1.04,
    "coverage_pct": {},
    "estimator": {},
    "histogram_bounds_ms": null,
    "tiers": ["cube", "raw"],
    "raw_window_since": "2026-09-26T00:00:00Z"
  },
  "next_cursor": null
}
```

`total_series` is the per-bucket total across **all** groups, not only the page.
It is there so `other = total − Σ(groups)` is computable and a chart cannot
disagree with the tiles above it.

## Reading a number honestly

### `coverage_pct` — how much of the traffic a figure could be computed over

Not every request can contribute to every measure. `ttft_ms` is null on a
non-streamed request; the two latency legs are null on rows written before the
split existed. An average over them silently describes a subset.

So the latency measures carry the share of requests they were actually computed
over, as a percentage of `requests`. Which measures, and at which grain:

| Where | Covers |
| - | - |
| `metadata.coverage_pct` | Every latency measure in your `metric` list, over the range totals — `latency_ms_avg`, `ttft_ms_avg`, the three TTFT percentiles, and the two legs. |
| A group's `coverage_pct` | The two leg means only — `provider_latency_ms_avg` and `gateway_overhead_ms_avg`. Those are the ones whose coverage varies group by group. |
| A bucket's `coverage_pct` | The same two. |

`null` at the group and bucket grain means you asked for neither leg mean, not
that coverage was zero. The exports follow the same rule: one
`<measure>_coverage_pct` column per leg mean requested.

```json theme={null}
"coverage_pct": { "ttft_ms_avg": 61.4, "gateway_overhead_ms_avg": 99.8 }
```

**Two rows can carry the same mean over 100% and over 1% of their requests.**
A `gateway_overhead_ms_avg` of 42 ms at 99.8% coverage is a property of that
group's traffic. The same 42 ms at 1.2% coverage is a property of eleven
requests. Treat a low coverage as a reason to widen the range or not to publish
the number, not as a rounding detail — and show it beside the figure wherever a
person will read it.

Per-group coverage exists because leg coverage is not uniform across a page:
one model can carry both latency legs on every row and the next none at all.

### `estimator` — how a non-additive figure was derived

```json theme={null}
"estimator": {
  "ttft_p95_ms": "ttft_histogram",
  "gateway_overhead_ms_avg": "mean_over_leg_count"
}
```

* **`ttft_histogram`** — the percentile was read off a cumulative histogram, so
  it is accurate to a bucket edge rather than exact. The bounds come back in
  `metadata.histogram_bounds_ms`: 100, 250, 500, 1000, 2000, 5000, 10000 and
  30000 ms. Anything slower than the last bound sits in an implicit overflow
  bucket, so an estimate **saturates at 30000** rather than inventing an upper
  edge. A `ttft_p99_ms` of exactly 30000 means "at least 30 s", not "30 s".
* **`mean_over_leg_count`** — a mean over the rows that carried both latency
  legs, which is also what `coverage_pct` is measured against. The cube stores
  sums and counts, so there is no percentile per leg.

> [!NOTE]
>
> Percentiles are available for time to first token only. There is no
> `latency_p95_ms` today.

### Streamed and non-streamed latency are different quantities

`latency_ms` is wall-clock on a non-streamed request and time-to-last-byte on a
streamed one. One average over both adds two different things together, and the
two legs cut from it inherit that.

So `latency_ms_avg`, `provider_latency_ms_avg` and `gateway_overhead_ms_avg`
require you to **pin `stream` to one value or add it to `group_by`.** Asking
for one without doing either is a `400`.

If you group by `stream`, the groups split correctly but the `totals` row and
`total_series` still span both halves — so those two answer `null` for those
measures rather than a mixed average. The key stays in the object, so you never
have to guess whether a measure was asked for.

## The two tiers

`/v1/analytics/*` reads from two stores, and tells you which one answered.

| Range | Read from |
| - | - |
| Wholly inside the last **31 days** | `usage_facts`, the per-attempt rows |
| Wholly older than that | the hourly rollup cube |
| Crossing the boundary | **both**, as one query |

Three fields carry it:

* `GroupOut.tier` and `SeriesPoint.tier` — `raw`, `cube`, `mixed`, or `null`
  when the row counted nothing. `mixed` is a property of an *answer* — a bucket
  or group whose hours fall on both sides — never a store you can request.
* `metadata.tiers` — the stores this answer read, in time order, e.g.
  `["cube", "raw"]`.
* `metadata.raw_window_since` — where the boundary stood when the query ran, so
  a chart can draw it. `null` when the raw tier is switched off.

**The boundary is hour-aligned**, because a cube row *is* an hour. A boundary of
10:37 takes the whole 11:00 bucket into the raw tier and leaves the 10:00 bucket
to the cube, so every hour belongs to exactly one side and nothing is counted
twice.

**The numbers do not change across it.** Both tiers publish the same measure
definitions, so a range that straddles the boundary is one aggregate, not two
halves stitched together. In particular the raw tier emits the same cumulative
histogram the cube does rather than exact quantiles — percentile semantics are
identical on both sides, and `estimator` still says `ttft_histogram` inside the
window. That is deliberate: an exact quantile inside the window and a bucket-edge
estimate outside it would be two different quantities in one series, and
percentiles do not add, so the halves could not be folded into a range total at
all.

> [!NOTE]
>
> `tier` is published by the summary surface. `/spend/summary`,
> `/reliability/attribution` and `/findings` read both tiers too — so no two
> panels answer one range from different stores — but they have no per-row field
> to report it on.

## Paging

A page comes back with `metadata.truncated` and, when there is more,
`next_cursor`. Pass it as `cursor` with **every other parameter unchanged**.

A cursor is bound to the query that issued it — the scope, range, grouping,
rollup, filters, population, metric list and sort. Change any of them and the
token is refused rather than silently answering about something else;
re-request the first page.

> [!WARNING]
>
> A cursor is also bound to the raw-window boundary, and that boundary moves on
> the hour. For a range reaching into the last few weeks, a page token issued in
> an earlier clock hour is refused. Re-request the first page; it is not an error
> in your code.

Caps that apply before any of this:

| Cap | Value |
| - | - |
| Range | 366 days |
| `group_by` | 2 dimensions |
| `metric` | 12 per request |
| `limit` | 200 groups per page |
| Series points (`limit` × buckets) | 20,000 |

The series cap is checked up front and refused rather than truncated — a
quietly trimmed timeseries would make a quiet hour and a dropped hour look the
same. The error names the three ways out: a coarser rollup, a shorter range, or
a smaller `limit`.

## CSV and Excel export

`GET /usage/summary.csv` and `GET /usage/summary.xlsx` take the same range,
filter, `metric`, `group_by` and `rollup` parameters as `/usage/summary` and
return the same grouped rows — groups, never raw events.

**`limit` and `cursor` are not parameters here.** An export is not paged; it
picks its own bound, and passing either is ignored.

Column order is fixed:

```
traffic_type, retries, tier, since, until, [bucket], <group_by…>, <metric…>, <*_coverage_pct…>
```

`bucket` is present only when you passed `rollup`. The population rides on every
row, so a saved file still says what it counted, and a coverage column is added
per latency-leg measure for the same reason.

**In the CSV only**, any cell whose value begins with `=`, `+`, `-`, `@`, a tab
or a carriage return is written with a leading apostrophe, so a spreadsheet does
not evaluate it as a formula. That covers measure cells as well as dimension
values — a negative `gateway_overhead_ms_avg` arrives as `'-3.2`. Strip it when
loading if your own parser does not. The `.xlsx` adds no prefix: it stores each
value as text instead, so stripping a leading apostrophe there would corrupt a
value that genuinely begins with one.

> [!WARNING]
>
> **The `tier` column is at index 2**, between `retries` and `since`, and it
> shifts every later column one place right. Anything that reads these files by
> column position — a spreadsheet formula, a loader, a script written against an
> export taken before 27 October 2026 — needs updating. Read by header name
> instead.

Both formats bound their output, and the bound depends on whether you asked for
a rollup:

| | Without `rollup` | With `rollup` |
| - | - | - |
| Rows | Up to 50,000 groups | Up to 20,000 — groups × buckets |
| Groups | 50,000 | `20000 ÷ buckets`, rounded down, at least 1 |

So `rollup=day` over 366 days keeps the **top 54 groups**, and `rollup=hour`
over 30 days keeps the top 27. The rest are dropped. If you need every group,
export without a rollup, or narrow the range.

Neither format stops quietly. A file that hit either bound ends with a
`# truncated at N rows` or `# truncated at N groups` line, and the Excel export
also sends `X-Mesh-Analytics-Truncated` and `X-Mesh-Analytics-Truncated-At`
headers — it builds the whole file before responding, where the CSV streams and
can only say so on its last line. `X-Mesh-Analytics-Truncated` is always sent,
`none` included; absent would otherwise mean both "complete" and "the header
never arrived".

Both send `X-Mesh-Analytics-Population` and `X-Mesh-Analytics-Row-Limit`. On a
rollup export, `X-Mesh-Analytics-Row-Limit` carries the **group** limit, not the
row count — the file can hold that many groups times its bucket count.

Concurrent Excel builds are bounded per worker. A request that waits too long
for a slot gets a `429` with `Retry-After`; retry rather than treating it as a
failure.

## Drilling through to the logs

Every chart element should be able to open the requests behind it. Seven of the
dimensions the analytics API groups by are also filters on
[the usage endpoints](https://developers.meshapi.ai/docs/reference/usage-api.md) — `POST /v1/usage` and
`POST /v1/usage/events`, and the dashboard's own events CSV export:

`provider`, `fault_owner`, `is_byok`, `stream`, `traffic_type`,
`is_retry_attempt`, `end_user_id`.

A `member_user_id` bar drills too, through `UsageFilter.member_user_id` — one
exact value, org-wide reach, and a team lead must send `team_id` alongside it.

`end_user_id` takes one exact value on the logs side, not a list as it does
here.

So a bar grouped by `fault_owner=provider` over
`since=2026-10-01T00:00:00Z&until=2026-10-27T00:00:00Z` drills to:

```bash theme={null}
curl https://api.meshapi.ai/v1/usage/events \
  -H "Authorization: Bearer rsk_<key>" \
  -H "Content-Type: application/json" \
  -d '{
        "org_id": "<ORG_ID>",
        "since": "2026-10-01T00:00:00Z",
        "until": "2026-10-26T23:59:59.999999Z",
        "fault_owner": ["provider"],
        "traffic_type": ["external"],
        "is_retry_attempt": [false],
        "limit": 50
      }'
```

> [!WARNING]
>
> **Two defaults differ between the two surfaces. A drill-through has to send
> both explicitly or the rows will not add up to the bar they came from.**
>
> * **`is_retry_attempt` omitted is not "every row".** The logs reader collapses
>   to the attempt that served each request, and always has. The analytics
>   `include_retries=true` population is `is_retry_attempt: [true, false]`.
> * **`traffic_type` omitted *is* "every class"**, where the analytics API
>   defaults to `external`. Send `"traffic_type": ["external"]` whenever the
>   aggregate above it used the default population.
> * **The two surfaces end a range differently.** Analytics `until` is always
>   exclusive. On the logs reader a *bare date* such as `"2026-10-27"` is widened
>   to the end of that day and included, so the same pair of strings covers one
>   extra day of logs. Send a full timestamp on both sides, as above.

`fault_owner` is derived at query time on the logs side, through the same rule
that writes the stored column the charts group by, so the two agree row for row.

## Errors

| Status | When |
| - | - |
| `400` | A query whose *values* are valid but whose combination is not — an inverted or over-long range, an unrankable `rank_by`, too many metrics, a `group_by` over a dimension already fixed to one value, a latency measure without a `stream` split, `end_user_id` over a range older than the raw window, a series over the point cap, or a cursor from a different query. The message names the fix. |
| `401` | Missing, malformed or invalid credential. |
| `403` | The key is valid but lacks the `usage:read` scope, or `member_user_id` was passed by a caller whose reach is not org-wide. |
| `422` | A parameter the API cannot parse at all — an unknown `metric`, `group_by`, `rollup` or `rank_by` name, a non-ISO `since`, a `limit` below 1. |
| `429` | Too many Excel exports building at once. Honour `Retry-After`. |

> [!NOTE]
>
> `400` and `422` do not carry the same body. A `400` is the gateway's standard
> envelope with one `message`; a `422` is
> `{"error": {"code": "validation_error", "message": …, "details": [...]}}`, where
> `details` names each offending parameter. Handle both if you surface API errors
> to a user.

See [Error codes](https://developers.meshapi.ai/docs/reference/errors.md) for the envelope. Every response
carries `X-Request-Id`; quote it in a support request.

## Known limits

* **Percentiles cover time to first token only.** There is no total-latency
  percentile measure yet.
* **Only `/usage/summary` publishes `tier`.** The other three panels read both
  stores but do not report which answered.
* **`end_user_id` reaches back 31 days, not 24 months**, and only on
  `/usage/summary` and its exports.
* **History ends at 24 months**, and one query may span at most 366 days of it.
  [The usage endpoints](https://developers.meshapi.ai/docs/reference/usage-api.md) have no such window — reach
  for those when you need a figure from further back than the cube keeps.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.