## Run a report query

**post** `/v2/reports/query`

Executes an ad-hoc reporting query: one metric + aggregation over a date range, with optional filters, grouping, segmentation and period-over-period comparison.

### Building a query

1. Pick a `metric` and `aggregation` from the datasets catalog (`GET /v2/reports/datasets`). Counts use `count`; duration metrics support `sum`, `avg`, `median`, `min`, `max`, `range` and `percentile` (pass `percentile: 95` for p95); rate metrics use `value`.
1. Set the reporting window with `startDate` / `endDate` (ISO 8601) and a `granularity` (`hour`, `day`, `week`, `month`) for the returned time series. Reporting data is available from **July 12, 2026**. Windows ending earlier are rejected; crossing windows are clamped to that boundary in the requested timezone.
1. Optionally narrow with `filters` — a rule (`{ "kind": "rule", "fieldId": "...", "operator": "in", "value": [...] }`) or an `and`/`or` group of rules. Attribute IDs and allowed operators come from the catalog.
1. Optionally break results down with `groupBy` (primary dimension) and `segmentBy` (secondary dimension).
1. Optionally pass `compareStartDate` / `compareEndDate` to get `previousValue` / `deltaPercent` alongside every data point.

### Top-N and Show Other

For high-cardinality breakdowns, cap the number of returned series:

- `topValuesLimit` keeps only the top **N** `groupBy` values; `segmentTopValuesLimit` does the same for `segmentBy`. Allowed values: **5, 7, 10, 15, 20**. `topValuesLimit` requires `groupBy`; `segmentTopValuesLimit` requires `segmentBy`.
- Members are ranked by the selected metric/aggregation over the **full filtered primary range**, so the ranked set is **stable across every time bucket** (a value that spikes in one bucket but is small overall does not enter the set). A comparison period reuses the **primary period's** ranked set — it is never re-ranked, so the legend stays identical period-over-period.
- `showOther` / `segmentShowOther` append a single synthetic bucket with id `__other__` and label `Other` that sums every value outside the top set. Because the values are summed, Show Other is only supported for **additive aggregations** (`count`, `sum`, `value`); it is rejected for `avg`, `median`, `min`, `max`, `range`, `percentile`, and for percentage metrics (whose ratios cannot be summed). `showOther` also requires `topValuesLimit` (and `segmentShowOther` requires `segmentTopValuesLimit`). Plain `topValuesLimit` (without Show Other) works with any aggregation.
- The `__other__` group/segment aggregates rows outside the selected top groups and **cannot be drilled into** (`POST /v2/reports/drill-in` rejects `dataPointFilters.groupValue` / `segmentValue` equal to `__other__`).

### Example

```json
{
  "metric": "new_conversations",
  "aggregation": "count",
  "startDate": "2026-07-12",
  "endDate": "2026-07-15",
  "granularity": "day",
  "groupBy": "conversation.channel",
  "filters": {
    "kind": "rule",
    "fieldId": "conversation.state",
    "operator": "is",
    "value": "closed"
  }
}
```

### Response shape

- `value` — the aggregate across the whole window
- `timeSeries` — one datum per granularity bucket
- `groupedData` / `segmentData` — present when `groupBy` / `segmentBy` were requested
- `flowData` — present for `view: "sankey"` (Overview conversation-flow metrics)
- `meta` — echo of the resolved metric, dataset, unit and aggregation

### Special views

- `view: "hourly_heatmap"` buckets by day-of-week × hour-of-day (use with volume metrics)
- `view: "sankey"` returns conversation flow edges (Overview metrics only)

### Version Availability

This endpoint is only available in API version 2026-01-01.nova and newer, and only for workspaces with the Reports product enabled (404 otherwise).

### Header Parameters

- `"Featurebase-Version": optional "2026-01-01.nova" or "2025-12-12.clover"`

  - `"2026-01-01.nova"`

  - `"2025-12-12.clover"`

### Body Parameters

- `aggregation: "count" or "sum" or "avg" or 6 more`

  Aggregation function applied to the metric. Each metric supports a subset of aggregations — see the `allowedAggregations` field in the `GET /v2/reports/datasets` catalog.

  - `"count"`

  - `"sum"`

  - `"avg"`

  - `"median"`

  - `"min"`

  - `"max"`

  - `"range"`

  - `"percentile"`

  - `"value"`

- `endDate: string`

  End of the reporting window (ISO 8601 date or datetime, inclusive). Windows ending before 2026-07-12 are rejected.

- `granularity: "hour" or "day" or "week" or "month"`

  Time bucket size for the returned time series.

  - `"hour"`

  - `"day"`

  - `"week"`

  - `"month"`

