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

# Reports API

> Build any Surfacd report from one endpoint. Choose an entity, pick dimensions and measures, add filters and a compare window, and get back a dataset you can chart in your own tools.

<Note>
  API and MCP access is included on all plans. Browse the [API Reference](/api-reference) for the full endpoint contract.
</Note>

The Reports API is one endpoint per report entity. You send a small JSON body that says what to group by, what to compute and what to keep. You get back rows plus a column schema, so the same response renders in a spreadsheet, a BI tool, a script or an AI assistant.

Every report page and widget in Surfacd can be rebuilt with it. The entity pages show the exact body for each one:

<CardGroup cols={2}>
  <Card title="Visibility" icon="trophy" href="/docs/reports-api-visibility">
    Visibility, mentions, position, sentiment and rank, or one row per AI answer. Dashboard, Rankings, Analyse, Prompts and Brand Mentions.
  </Card>

  <Card title="Sources" icon="link" href="/docs/reports-api-sources">
    Cited domains, hostnames, URLs and pages. Sources overview, tables, gaps and page sentiment.
  </Card>

  <Card title="Narratives" icon="comments" href="/docs/reports-api-narratives">
    Reputation narratives, themes and sentiment. Requires the Reputation feature.
  </Card>
</CardGroup>

## Endpoints

| Method | URL | Purpose |
| - | - | - |
| `POST` | `/api/v1/projects/{project}/reports/{entity}` | Run a query. `{entity}` is `visibility`, `sources` or `narratives`. |
| `GET` | `/api/v1/reports/{entity}/schema` | What the entity accepts: dimensions, measures, filter fields with roles, related blocks. |
| `GET` | `/api/v1/projects/{project}/reports/values/{field}` | The values a filter field takes for the project, with search. |

All three use a team [API key](/docs/api-keys) as a Bearer token. `{project}` is the project UUID from `GET /api/v1/projects`.

## Your first query

Visibility per brand over the last four weeks, best first:

```bash theme={null}
curl -X POST 'https://app.surfacd.com/api/v1/projects/PROJECT_UUID/reports/visibility' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -d '{
    "start_date": "2026-09-01",
    "end_date": "2026-09-28",
    "dimensions": ["brand"],
    "measures": ["visibility", "mentions"]
  }'
```

```json theme={null}
{
  "id": "visibility",
  "title": "Visibility",
  "columns": [
    { "id": "brand_uuid", "title": "Brand id", "type": "dimension", "format": "number", "decimals": null, "prefix": "", "suffix": "" },
    { "id": "brand", "title": "Brand", "type": "dimension", "format": "number", "decimals": null, "prefix": "", "suffix": "" },
    { "id": "visibility", "title": "Visibility", "type": "measure", "format": "percent", "decimals": 1, "prefix": "", "suffix": "" },
    { "id": "mentions", "title": "Mentions", "type": "measure", "format": "number", "decimals": 0, "prefix": "", "suffix": "" }
  ],
  "rows": [
    { "brand_uuid": "4580cd66-…", "brand": "JBL", "visibility": 0.203, "mentions": 186 },
    { "brand_uuid": "4e1f77d6-…", "brand": "Apple", "visibility": 0.162, "mentions": 148 }
  ],
  "page": { "offset": 0, "limit": 100, "total": 25 },
  "related": {},
  "benchmarks": []
}
```

`columns` tells you how to format each cell. `rows` are raw values keyed by column id. A `percent` column is a decimal: `0.203` is 20.3%.

## The request

```json theme={null}
{
  "start_date": "2026-09-01",
  "end_date": "2026-09-28",
  "compare_start_date": "2026-08-04",
  "compare_end_date": "2026-08-31",
  "dimensions": ["brand", "service"],
  "measures": ["visibility", "mentions"],
  "filters": [{ "field": "service", "op": "in", "values": ["chatgpt", "gemini"] }],
  "order_by": [{ "field": "visibility", "dir": "desc" }],
  "limit": 100,
  "offset": 0,
  "related": []
}
```

