Skip to main content
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: Reach for the usage endpoints when you want individual requests, and these when you want a shape of the traffic.
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:
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 and labels below — key_id and member_user_id are already fixed for you, so grouping by either is refused.

The endpoints

/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:
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.
Abridged — the real dimensions and measures arrays carry every one of them, and the other three panels appear beside /usage/summary:
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. 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. /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:
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:
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

Dimensions

Thirteen, and they are not all equally reachable:
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:
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
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.
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 Caching and routing Latency. All in milliseconds.

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

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: 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.
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

  • 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.
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. 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.
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.
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: 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:
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.
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: 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 — 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:
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

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 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 have no such window — reach for those when you need a figure from further back than the cube keeps.