- `metric: string`

  Metric ID to query (e.g. `new_conversations`). Discover metric IDs via `GET /v2/reports/datasets`.

- `startDate: string`

  Start of the reporting window (ISO 8601 date or datetime, inclusive). Reporting data is available from 2026-07-12; crossing windows are clamped to that boundary.

- `compareEndDate: optional string`

  End of the comparison window. Must be paired with `compareStartDate`.

- `compareStartDate: optional string`

  Start of the comparison window for period-over-period deltas. Must be paired with `compareEndDate`.

- `filters: optional ReportFilterRule or ReportFilterExpression`

  Filter expression: a single rule, or an `and`/`or` group combining rules and nested groups. Attribute IDs and their allowed operators come from `GET /v2/reports/datasets`.

  - `ReportFilterRule object { fieldId, kind, operator, value }`

    - `fieldId: string`

      Attribute ID to filter on (e.g. `conversation.channel`). Discover attribute IDs via `GET /v2/reports/datasets`.

    - `kind: "rule"`

      - `"rule"`

    - `operator: "is" or "is_not" or "in" or 10 more`

      Comparison operator. Each attribute supports a subset of operators — see the `allowedOperators` field in the `GET /v2/reports/datasets` catalog.

      - `"is"`

      - `"is_not"`

      - `"in"`

      - `"not_in"`

      - `"contains"`

      - `"not_contains"`

      - `"gte"`

      - `"lte"`

      - `"between"`

      - `"exists"`

      - `"not_exists"`

      - `"is_member_of"`

      - `"is_not_member_of"`

    - `value: optional string or number or boolean or array of string or number or boolean`

      Value to compare against. Scalar for `is`, `is_not`, `contains`, `not_contains`, `gte`, `lte` (ISO date strings for date attributes); non-empty array for `in`, `not_in`, `is_member_of`, `is_not_member_of`; 2-element array for `between`; omit for `exists` / `not_exists`.

      - `string`

      - `number`

      - `boolean`

      - `array of string or number or boolean`

        - `string`

        - `number`

        - `boolean`

  - `ReportFilterExpression object { children, kind, op }`

    - `children: array of ReportFilterRule or object { children, kind, op }`

      Rules and/or nested groups of rules. Groups may nest up to 6 levels at runtime; 100 rules max per expression.

      - `ReportFilterRule object { fieldId, kind, operator, value }`

      - `ReportFilterGroup object { children, kind, op }`

        - `children: array of ReportFilterRule`

          - `fieldId: string`

            Attribute ID to filter on (e.g. `conversation.channel`). Discover attribute IDs via `GET /v2/reports/datasets`.

          - `kind: "rule"`

          - `operator: "is" or "is_not" or "in" or 10 more`

            Comparison operator. Each attribute supports a subset of operators — see the `allowedOperators` field in the `GET /v2/reports/datasets` catalog.

          - `value: optional string or number or boolean or array of string or number or boolean`

            Value to compare against. Scalar for `is`, `is_not`, `contains`, `not_contains`, `gte`, `lte` (ISO date strings for date attributes); non-empty array for `in`, `not_in`, `is_member_of`, `is_not_member_of`; 2-element array for `between`; omit for `exists` / `not_exists`.

        - `kind: "group"`

          - `"group"`

        - `op: "and" or "or"`

          Group operator: `and` (all match) or `or` (any match).

          - `"and"`

          - `"or"`

    - `kind: "group"`

      - `"group"`

    - `op: "and" or "or"`

      Group operator: `and` (all match) or `or` (any match).

      - `"and"`

      - `"or"`

- `groupBy: optional string`

  Attribute ID to group results by (e.g. `conversation.channel`). See `supportsGroupBy` in the catalog.

- `officeHoursOnly: optional boolean`

  Restrict time-based metrics to configured office hours. Only supported by some metrics (see `supportsOfficeHours` in the catalog).

- `percentile: optional number`

  Percentile (1-100) — required when `aggregation` is `percentile`.

- `segmentBy: optional string`

  Attribute ID for secondary segmentation within each group or time bucket.

- `segmentShowOther: optional boolean`

  When `true`, fold `segmentBy` values outside the top set into a synthetic `__other__` / `Other` segment. Only supported for additive aggregations (`count`, `sum`, `value`) and not for percentage metrics. The `__other__` segment cannot be drilled into. Requires `segmentBy` and `segmentTopValuesLimit`.