| Key | Required | Meaning |
| - | - | - |
| `start_date`, `end_date` | yes | The window, inclusive, `YYYY-MM-DD`. |
| `compare_start_date`, `compare_end_date` | no | A second window to compare against. Both or neither. See [Compare window](#compare-window). |
| `dimensions` | no | What each row groups by, in display order. Leave it out for one project-level row. |
| `measures` | yes | The numbers to compute per row. At least one. |
| `filters` | no | Conditions. See [Filters](#filters). |
| `order_by` | no | A list of `{ "field", "dir" }`. Any dimension or measure on the row, including compare columns. |
| `limit`, `offset` | no | Paging. Default 100 rows, maximum 1000. |
| `top` | no | `{ "field", "by", "limit" }`: keep only the best values of one dimension over the whole window. See [Paging and rank](#paging-and-rank). |
| `related` | no | Extra per-row blocks the entity offers. See [Related blocks](#related-blocks). |

## Rows and dimensions

A row is one dimension group.

* `"dimensions": ["brand"]` is one row per brand.
* `"dimensions": ["brand", "service"]` is one row per brand per platform.
* `"dimensions": ["brand", "date"]` is a timeline, one row per brand per day. `week` and `month` bucket the same way. Use one time bucket per query.
* `"dimensions": ["service"]` on visibility, without `brand`, aggregates across the project's brands: one row per platform.
* No `dimensions` at all gives one project-level row, which is what a KPI card needs.

A brand with no mentions in the window still gets a row, zero-filled, so rankings and charts stay complete.

Every dimension with a name also carries an id column: `brand` comes with `brand_uuid`, `prompt` with `prompt_uuid`, `topic` with `topic_uuid`, and so on. Prompts without a topic form one group whose `topic` and `topic_uuid` are `null`.

## Filters

Every filter field has a **role**. The role decides where the condition runs and which operators it accepts. You never choose between a `where` and a `having`; the field does.

| Role | What it does | Operators | Fields |
| - | - | - | - |
| **population** | Narrows which AI answers are counted at all. Changes every number, including the denominators of `visibility`, `share` and `citation_rate`. | `in` | `service`, `country`, `tag`, `topic`, `prompt`, `mentions_brand`, `cites_domain`, `mention_sentiment` (on sources), `carries_narrative`, `carries_theme` |
| **entity** | Hides rows by their label after the numbers are computed. Never changes a number. The field must be in `dimensions`. | `in`, `not_in`, `contains` | `brand`, `domain`, `hostname`, `url`, `narrative`, `theme`, … |
| **measure** | Hides rows by a computed value. | `eq`, `gt`, `gte`, `lt`, `lte` | any measure, plus `<measure>_compare` and `<measure>_change` |
| **lens** | Names the brand the page columns on `sources` describe. Removes no rows. | `in` with one UUID | `page_brand` |
| **page** | Narrows which cited pages count on `sources`, before grouping. | `in`, `not_in`, comparisons | `competitors_on_page`; `page_type` and `category` when they are not on the row |

Two things follow from this:

* **"ChatGPT only" changes visibility.** `service in ["chatgpt"]` makes visibility = mentions on ChatGPT ÷ answers on ChatGPT, the same as the platform filter in the app.
* **A brand filter does not.** `brand in ["…"]` keeps one row and drops the rest. That brand's visibility is exactly what it was with no filter. Use it to pick your brand for a KPI, not to change the maths.

`in` and `not_in` take `values` (a list). Comparisons take a numeric `value`. `contains` takes a text `value` and matches the label case-insensitively. The wrong pairing is a 422.

### Population filter values

| Field | Values | Where to find them |
| - | - | - |
| `service` | Platform slugs: `chatgpt`, `gemini`, `perplexity`, `claude`, `copilot`, `grok`, `deepseek`, `google_ai_mode`, `google_ai_overviews` | `GET /api/v1/platforms` |
| `country` | ISO codes: `US`, `GB` | `GET /api/v1/countries` |
| `tag` | Tag UUIDs or names | `GET /api/v1/projects/{project}/tags` |
| `topic` | Topic UUIDs or names, or `no_topic` | `GET /api/v1/projects/{project}/topics` |
| `prompt` | Exactly one prompt UUID. Use `tag` or `topic` for a set of prompts. | `GET /api/v1/projects/{project}/prompts` |
| `mentions_brand` | Brand UUIDs of the project. Keeps only answers that name one of them. | `GET /api/v1/projects/{project}/brands` |
| `cites_domain` | Domain names such as `reddit.com`. Keeps only answers that cite a page on one of them. | the values endpoint |
| `mention_sentiment` | `positive`, `neutral`, `negative`. On sources, with one `mentions_brand`: only answers that speak of that brand this way. On visibility it is a dimension instead. | fixed |
| `carries_narrative`, `carries_theme` | Narrative or theme UUIDs. Keeps only answers that carry them. Needs Reputation. | the values endpoint |

No `service` filter means the project's active platforms, the same default as the rest of the API.

The content cuts, `mentions_brand`, `cites_domain` and (on sources) `mention_sentiment`, make "answers that name us" or "answers that cite Reddit" the whole population. Every ratio is then a share of those answers. Add the `answers` measure to see the denominator.

## Compare window

`compare_start_date` and `compare_end_date` run the same query over a second window. Same filters, other dates. Every measure then gains two columns, right after it:

| Column | Meaning |
| - | - |
| `visibility_compare` | The measure over the compare window, for the same row. |
| `visibility_change` | Current minus compare. `null` when the compare window has no matching row. |

* Both work in `filters` and `order_by`. "Movers" is `order_by: [{ "field": "share_change", "dir": "desc" }]`. "Dropped 10 points" is `{ "field": "visibility_change", "op": "lte", "value": -0.1 }`.
* Rows match on their dimension keys. A `date`, `week` or `month` row matches by **position from each window's start**: day 1 pairs with day 1, so a 28-day chart overlays the previous 28 days. A day with no answers leaves a hole and does not shift the days after it. Windows of different lengths pair from the start; the longer tail reads `null`.
* A single-row result also fills `benchmarks[0]` with `value`, `difference`, `change` and `favourable` for the first measure, so a KPI card can show its trend. `avg_position` counts down as good.
* Ratios compare fairly across unequal windows. Counts do not: 186 mentions over 28 days against 71 over 14 is not a trend.
* Visibility with the `answer` dimension is a list and takes no compare window.

## Paging and rank

* `limit` and `offset` cut the rows. `page.total` in the response is the row count before the cut, so you can build a pager. It is correct even on a page past the end.
* The `rank` measure numbers rows within their group by the entity's main measure: visibility on visibility, citations on sources. It is assigned before any entity or measure filter, so `{ "field": "rank", "op": "lte", "value": 3 }` on `["brand", "service"]` is "the top three brands on each platform" and does not renumber anyone.
* `top` keeps the best values of one dimension over the whole window, on every row. `"top": { "field": "brand", "by": "visibility", "limit": 6 }` on `["brand", "date"]` gives the six most visible brands for every day, where `limit: 6` would give six rows. The field must be in `dimensions` and cannot be `date`, `week` or `month`. Measures where lower is better, such as `avg_position`, rank ascending. It hides rows and never changes a number.
* `contains` on an entity field is your search box: `{ "field": "url", "op": "contains", "value": "reddit" }`.

## Related blocks

Some entities offer extra per-row lists that would not fit a flat row: the brands named on a cited page, the sources an answer cites, a narrative's evidence. Ask for them in `related`; they come back under `related` in the response, one list per block. Each entry carries `row`, the index of its row in `rows`, so filtering or sorting rows on your side never loses the link.

```json theme={null}
"related": {
  "brands_on_page": [
    { "row": 0, "brands": [{ "uuid": "…", "name": "Apple", "domain": "apple.com", "pages": 6 }] }
  ]
}
```

| Entity | Blocks |
| - | - |
| `sources` | `brands_on_page` |
| `answers` | `brands_in_answer`, `sources_in_answer`, `answer_text` |
| `narratives` | `narrative_description`, `evidence` |

## The response

| Key | Meaning |
| - | - |
| `id`, `title`, `description` | The entity and its display name. |
| `columns` | The row schema in display order: id columns, dimensions, then each measure followed by its `_compare` and `_change` columns when a compare window is set. Each has `type` (`dimension`, `measure`, `date`), `format` (`number`, `percent`), `decimals`, `prefix`, `suffix`. |
| `rows` | One object per row, keyed by column id. Raw values: percent columns are decimals, `sentiment` is 0 to 100, positions and ranks are integers. |
| `page` | `offset`, `limit` and `total`. |
| `related` | The requested blocks, keyed by block id. |
| `value`, `column` | The first measure of a single-row result, for a KPI card. `0` otherwise. |
| `benchmarks` | On a single-row result with a compare window: the compare value, difference, change and whether it is favourable. |
| `empty` | Text to show when `rows` is empty. |
| `from`, `to` | The window you asked for. |

## Finding identifiers

Filters take public identifiers only: UUIDs, slugs, codes and names. Two endpoints tell you what to send.

The **schema** endpoint lists what an entity accepts. Its `filter_fields` carry the role and operators of every field:

```bash theme={null}
curl 'https://app.surfacd.com/api/v1/reports/sources/schema' \
  -H 'Authorization: Bearer YOUR_API_KEY' -H 'Accept: application/json'
```

The **values** endpoint lists what a field takes for your project, with a search:

```bash theme={null}
curl 'https://app.surfacd.com/api/v1/projects/PROJECT_UUID/reports/values/cites_domain?search=reddit' \
  -H 'Authorization: Bearer YOUR_API_KEY' -H 'Accept: application/json'
```

```json theme={null}
{
  "field": "cites_domain",
  "search": "reddit",
  "total": 1,
  "values": [{ "value": "reddit.com", "label": "reddit.com", "description": "2127 citations" }]
}
```

Put `value` in the filter. `label` and `description` are for people: the primary brand is marked, domains carry their citation count, narratives their sentiment and theme. Fields with a list: `brand`, `mentions_brand`, `page_brand`, `narrative`, `carries_narrative`, `theme`, `carries_theme`, `prompt`, `topic`, `tag`, `service`, `country`, `domain`, `cites_domain`, `page_type`, `category`, `brand_on_page`, `mention_sentiment`, `page_sentiment`, `narrative_sentiment`.

## Errors

Validation problems return `422` with `errors` keyed by the offending path, so a wrong body tells you where.

| Message | Cause |
| - | - |
| `The selected measures.0 is invalid.` | A measure the entity does not have. The message lists the valid ones. |
| `The service filter only supports the in operator.` | A population field with a comparison. |
| `The visibility filter only supports the eq, gt, gte, lt and lte operators.` | A measure with `in`. |
| `Using brand here needs brand in dimensions.` | An entity filter without its dimension on the row. |
| `Not a brand of this project: <uuid>.` | A brand UUID from another project. |
| `Compare columns need compare_start_date and compare_end_date.` | `visibility_change` without a compare window. |
| `Use one time dimension: date, week or month.` | Two time buckets in one query. |
| `The prompt filter takes in with exactly one prompt uuid.` | A list of prompts. Use `tag` or `topic` for a set. |

`403` means the query reads reputation data (the `narratives` entity, or a `carries_narrative` / `carries_theme` filter) and the team does not have the [Reputation](/docs/reputation) feature. `404` means the project is not in the key's team.

## Deprecated endpoints

The Reports API replaces these single-report endpoints. They keep working until a removal date is announced. New integrations should use the report query.

| Deprecated endpoint | Replacement | Body |
| - | - | - |
| `GET /projects/{project}/brand-rankings` | `POST /projects/{project}/reports/visibility` | `dimensions: ["brand"]`. [Example](/docs/reports-api-visibility#brand-rankings) |
| `GET /projects/{project}/visibility-over-time` | `POST /projects/{project}/reports/visibility` | `dimensions: ["brand", "date"]`, or `week` / `month`. [Example](/docs/reports-api-visibility#visibility-chart) |
| `GET /projects/{project}/matrix-analysis` | `POST /projects/{project}/reports/visibility` | `dimensions: ["brand", "service"]`, or `country` / `topic` / `tag`. [Example](/docs/reports-api-visibility#analyse) |
| `GET /projects/{project}/sources-data/domains` | `POST /projects/{project}/reports/sources` | `dimensions: ["domain"]`. [Example](/docs/reports-api-sources#sources-tables) |
| `GET /projects/{project}/sources-data/hostnames` | `POST /projects/{project}/reports/sources` | `dimensions: ["hostname"]` |
| `GET /projects/{project}/sources-data/urls` | `POST /projects/{project}/reports/sources` | `dimensions: ["url"]` |
| `GET /projects/{project}/sources-data/category-distribution` | `POST /projects/{project}/reports/sources` | `dimensions: ["category"]`, measures `citations`, `share`. [Example](/docs/reports-api-sources#dashboard) |
| `GET /projects/{project}/reputation/overview` | `POST /projects/{project}/reports/narratives` | `dimensions: ["brand"]`. [Example](/docs/reports-api-narratives#reputation-overview) |
| `GET /projects/{project}/reputation/sentiment-series` | `POST /projects/{project}/reports/narratives` | `dimensions: ["brand", "date"]` |
| `GET /projects/{project}/reputation/sentiment-by-model` | `POST /projects/{project}/reports/narratives` | `dimensions: ["brand", "service"]` |
| `GET /projects/{project}/reputation/themes` | `POST /projects/{project}/reports/narratives` | `dimensions: ["theme"]`. [Example](/docs/reports-api-narratives#themes) |
| `GET /projects/{project}/reputation/narratives` | `POST /projects/{project}/reports/narratives` | `dimensions: ["narrative", "narrative_sentiment", "theme"]`. [Example](/docs/reports-api-narratives#narratives) |
| `GET /projects/{project}/reputation/sources/domains` | `POST /projects/{project}/reports/sources` | `dimensions: ["domain"]` plus a `carries_narrative` or `carries_theme` filter |
| `GET /projects/{project}/reputation/sources/urls` | `POST /projects/{project}/reports/sources` | `dimensions: ["url"]` plus a `carries_narrative` or `carries_theme` filter |
| `GET /projects/{project}/reputation/sources/prompts` | `POST /projects/{project}/reports/sources` | `dimensions: ["domain", "prompt"]` plus a `domain` filter and a `carries_narrative` or `carries_theme` filter. [Example](/docs/reports-api-sources#prompts-citing-a-page) |

All paths start with `/api/v1`. The detail endpoints (one theme, narrative or response by UUID), `mentions` and `visibility-decomposition` are not deprecated.

## Using it from an AI assistant

The [MCP server](/docs/mcp) exposes the same capability as tools: `run_report` runs a query, `get_report_schema` and `list_report_values` describe what it accepts, and `get_report_query_guide` explains how to write one. An assistant connected to Surfacd can build any of the reports on the entity pages on request.


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