# Reports API

> Run report definitions, list the template gallery, and manage saved reports, their history and dashboards

Source: https://www.apitraffic.io/docs/api-reference/reports

The reports API runs what the [report builder](https://www.apitraffic.io/docs/reports) runs and stores what it saves: saved reports, their revisions, and [dashboards](https://www.apitraffic.io/docs/dashboards). Every path is scoped to an account, and an account can only reach its own records.

**Plan.** Every route needs the **Custom reports** plan feature (`403 FEATURE_NOT_IN_PLAN` otherwise). After a downgrade, what you already saved can still be listed, read (with its revisions) and deleted: `GET` and `DELETE` on saved reports and dashboards keep working, while running, creating and changing them answer `403 FEATURE_NOT_IN_PLAN`. Saving more reports or dashboards than the plan allows answers `409 ENTITLEMENT_LIMIT_REACHED`. Firewall dimensions and metrics (`waf_action`, `waf_blocked_count`, `distinct:waf_rule`, ...), the firewall's decision tags read as tags (`tag:waf.action`, `attr:waf.rule:…`, `distinct:tag:waf.policy`) and filters on them (`waf_action == "block"`, `tag:waf.matched == "1"`) also need the firewall in the plan (`wafEnabled`): without it, running a report with them through `POST /reports/query`, creating a report with them or changing a saved report's definition to one with them, saving a new or changed dashboard filter on them, or adding a report to a dashboard whose filter reads them (or changing the definition of a report that is a tile of one: `data.cause` is then `"dashboardFilter"`, and the message names those dashboards and what to do), answers `403 FEATURE_NOT_IN_PLAN`. A saved report that already uses them keeps running; a report saved with its definition unchanged (a rename, say) and a dashboard saved with its filter unchanged (after arranging its tiles, say) save as usual; and the firewall templates are listed with `available: false`.

**Permissions.** Any member can run reports and create saved reports and dashboards. A private report or dashboard is visible to its owner only (`404` for anyone else). The owner can change or delete it; owners and admins can also change or delete any account-visible one. Each record carries `canEdit` for the caller.

**Secret and personal tags.** A report that groups by, counts or filters on a tag key classified `secret` is refused with `400 INVALID_REPORT`. Values of keys classified `personal` are masked for callers who may not view personal tags, and the key is listed in `meta.masked`.

**IP addresses.** A client's IP address is personal data. Grouped by `ip_address`, a caller who may not view personal tags gets each row's value as an opaque id (`ip_` and 16 hex digits, stable for the account) and its label as the network part only (`203.0.113.x`; an IPv6 address keeps its first three groups, the /48), and `meta.masked` lists `ip_address`. Reports cannot filter on an IP address; they can filter on the country (`country == "DE"`). An embed never shows IP addresses.

**Filters hidden from the caller.** A saved filter that names a `personal` tag key would show its values, so for a caller who may not view personal tags a saved report, each of its revisions and a dashboard come back with `"filterHidden": true` (at the top level of the record or revision) and an empty filter (`definition.filter` or `filter` is `""`). Such a report can't run for that caller, and a dashboard's tiles can't run for them either (see [`POST /savedReports/{reportSid}/run`](#saved-reports)). Writing an empty filter keeps a hidden one as it is; `removeHiddenFilter: true` on `PATCH` clears it.

All examples use `Authorization: Bearer `.

## The report definition

A report definition (`ReportRequest`) is the body of [`POST /reports/query`](#run-a-report) and the `definition` of a saved report.

- **source (body, string)**:
  What the report counts: `requests` (the default, also when left out) or `spans`, the [APM](https://www.apitraffic.io/docs/apm/overview) operations inside requests (database queries, Redis and cache calls, outbound HTTP, queue, RPC and GraphQL work). A span report takes the [span dimensions, metrics and filter fields](#span-reports) instead of the request ones

- **timeRange (body, object, required)**:
  `{ "relative": "last_7d" }` (one of `last_1h`, `last_24h`, `last_7d`, `last_30d`, `last_90d`, `today`, `yesterday`, `this_month`, `previous_month`; UTC days), or `{ "start": "…", "end": "…" }` in ISO 8601. At most 400 days; an end in the future is cut to now

- **groupBy (body, string[])**:
  Up to 2 dimensions: a request field (`method`, `host`, `endpoint`, `endpoint_kind`, `path`, `status_code`, `status_class` (`1xx`, `2xx`, `3xx`, `4xx`, `5xx`, or `unknown` for a missing status or one below 100), `direction`, `bucket_sid`, `environment_sid`, `context_sid`, `ai_kind`, `ai_provider`, `ai_model`, `rpc_method`, `mcp_tool`), `tag:`, `attr::` (a catalogue attribute of a tag's values), or a firewall dimension: `waf_action` (`block`, `would-block` or `tag`), `waf_policy` (the deciding policy's sid; labelled with its description), `waf_rule` (the deciding rule's id; labelled with its message) or `waf_matched` (`yes` or `no`). Requests the firewall did not match have `""` for `waf_action`, `waf_policy` and `waf_rule`. Or a client dimension, both recorded on each request when it is received: `ip_address` (the client's IP address, labelled with it; `""` without one) or `country` (the ISO 3166-1 alpha-2 code of the country the address was located in, labelled with its name; `""`, labelled `(none)`, without one). Requests received before they were recorded are `""`, and `meta.approximations` says from when they are recorded. Omit for totals

- **metrics (body, string[])**:
  1 to 10 of `count`, `error_count`, `server_error_count`, `error_rate`, `avg`, `p50`, `p90`, `p95`, `p99`, `max` (latency in ms), `request_bytes`, `response_bytes`, `distinct:` (not an attribute; `distinct:ip_address` counts client IP addresses, `distinct:country` countries), `sum:` and `avg:` for a numeric field (`duration_ms`, `request_size`, `response_size`, `db_query_count`, `db_time_ms`, `redis_op_count`, `redis_time_ms`, `cache_op_count`, `cache_time_ms`, `http_out_count`, `http_out_time_ms`, `queue_op_count`, `queue_time_ms`, `rpc_op_count`, `rpc_time_ms`, `span_count`, `spans_dropped`, `ai_message_count`, `ai_tool_count`, `ai_input_tokens`, `ai_output_tokens`, `ai_cached_input_tokens`), and the firewall metrics `waf_matched_count`, `waf_blocked_count`, `waf_would_block_count` (request counts), `avg:waf_score` and `waf_score_max` (the anomaly score of matched requests). Default `["count"]`. `distinct:` metrics can't be combined with an attribute dimension. Firewall dimensions and metrics, `ip_address` and `country` are read from stored requests (at most 31 days at a time)

- **filter (body, string)**:
  Criteria in the request search language, up to 4,000 characters, such as `status_code >= 500 AND tag:plan == "enterprise"` or `waf_action == "block"`. Conditions on bodies are refused (`400 INVALID_FILTER_CRITERIA`)

- **scope (body, object)**:
  `bucketSid`, `environmentSid` and `direction` (`inbound` or `outbound`); each optional

- **interval (body, string)**:
  `auto` (about 200 points), `1m`, `5m`, `15m`, `1h`, `6h` or `1d`. Adds a series to each row. At most 500 points. Omit for totals only

- **compareTo (body, string)**:
  `previous_period`, `same_period_last_week` or `same_period_last_month`

- **orderBy (body, object)**:
  `{ "metric": "", "dir": "desc" | "asc" }`. Default: `count` if asked for, else the first metric, descending

- **limit (body, number)**:
  Top rows of the first dimension, 1 to 100. Default 25

- **secondaryLimit (body, number)**:
  Top values of the second dimension within each row, 1 to 50. Default 10

- **includeOther (body, boolean)**:
  Add an Other row for everything outside the top rows, so rows add up to the totals. Default `true`

- **resolveLabels (body, boolean)**:
  Add catalogue labels for tag values in `labels`. Default `true`

- **precision (body, string)**:
  `fast` (default) prefers aggregates; `exact` reads stored requests

A report may hold at most 20,000 values (rows × second-dimension values × points); a larger one is refused with a `400` that says what to lower.

## Span reports

With `"source": "spans"` a report counts spans instead of requests ([Span reports](https://www.apitraffic.io/docs/span-reports)). Everything else in the definition (time range, interval, comparison, limits, Other, ordering) works the same, with these differences:

- **Plan.** Span reports need **APM** as well as **Custom reports**: running or saving one without it answers `403 FEATURE_NOT_IN_PLAN` with `data.feature: "apmEnabled"`. Accounts without a span source (no spans sent yet) get an empty result whose `meta.reasons` says so.
- **Window.** Spans are kept for a shorter time than requests: the window is first cut to the account's span retention (`meta.clamped`, `meta.clampReason: "retention"`), and a span report reads at most 7 days of stored spans; a longer window answers `400 INVALID_REPORT`, except on a dashboard tile run (`dashboardSid`), where it is cut to its last 7 days instead (`meta.clamped: true`, `meta.clampReason: "span_window"`, `meta.clampDays: 7`, and a sentence in `meta.reasons`), on every plan. A comparison window then follows the shortened window, and an `auto` interval is picked for it. `scope.direction: "outbound"` is refused (spans belong to the inbound requests that ran them; outbound calls are `http.out` spans).
- **Dimensions** (`groupBy`, also filter fields): `type` (span type), `fingerprint` (operation; rows carry the operation's name in `labels.fingerprint`, never statement text), `name`, `op` (verb), `target`, `system`, `db`, `host` (peer host), `out_target` (outbound method, host and path), `out_status_class` (`2xx` to `5xx`, `none` for no response), `error_class`, `error_type`, `endpoint` (the endpoint that ran the span), `call_site`, `service_version`, `instance_sid`, `bucket_sid`, `environment_sid`. `tag:` and `attr:` dimensions are refused: tags are not recorded on spans yet.
- **Metrics:** `count` (calls), `error_count` (failed calls), `error_rate`, `total_time` (ms), `avg`, `p50`, `p90`, `p95`, `p99`, `max` (duration in ms), `requests` (requests with the operation, approximate; estimated under sampling), `calls_per_request` (N+1: stored spans per stored request, which sampling does not bias), `out_client_error_count`, `out_server_error_count`, `out_no_response_count` (`http.out` spans only), `sum:rows`, `avg:rows`, `distinct:`. `server_error_count`, `request_bytes`, `response_bytes` and the request `sum:`/`avg:` fields are refused. Rows can be picked in SQL by `count`, `error_count` and `total_time`.
- **Sampling.** Counts and times are weighted by each span's sample weight, so they estimate every call when spans are sampled; `meta.sampled` is `true` (and `approximations` says so) only when sampling was in effect for the window. Percentiles come from fixed latency buckets (approximate) unless `precision` is `exact`.
- **Filter:** the same grammar as the request search language, over the dimensions above plus `duration_ms` and `rows` (numbers), `out_status_code` (number, `http.out` only) and `ok` (`true` or `false`), for example `type == "db.query" AND duration_ms > 100`. `tag:` and request fields are refused with `400 INVALID_FILTER_CRITERIA`.
- **Embeds.** Span reports, and dashboards with span tiles, can't be embedded yet: turning embedding on or minting a token for one answers `400`.

```bash
curl -X POST https://api.apitraffic.io/v1/accounts/{accountSid}/reports/query \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{
    "source": "spans",
    "timeRange": { "relative": "last_7d" },
    "filter": "type == \"db.query\"",
    "groupBy": ["fingerprint"],
    "metrics": ["total_time", "p95", "count"],
    "orderBy": { "metric": "total_time" },
    "limit": 1
  }'
```

```json
{
  "rows": [
    { "dims": { "fingerprint": "fpr_3c1e9a0b5d7f2468" }, "labels": { "fingerprint": "SELECT users" },
      "metrics": { "total_time": 184220.5, "p95": 41.2, "count": 12840 } },
    { "dims": { "fingerprint": "__other__" }, "labels": { "fingerprint": "Other" }, "other": true,
      "metrics": { "total_time": 22104, "p95": 12.8, "count": 9311 } }
  ],
  "totals": { "total_time": 206324.5, "p95": 38.9, "count": 22151 },
  "meta": {
    "source": "raw", "dataset": "spans", "sampled": false,
    "segments": [ { "source": "raw", "from": "2026-10-13T09:00:00.000Z", "to": "2026-10-20T09:00:00.000Z" } ],
    "exact": false, "approximations": [ "Percentiles are estimated from fixed latency buckets." ],
    "otherShare": 0.420342, "missingValueShare": null, "masked": [], "clamped": false,
    "window": { "from": "2026-10-13T09:00:00.000Z", "to": "2026-10-20T09:00:00.000Z" },
    "intervalSeconds": null, "reasons": [ "Stored spans are read." ], "queries": 2
  }
}
```

## The report result

- **rows (array)**:
  One per group, ordered by `orderBy` with Other last. Each has `dims` (dimension → value; `__other__` for the Other row, `""` for requests without the tag, firewall decision or client IP address), `labels` (dimension → catalogue label, a firewall policy's or rule's name, an IP address (masked: its network part), or a country's name), `other: true` on the Other row, `metrics` (metric → number or `null`), with `compareTo` a `compare` (metric → `value` (the earlier period's), `delta`, `delta_pct`), and with `interval` a `series` (one entry per point: `t` and each metric)

- **totals (object)**:
  Each metric over every request in scope

- **compareTotals (object)**:
  With `compareTo`: the totals' comparison, shaped like a row's `compare`

- **points (string[])**:
  With `interval`: the start of each point, ISO 8601

- **meta (object)**:
  How the numbers were made: `dataset` (`requests` or `spans`: what was counted), `source` (`aggregates`, `raw` or `mixed`: where it was read from), `aggregateFamily` (with aggregates: `requests` or `endpoint`, the pre-aggregated rows read), `sampled` (span reports: whether sampling was in effect, so counts are estimates), `segments` (`source`, `from`, `to` for each range read), `exact`, `approximations` (sentences), `otherShare` (0 to 1 of requests folded into Other), `missingValueShare` (0 to 1 of requests without the grouped tag), `masked` (tag keys shown masked, and `ip_address` when IP addresses are), `attribution` (`text` and `url`: the credit for the geo data, present when the report shows countries; show it with them), `clamped` (true when the window was cut to the account's retention or, for a span tile on a dashboard, to the last days span reports read), `clampReason` and `clampDays` (span reports only, when `clamped`: `retention` with the days spans are kept, or `span_window` with the days a span report reads), `window` and `compareWindow` (`from`, `to`), `intervalSeconds`, `reasons` (why this source), `clientIpMode` (`truncated` or `off`, when the report reads IP addresses and the account [keeps networks only or none](https://www.apitraffic.io/docs/account/client-ip-addresses)), `promotable` (the tag key whose promotion alone would let pre-aggregated data answer this report: a registered key, not promoted or classified secret, that the report groups by, with nothing else in the report that pre-aggregated data does not hold; otherwise empty, and always empty for span reports and for reports with a firewall or client dimension or a firewall metric, which only stored requests answer), `queries`

## Run a report

`POST https://api.apitraffic.io/v1/accounts/{accountSid}/reports/query`

Run a report definition once, without saving it. The body is a [report definition](#the-report-definition), plus `explain: true` to add `plan` to the response (the path, the pre-aggregated rows read, segments and reasons the planner chose).

```bash
curl -X POST https://api.apitraffic.io/v1/accounts/{accountSid}/reports/query \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{
    "timeRange": { "relative": "last_7d" },
    "groupBy": ["tag:customerId"],
    "metrics": ["count", "error_rate", "p95"],
    "compareTo": "previous_period",
    "limit": 2
  }'
```

```json
{
  "rows": [
    { "dims": { "tag:customerId": "org_8f2a" }, "labels": { "tag:customerId": "Acme Corp" },
      "metrics": { "count": 182341, "error_rate": 0.0042, "p95": 412 },
      "compare": { "count": { "value": 150002, "delta": 32339, "delta_pct": 21.56 },
                   "error_rate": { "value": 0.0051, "delta": -0.0009, "delta_pct": -17.65 },
                   "p95": { "value": 398, "delta": 14, "delta_pct": 3.52 } } },
    { "dims": { "tag:customerId": "org_19c0" }, "labels": {}, "metrics": { "count": 90211, "error_rate": 0.0011, "p95": 230 },
      "compare": { "count": { "value": 88000, "delta": 2211, "delta_pct": 2.51 },
                   "error_rate": { "value": 0.001, "delta": 0.0001, "delta_pct": 10 },
                   "p95": { "value": 240, "delta": -10, "delta_pct": -4.17 } } },
    { "dims": { "tag:customerId": "__other__" }, "labels": { "tag:customerId": "Other" }, "other": true,
      "metrics": { "count": 4120, "error_rate": 0.002, "p95": 300 },
      "compare": { "count": { "value": 3900, "delta": 220, "delta_pct": 5.64 },
                   "error_rate": { "value": 0.002, "delta": 0, "delta_pct": 0 },
                   "p95": { "value": 310, "delta": -10, "delta_pct": -3.23 } } }
  ],
  "totals": { "count": 276672, "error_rate": 0.0032, "p95": 380 },
  "compareTotals": { "count": { "value": 241902, "delta": 34770, "delta_pct": 14.37 },
                     "error_rate": { "value": 0.0038, "delta": -0.0006, "delta_pct": -15.79 },
                     "p95": { "value": 377, "delta": 3, "delta_pct": 0.8 } },
  "meta": {
    "source": "aggregates",
    "segments": [ { "source": "aggregates", "from": "2026-10-13T09:00:00.000Z", "to": "2026-10-20T09:00:00.000Z" } ],
    "compareSource": "aggregates",
    "exact": false,
    "approximations": [ "Percentiles are estimated from fixed latency buckets.", "Aggregates keep each minute's busiest values; the rest are counted as Other." ],
    "otherShare": 0.014891,
    "missingValueShare": 0.031,
    "masked": [],
    "window": { "from": "2026-10-13T09:00:00.000Z", "to": "2026-10-20T09:00:00.000Z" },
    "compareWindow": { "from": "2026-10-06T09:00:00.000Z", "to": "2026-10-13T09:00:00.000Z" },
    "intervalSeconds": null,
    "reasons": [ "customerId's aggregates cover the whole window." ],
    "promotable": [],
    "queries": 4
  }
}
```

A definition the engine refuses answers `400 INVALID_REPORT` with the reason in `message`. When the only problem is that it reads more than 31 days of stored requests and promoting the tag key it groups by would let pre-aggregated data answer it, `data.promotable` names that key (`{ "promotable": ["customerId"] }`); promote it with the [Dimensions API](https://www.apitraffic.io/docs/api-reference/dimensions). Failed reads answer like other analytics reads: `504 TELEMETRY_TIMEOUT` (shorten the window, add a filter or promote the key), `503 TELEMETRY_OVERLOADED` with `Retry-After`.

## Templates

`GET https://api.apitraffic.io/v1/accounts/{accountSid}/reportTemplates`

The template gallery. `{customerKey}` in a template's definition is replaced with the account's customer key. Without one, the templates that need it are unavailable.

- **records (array)**:
  Each: `key`, `name`, `description`, `visualization`, `definition` (a report definition), `requires` (for example `["customerKey"]`), `available`, and `unavailableReason` when not available

```json
{
  "records": [
    { "key": "endpoint_overview", "name": "Endpoint overview", "description": "Requests, error rate and p95 per endpoint.",
      "visualization": "table", "requires": [], "available": true,
      "definition": { "timeRange": { "relative": "last_24h" }, "groupBy": ["endpoint"], "metrics": ["count", "error_rate", "p95"] } },
    { "key": "top_customers", "name": "Top customers by volume", "description": "Your busiest customers.",
      "visualization": "bar", "requires": ["customerKey"], "available": false,
      "unavailableReason": "Pick a customer key in Settings → Dimensions first.",
      "definition": { "timeRange": { "relative": "last_30d" }, "groupBy": ["tag:{customerKey}"], "metrics": ["count", "error_rate", "p95"] } }
  ]
}
```

Keys: `endpoint_overview`, `top_customers`, `customer_errors`, `customer_latency_trend`, `endpoint_usage_by_customer`, `active_customers`, `usage_by_plan`, `outbound_vendor_usage`, `llm_tokens_by_customer`, and the [span report](#span-reports) templates `slowest_queries`, `outbound_by_peer`, `errors_by_span_type` and `n_plus_one`, listed only when the plan includes APM.

## Saved reports

A saved report: `sid` (`rpt_…`), `name`, `description`, `definition`, `visualization` (`table`, `line`, `stacked_area`, `bar`, `stacked_bar` or `number`), `display` (how it is drawn beyond its visualization, with the defaults filled in: `{ "table": "collapsed" }` unless set), `visibility` (`private` or `account`), `ownerUserSid`, `folder`, `pinned`, `embeddable`, `definitionVersion`, `templateKey`, `createdAt`, `updatedAt` and `canEdit`.

`GET https://api.apitraffic.io/v1/accounts/{accountSid}/savedReports`

Your private reports and every account-visible one.

- **folder (query, string)**:
  Only reports in this folder

- **pinned (query, boolean)**:
  Only pinned (`true`) or unpinned (`false`) reports

Returns `{ "records": [ …saved reports ] }`.

`POST https://api.apitraffic.io/v1/accounts/{accountSid}/savedReports`

Save a report. Returns `201` with the report.

- **name (body, string, required)**:
  1 to 120 characters

- **description (body, string)**:
  Up to 1,000 characters

- **definition (body, object, required)**:
  A [report definition](#the-report-definition), checked as `POST /reports/query` checks it. Use a relative `timeRange` so the report always covers recent traffic

- **visualization (body, string, required)**:
  `table`, `line`, `stacked_area`, `bar`, `stacked_bar` or `number`

- **display (body, object)**:
  How the report is drawn beyond its visualization. Its only field, `table`, sets the data table under a chart or a single number: `hidden` (only the chart; screen readers still get the table), `collapsed` (behind **Show the numbers**, the default) or `shown`. A `table` report ignores it. Any other field or value is `400`. `null` (or `{}`) means the defaults. The builder, dashboard tiles and embeds all follow it

- **visibility (body, string)**:
  `private` (default) or `account`

- **folder (body, string)**:
  Up to 80 characters; `null` for none

- **pinned (body, boolean)**:
  List it first in Reports

- **templateKey (body, string)**:
  The template it was made from

```bash
curl -X POST https://api.apitraffic.io/v1/accounts/{accountSid}/savedReports \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{
    "name": "Errors by customer",
    "definition": { "timeRange": { "relative": "last_7d" }, "groupBy": ["tag:customerId"], "metrics": ["error_count", "error_rate"],
                    "orderBy": { "metric": "error_count", "dir": "desc" } },
    "visualization": "table",
    "visibility": "account",
    "folder": "Support"
  }'
```

```json
{
  "sid": "rpt_6Hq2…", "name": "Errors by customer", "description": null,
  "definition": { "timeRange": { "relative": "last_7d" }, "groupBy": ["tag:customerId"], "metrics": ["error_count", "error_rate"],
                  "orderBy": { "metric": "error_count", "dir": "desc" } },
  "visualization": "table", "display": { "table": "collapsed" }, "visibility": "account", "ownerUserSid": "usr_…", "folder": "Support", "pinned": false,
  "definitionVersion": 1, "templateKey": null, "createdAt": "2026-10-20T09:00:00.000Z", "updatedAt": "2026-10-20T09:00:00.000Z", "canEdit": true
}
```

`GET https://api.apitraffic.io/v1/accounts/{accountSid}/savedReports/{reportSid}`

One saved report. `404` for a report that is private to someone else. It also carries `onEmbeddedDashboard`: `true` when an embeddable dashboard shows it, so only owners and admins may change its definition.

`PATCH https://api.apitraffic.io/v1/accounts/{accountSid}/savedReports/{reportSid}`

Change any of the fields `POST` takes. A change to `definition`, `visualization` or `name` keeps the previous version as a revision and adds 1 to `definitionVersion`. A change to `display` does not (it replaces the whole object; `null` resets it), and while the report is embedded anyone who can edit it may still change it. Returns the report.

- **removeHiddenFilter (body, boolean)**:
  `true` clears the report's stored filter. Meant for a filter hidden from the caller (`filterHidden`): it is deleted and lost. A non-empty `filter` in the same request's `definition` wins and is stored instead. A `definition` whose `filter` is empty, without this flag, keeps a hidden filter. While a hidden filter stays, changing a shared report's definition is refused with `400 INVALID_REPORT` ("This shared report filters on a personal tag you can't view, so its settings can't change while that filter stays. Remove the filter (it will be lost), or ask someone who can view personal tags.")

- **embeddable (body, boolean)**:
  `true` lets [embed tokens](https://www.apitraffic.io/docs/integrations/embed-reports) show this report. Only an account owner or admin (or an administrator API token) can turn it on, and only on an account-visible report: otherwise `403 NOT_AUTHORIZED` or `400 INVALID_PARAMETERS`. Turning it on needs the `embedsEnabled` plan feature (`403 FEATURE_NOT_IN_PLAN`) and a free `embeddedViews` slot (`409 ENTITLEMENT_LIMIT_REACHED`). `false` stops every embed of it; anyone who can edit it may turn it off. Making it private turns it off too.

```bash
curl -X PATCH https://api.apitraffic.io/v1/accounts/{accountSid}/savedReports/rpt_6Hq2… \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{ "visualization": "bar", "pinned": true }'
```

`DELETE https://api.apitraffic.io/v1/accounts/{accountSid}/savedReports/{reportSid}`

Delete a report and its revisions. Dashboard tiles that show it are removed. Returns `204`. This works after a downgrade too, so you can clean up what you saved.

`GET https://api.apitraffic.io/v1/accounts/{accountSid}/savedReports/{reportSid}/revisions`

Every version, newest first, at most 50. The first is the current version (its `version` equals the report's `definitionVersion`). A version whose filter is hidden from the caller has `"filterHidden": true` and an empty `definition.filter`.

```json
{
  "records": [
    { "version": 1, "name": "Errors by customer", "visualization": "table",
      "definition": { "timeRange": { "relative": "last_7d" }, "groupBy": ["tag:customerId"], "metrics": ["error_count", "error_rate"] },
      "changedBy": "usr_…", "createdAt": "2026-10-20T09:00:00.000Z" }
  ]
}
```

`POST https://api.apitraffic.io/v1/accounts/{accountSid}/savedReports/{reportSid}/run`

Run a saved report, optionally with overrides. This is how a dashboard runs its tiles. Every field is optional.

A report whose own filter names a personal tag the caller may not view is refused with `400 INVALID_REPORT` ("Values of the tag key '…' are personal and masked for your role: a report cannot filter on them.").

- **dashboardSid (body, string)**:
  The dashboard this run is a tile of. Send it with every tile run: when that dashboard's filter is hidden from the caller (`filterHidden`), the run is refused with `400 INVALID_REPORT` ("This dashboard filters on a personal tag you can't view, so its tiles can't run for you.") rather than run without the filter. A `dash_…` sid; a dashboard that was deleted or is no longer visible to the caller answers `400 INVALID_REPORT` ("This dashboard was deleted or is no longer shared with you.")

- **timeRange (body, object)**:
  Replaces the report's time range

- **filter (body, string)**:
  Added to the report's own filter: the report counts requests that match both (`(report filter) AND (this filter)`). For a [span report](#span-reports) it is in the span filter language, except on a tile run (`dashboardSid`): a dashboard's filter is request language, and on a span tile only `endpoint` and `instance` conditions apply. Any other field answers `400 INVALID_REPORT` ("This dashboard's filter uses status_code, which spans don't have."), as does a dashboard scoped to `outbound` traffic. A filter on the firewall's decisions (`waf_action == "block"`, `tag:waf.matched == "1"`) needs the firewall in the plan (`403 FEATURE_NOT_IN_PLAN`), except on a tile run of the dashboard it is saved on (`dashboardSid`, the report one of its tiles, and the dashboard's own filter): that keeps running after a downgrade, as the saved report does

- **scope (body, object)**:
  `bucketSid`, `environmentSid`, `direction`: each one set replaces the report's

```bash
curl -X POST https://api.apitraffic.io/v1/accounts/{accountSid}/savedReports/rpt_6Hq2…/run \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{ "dashboardSid": "dash_3Nf8…", "timeRange": { "relative": "last_24h" }, "filter": "tag:customerId == \"org_8f2a\"", "scope": { "environmentSid": "env_…" } }'
```

Returns a [report result](#the-report-result) whose `meta` also has `report`: `{ "sid", "name", "visualization", "display" }`.

## Dashboards

A dashboard: `sid` (`dash_…`), `name`, `description`, `visibility` (`private` or `account`), `ownerUserSid`, `filter` (criteria applied to every tile), `scope` (`bucketSid`, `environmentSid`, `direction`), `timeRange` (default `{ "relative": "last_24h" }`), `layout`, `embeddable`, `createdAt`, `updatedAt` and `canEdit`, plus `"filterHidden": true` (with `filter` as `""`) when the filter names a personal tag the caller may not view.

`layout` is `{ "tiles": [ … ] }`, at most 24 tiles on a grid 12 columns wide. Each tile: `id` (unique in the dashboard), `reportSid`, `x` (0 to 11), `y` (0 or more), `w` (1 to 12, with `x + w` at most 12), `h` (1 to 8) and an optional `title`.

`GET https://api.apitraffic.io/v1/accounts/{accountSid}/dashboards`

Your private dashboards and every account-visible one. Returns `{ "records": [ …dashboards ] }`.

`POST https://api.apitraffic.io/v1/accounts/{accountSid}/dashboards`

Create a dashboard. Returns `201` with the dashboard.

- **name (body, string, required)**:
  1 to 120 characters

- **description (body, string)**:
  Up to 1,000 characters

- **visibility (body, string)**:
  `private` (default) or `account`

- **filter (body, string)**:
  Criteria applied to every tile, up to 4,000 characters; `""` for none. Checked on write (`400 INVALID_FILTER_CRITERIA`)

- **scope (body, object)**:
  `bucketSid`, `environmentSid`, `direction`

- **timeRange (body, object)**:
  As in a report definition

- **layout (body, object)**:
  `{ "tiles": [ … ] }`. Every `reportSid` must be a report the caller can see; an account-visible dashboard may only hold account-visible reports (`400` otherwise)

```bash
curl -X POST https://api.apitraffic.io/v1/accounts/{accountSid}/dashboards \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{
    "name": "Customer health",
    "visibility": "account",
    "filter": "tag:customerId == \"org_8f2a\"",
    "timeRange": { "relative": "last_7d" },
    "layout": { "tiles": [
      { "id": "t1", "reportSid": "rpt_6Hq2…", "x": 0, "y": 0, "w": 6, "h": 3 },
      { "id": "t2", "reportSid": "rpt_9Kd1…", "x": 6, "y": 0, "w": 6, "h": 3 }
    ] }
  }'
```

```json
{
  "sid": "dash_3Nf8…", "name": "Customer health", "description": null, "visibility": "account", "ownerUserSid": "usr_…",
  "filter": "tag:customerId == \"org_8f2a\"", "scope": {}, "timeRange": { "relative": "last_7d" },
  "layout": { "tiles": [
    { "id": "t1", "reportSid": "rpt_6Hq2…", "x": 0, "y": 0, "w": 6, "h": 3 },
    { "id": "t2", "reportSid": "rpt_9Kd1…", "x": 6, "y": 0, "w": 6, "h": 3 }
  ] },
  "createdAt": "2026-10-20T09:00:00.000Z", "updatedAt": "2026-10-20T09:00:00.000Z", "canEdit": true
}
```

`GET https://api.apitraffic.io/v1/accounts/{accountSid}/dashboards/{dashboardSid}`

One dashboard. `404` for a dashboard that is private to someone else. To show it, run each tile with [`POST /savedReports/{reportSid}/run`](#saved-reports), passing the dashboard's `sid` as `dashboardSid`, and its `timeRange`, `filter` and `scope`. When the dashboard has `filterHidden`, its tiles can't run for the caller.

`PATCH https://api.apitraffic.io/v1/accounts/{accountSid}/dashboards/{dashboardSid}`

Change any of the fields `POST` takes; the same checks apply. Returns the dashboard. An empty `filter` keeps a filter hidden from the caller as it is.

- **removeHiddenFilter (body, boolean)**:
  `true` clears the dashboard's stored filter, so its tiles can run for the caller. Meant for a filter hidden from them (`filterHidden`): it is deleted and lost. A non-empty `filter` in the same request wins and is stored instead

- **embeddable (body, boolean)**:
  `true` lets [embed tokens](https://www.apitraffic.io/docs/integrations/embed-reports) show this dashboard and its tiles. Only an account owner or admin (or an administrator API token) can turn it on, and only on an account-visible dashboard: otherwise `403 NOT_AUTHORIZED` or `400 INVALID_PARAMETERS`. Turning it on needs the `embedsEnabled` plan feature (`403 FEATURE_NOT_IN_PLAN`) and a free `embeddedViews` slot (`409 ENTITLEMENT_LIMIT_REACHED`). `false` stops every embed of it; anyone who can edit it may turn it off. Making it private turns it off too.

```bash
curl -X PATCH https://api.apitraffic.io/v1/accounts/{accountSid}/dashboards/dash_3Nf8… \
  -H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
  -d '{ "filter": "tag:customerId == \"org_19c0\"", "timeRange": { "relative": "last_30d" } }'
```

`DELETE https://api.apitraffic.io/v1/accounts/{accountSid}/dashboards/{dashboardSid}`

Delete a dashboard. Its reports are kept. Returns `204`. This works after a downgrade too.