- `segmentTopValuesLimit: optional 5 or 7 or 10 or 2 more`

  Keep only the top N `segmentBy` values per series, ranked by the selected metric over the full filtered primary range. Under a time View-by the ranked segment set is identical across every bucket; under a dimension View-by the ranking is applied within each retained parent group. Allowed values: `5`, `7`, `10`, `15`, `20`. Requires `segmentBy`.

  - `5`

  - `7`

  - `10`

  - `15`

  - `20`

- `showOther: optional boolean`

  When `true`, append a single synthetic group with id `__other__` and label `Other` that sums the values of every `groupBy` value outside the top set. Only supported for additive aggregations (`count`, `sum`, `value`) and not for percentage metrics. The `__other__` group cannot be drilled into. Requires `groupBy` and `topValuesLimit`.

- `timezone: optional string`

  IANA timezone for date bucketing (e.g. `America/New_York`). Defaults to UTC.

- `topValuesLimit: optional 5 or 7 or 10 or 2 more`

  Keep only the top N `groupBy` (View-by) values, ranked by the selected metric/aggregation over the full filtered primary range. Allowed values: `5`, `7`, `10`, `15`, `20`. The ranked set is stable across every time bucket, and a comparison period reuses the primary period's ranked set (never re-ranked). Requires `groupBy`.

  - `5`

  - `7`

  - `10`

  - `15`

  - `20`

- `view: optional "standard" or "hourly_heatmap" or "sankey"`

  Result shape. `standard` returns a time series (plus grouped/segment data when requested), `hourly_heatmap` buckets by day-of-week × hour-of-day, `sankey` returns conversation flow data. Each metric lists its `supportedViews` in the `GET /v2/reports/datasets` catalog.

  - `"standard"`

  - `"hourly_heatmap"`

  - `"sankey"`

### Returns

- `meta: object { aggregation, datasetId, granularity, 5 more }`

  - `aggregation: "count" or "sum" or "avg" or 6 more`

    Aggregation function applied to the metric. Each metric supports a subset of aggregations — see the `allowedAggregations` field in the `GET /v2/reports/datasets` catalog.

    - `"count"`

    - `"sum"`

    - `"avg"`

    - `"median"`

    - `"min"`

    - `"max"`

    - `"range"`

    - `"percentile"`

    - `"value"`

  - `datasetId: string`

  - `granularity: string`

  - `metric: string`

  - `officeHoursOnly: boolean`

  - `unit: "count" or "percentage" or "duration_ms" or "number"`

    - `"count"`

    - `"percentage"`

    - `"duration_ms"`

    - `"number"`

  - `groupBy: optional string`

  - `segmentBy: optional string`

- `object: "report_query_result"`

  - `"report_query_result"`

- `value: number`

  Aggregated value across the whole reporting window.

- `deltaPercent: optional number`

  Percentage change vs the comparison window.

- `flowData: optional array of object { metricId, pathId, source, 7 more }`

  - `metricId: string`

  - `pathId: string`

  - `source: string`

  - `sourceLabel: string`

  - `target: string`

  - `targetLabel: string`

  - `value: number`

  - `colorKey: optional string`

  - `percentage: optional number`

  - `sortOrder: optional number`

- `groupedData: optional array of ReportGroupedDatum`

  - `group: string`

    Raw group value (e.g. an ID). Use `groupLabel` for display.

  - `value: number`

  - `groupLabel: optional string`

  - `previousValue: optional number`

  - `segments: optional array of ReportSegmentDatum`

    - `segment: string`

    - `value: number`

    - `previousValue: optional number`

    - `segmentLabel: optional string`

- `previousValue: optional number`

  Aggregated value for the comparison window, when requested.

- `segmentData: optional array of ReportSegmentDatum`

  - `segment: string`

  - `value: number`

  - `previousValue: optional number`

  - `segmentLabel: optional string`

- `table: optional object { columns, key, mode, 6 more }`

  - `columns: array of object { id, kind, label, 6 more }`

    - `id: string`

    - `kind: "dimension" or "metric" or "record_attribute"`

      - `"dimension"`

      - `"metric"`

      - `"record_attribute"`

    - `label: string`

    - `sortable: boolean`

    - `unit: "count" or "percentage" or "duration_ms" or 3 more`

      - `"count"`

      - `"percentage"`

      - `"duration_ms"`

      - `"number"`

      - `"text"`

      - `"datetime"`

    - `align: optional "left" or "right"`

      - `"left"`

      - `"right"`

    - `sticky: optional boolean`

    - `summary: optional "sum" or "weighted_rate" or "none"`

      - `"sum"`

      - `"weighted_rate"`

      - `"none"`

    - `valueType: optional string`

  - `key: string`

  - `mode: "aggregate" or "records"`

    - `"aggregate"`

    - `"records"`

  - `page: number`

  - `pageSize: number`

  - `rows: array of object { id, cells }`

    - `id: string`

    - `cells: map[object { display, denominator, numerator, 3 more } ]`

      - `display: string`

      - `denominator: optional number`

      - `numerator: optional number`

      - `raw: optional unknown`

      - `sortValue: optional string or number`

        - `string`

        - `number`

      - `value: optional unknown`

  - `totalRows: number`

  - `sort: optional object { columnId, direction }`

    - `columnId: string`

    - `direction: "asc" or "desc"`

      - `"asc"`

      - `"desc"`

  - `summaryRows: optional array of object { id, cells }`

    - `id: string`

    - `cells: map[object { display, denominator, numerator, 3 more } ]`

      - `display: string`

      - `denominator: optional number`

      - `numerator: optional number`

      - `raw: optional unknown`

      - `sortValue: optional string or number`

        - `string`

        - `number`

      - `value: optional unknown`

- `timeSeries: optional array of ReportTimeSeriesDatum`

  - `date: string`

    Time bucket start (ISO 8601).

  - `value: number`

  - `previousValue: optional number`

    Value for the same bucket in the comparison window, when requested.

  - `segments: optional array of ReportSegmentDatum`

    - `segment: string`

    - `value: number`

    - `previousValue: optional number`

    - `segmentLabel: optional string`

### Example

```http
curl https://do.featurebase.app/v2/reports/query \
    -H 'Content-Type: application/json' \
    -H "Authorization: Bearer $FEATUREBASE_API_KEY" \
    -d '{
          "aggregation": "count",
          "endDate": "2026-07-15",
          "granularity": "day",
          "metric": "new_conversations",
          "startDate": "2026-07-12",
          "groupBy": "conversation.channel",
          "percentile": 95,
          "segmentTopValuesLimit": 5,
          "timezone": "Europe/Tallinn",
          "topValuesLimit": 10,
          "view": "standard"
        }'
```

#### Response

```json
{
  "meta": {
    "aggregation": "count",
    "datasetId": "datasetId",
    "granularity": "granularity",
    "metric": "metric",
    "officeHoursOnly": true,
    "unit": "count",
    "groupBy": "groupBy",
    "segmentBy": "segmentBy"
  },
  "object": "report_query_result",
  "value": 0,
  "deltaPercent": 0,
  "flowData": [
    {
      "metricId": "metricId",
      "pathId": "pathId",
      "source": "source",
      "sourceLabel": "sourceLabel",
      "target": "target",
      "targetLabel": "targetLabel",
      "value": 0,
      "colorKey": "colorKey",
      "percentage": 0,
      "sortOrder": 0
    }
  ],
  "groupedData": [
    {
      "group": "group",
      "value": 0,
      "groupLabel": "groupLabel",
      "previousValue": 0,
      "segments": [
        {
          "segment": "segment",
          "value": 0,
          "previousValue": 0,
          "segmentLabel": "segmentLabel"
        }
      ]
    }
  ],
  "previousValue": 0,
  "segmentData": [
    {
      "segment": "segment",
      "value": 0,
      "previousValue": 0,
      "segmentLabel": "segmentLabel"
    }
  ],
  "table": {
    "columns": [
      {
        "id": "id",
        "kind": "dimension",
        "label": "label",
        "sortable": true,
        "unit": "count",
        "align": "left",
        "sticky": true,
        "summary": "sum",
        "valueType": "valueType"
      }
    ],
    "key": "key",
    "mode": "aggregate",
    "page": 0,
    "pageSize": 0,
    "rows": [
      {
        "id": "id",
        "cells": {
          "foo": {
            "display": "display",
            "denominator": 0,
            "numerator": 0,
            "raw": {},
            "sortValue": "string",
            "value": {}
          }
        }
      }
    ],
    "totalRows": 0,
    "sort": {
      "columnId": "columnId",
      "direction": "asc"
    },
    "summaryRows": [
      {
        "id": "id",
        "cells": {
          "foo": {
            "display": "display",
            "denominator": 0,
            "numerator": 0,
            "raw": {},
            "sortValue": "string",
            "value": {}
          }
        }
      }
    ]
  },
  "timeSeries": [
    {
      "date": "2026-07-15",
      "value": 0,
      "previousValue": 0,
      "segments": [
        {
          "segment": "segment",
          "value": 0,
          "previousValue": 0,
          "segmentLabel": "segmentLabel"
        }
      ]
    }
  ]
}
```
