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

# Insights

> Read-only analytics for your ad account, campaigns, ad groups, and ads — impressions, clicks, spend, and derived rates, bucketed over time and broken out by supply.

The insights surface is four read-only endpoints, one per scope in the hierarchy. Each returns a bare list of metric rows for the entity you ask about (and, via `aggregation_level`, the levels below it). Numbers combine **Thrad** and **ChatGPT (OpenAI Ads)** supply by default; you can isolate or split a single supply.

```
GET /v1/ad_account/insights
GET /v1/campaigns/{campaign_id}/insights
GET /v1/ad_groups/{ad_group_id}/insights
GET /v1/ads/{ad_id}/insights
```

All four take the same [query parameters](#query-parameters) and return the same [list shape](#response). The only difference is scope: the entity you target and the deepest `aggregation_level` you may request.

<Warning>
  Insights money is **dollar floats**, not micros. `spend`, `cpc`, and `cpm` are already in dollars (e.g. `0.70`), and `ctr` is a fraction (e.g. `0.10` = 10%). This is deliberately different from the resource endpoints (campaigns, ad groups, ads), where budgets and bids are **integer micros** (`$1 = 1,000,000`).
</Warning>

## Authorizations

<ParamField header="Authorization" type="string" required>
  Bearer token using your organization's API key: `Authorization: Bearer ak_...`. There is **one active key per organization** — create or rotate it in the Thrad Platform dashboard under **Settings → API keys**. Store it as the `THRAD_ADS_API_KEY` environment variable.
</ParamField>

## Query parameters

These apply to every insights endpoint. Repeated parameters use the `name[]` convention (e.g. `fields[]=impressions&fields[]=clicks`).

<ParamField query="time_granularity" type="string" default="daily">
  How rows are bucketed over time: `hourly`, `daily` (default), `monthly`, or `none`. With `none` a single row covers the whole requested range and has no `readable_time`. `monthly` rolls daily data up by calendar month. Any other value returns `invalid_value`.
</ParamField>

<ParamField query="aggregation_level" type="string">
  The row grain: `ad_account`, `campaign`, `ad_group`, or `ad`. Defaults to the endpoint's own scope. It must be **at or below** the endpoint scope — e.g. `GET /v1/campaigns/{id}/insights?aggregation_level=ad` returns one row per ad in that campaign, but `aggregation_level=ad_account` on a campaign endpoint is rejected with `aggregation_level_too_high`. Use a deeper level to discover the entity ids beneath a scope; there are no separate list endpoints.
</ParamField>

<ParamField query="time_ranges[]" type="array">
  One or more JSON window objects. Each must be one of three types, and each window may span **at most 366 days** (`range_too_wide`). When omitted, a trailing 30-day window is used.

  <Expandable title="time_ranges[] object">
    <ParamField body="unix_range" type="object">
      `{"type":"unix_range","start":<unix>,"end":<unix>}` — `start` and `end` are Unix seconds (UTC).
    </ParamField>

    <ParamField body="date_range" type="object">
      `{"type":"date_range","since":"YYYY-MM-DD","until":"YYYY-MM-DD","timezone":"UTC"}` — calendar dates. `timezone` is an optional IANA name (defaults to UTC); an unknown name returns `invalid_time_range`.
    </ParamField>

    <ParamField body="hour_range" type="object">
      `{"type":"hour_range","since":"YYYY-MM-DDTHH","until":"YYYY-MM-DDTHH","timezone":"UTC"}` — hour-precision bounds, pairs with `time_granularity=hourly`.
    </ParamField>
  </Expandable>

  <Note>
    `end` must be after `start` (`invalid_time_range`). With `time_granularity=hourly`, each window must also be **25 hours or less** (`hourly_range_too_wide`).
  </Note>
</ParamField>

<ParamField query="fields[]" type="array">
  Which metrics to project onto each row: `impressions`, `clicks`, `spend`, `ctr`, `cpc`, `cpm`. Defaults to `impressions`, `clicks`, `spend`. A dotted form like `ad.impressions` is accepted (the prefix is ignored). An unknown metric returns `unknown_field`.
</ParamField>

<ParamField query="filters[]" type="array">
  One or more JSON `{ "field", "operator", "value" }` objects. Three kinds of field are accepted:

  <Expandable title="filters[] field kinds">
    <ParamField body="metric filter" type="object">
      `field` is a metric (`spend`, `clicks`, `ctr`, …). Operator may be `IN`, `GREATER_THAN`, or `LESS_THAN`. `GREATER_THAN`/`LESS_THAN` require a numeric `value`. Metric filters are applied in memory to the computed rows.
    </ParamField>

    <ParamField body="entity-id filter" type="object">
      `field` is an entity id (`campaign.id`, `ad_group.id`, `ad.id`, or the `_id` form). Operator must be `IN` with a list `value` of public ids. These narrow the SQL scope and are org-resolved, so a cross-org or unknown id simply matches nothing. The id level cannot be finer than `aggregation_level` (else `invalid_filter`).
    </ParamField>

    <ParamField body="source filter" type="object">
      `{"field":"source","operator":"IN","value":["thrad"]}` — selects supply, equivalent to the `source` parameter. Operator must be `IN`. If you pass both this and `source` and they disagree, the request is rejected with `invalid_filter`.
    </ParamField>
  </Expandable>

  An unknown field returns `invalid_filter`; a disallowed operator returns `invalid_filter_operator`.
</ParamField>

<ParamField query="sort[]" type="array">
  One or more JSON `{ "field", "direction" }` objects, applied in order. `field` is a metric (`ad.clicks` accepted); `direction` is `asc` or `desc`. A non-metric field or a bad direction returns `invalid_value`. Row `id` is always the final deterministic tiebreak.
</ParamField>

<ParamField query="segments[]" type="array">
  Break each row into sub-rows along one dimension. **At most one** segment (`too_many_segments` otherwise).

  * `source` — split into `thrad` + `openai` sub-rows. Supported at every level; the sub-rows **sum to the combined total**.
  * `country` / `device` — break by `country_code` / `device_type`. **Ad-group or ad scope only** (`unsupported_segment` higher up), **Thrad supply only** (`segment_source_unsupported` if `source=openai`), and only `none` or `daily` granularity (`segment_granularity_unsupported`). This path aggregates a single window, so it rejects an entity-id `filters[]` (`unsupported_segment`) and more than one `time_ranges[]` (`invalid_time_range`).
  * `product` — not supported (`unsupported_segment`). Any other value returns `invalid_segment`.
</ParamField>

<ParamField query="source" type="string" default="all">
  Which supply to report: `thrad`, `openai`, or `all` (default — combined Thrad + ChatGPT). A convenience equivalent to the `source` filter above.

  <Note>
    ChatGPT (OpenAI Ads) insights are daily-only. `source=openai` with `time_granularity=hourly` is a hard error (`hourly_unsupported_for_external_source`); a combined (`all`) hourly request silently reports Thrad supply only.
  </Note>
</ParamField>

<ParamField query="limit" type="integer" default="20">
  Maximum rows in the page, `1`–`2000` (default `20`). Out of range returns `invalid_value`.
</ParamField>

<ParamField query="before" type="string">
  Cursor for the previous page — pass a prior response's `first_id`. Mutually exclusive with `after` (`invalid_cursor` if both). An unknown cursor returns `invalid_cursor`.
</ParamField>

<ParamField query="after" type="string">
  Cursor for the next page — pass a prior response's `last_id`. Mutually exclusive with `before`.
</ParamField>

<Note>
  `includes[]` and `override_segment_group_order[]` exist in OpenAI's Ads API but are **not supported here**. Passing either returns `400 unsupported_parameter` — they are never silently ignored.
</Note>

<RequestExample>
  ```bash cURL — daily campaign insights theme={null}
  curl -G https://api.thrad.ai/v1/campaigns/$CAMPAIGN_ID/insights \
    -H "Authorization: Bearer $THRAD_ADS_API_KEY" \
    --data-urlencode 'time_granularity=daily' \
    --data-urlencode 'fields[]=impressions' \
    --data-urlencode 'fields[]=clicks' \
    --data-urlencode 'fields[]=spend' \
    --data-urlencode 'fields[]=ctr' \
    --data-urlencode 'time_ranges[]={"type":"date_range","since":"2026-04-01","until":"2026-04-03"}'
  ```

  ```bash cURL — split by supply theme={null}
  curl -G https://api.thrad.ai/v1/ad_account/insights \
    -H "Authorization: Bearer $THRAD_ADS_API_KEY" \
    --data-urlencode 'time_granularity=none' \
    --data-urlencode 'segments[]=source' \
    --data-urlencode 'time_ranges[]={"type":"unix_range","start":1775347200,"end":1775433600}'
  ```

  ```bash cURL — ads ranked by spend theme={null}
  curl -G https://api.thrad.ai/v1/campaigns/$CAMPAIGN_ID/insights \
    -H "Authorization: Bearer $THRAD_ADS_API_KEY" \
    --data-urlencode 'aggregation_level=ad' \
    --data-urlencode 'fields[]=spend' \
    --data-urlencode 'fields[]=cpc' \
    --data-urlencode 'sort[]={"field":"spend","direction":"desc"}' \
    --data-urlencode 'filters[]={"field":"clicks","operator":"GREATER_THAN","value":0}' \
    --data-urlencode 'limit=10'
  ```

  ```python Python theme={null}
  import os
  import json
  import requests

  resp = requests.get(
      f"https://api.thrad.ai/v1/campaigns/{os.environ['CAMPAIGN_ID']}/insights",
      headers={"Authorization": f"Bearer {os.environ['THRAD_ADS_API_KEY']}"},
      params={
          "time_granularity": "daily",
          "fields[]": ["impressions", "clicks", "spend", "ctr"],
          "time_ranges[]": json.dumps(
              {"type": "date_range", "since": "2026-04-01", "until": "2026-04-03"}
          ),
      },
  )
  for row in resp.json()["data"]:
      print(row["readable_time"], row["spend"], row["ctr"])
  ```
</RequestExample>

<ResponseExample>
  ```json 200 - Daily rows theme={null}
  {
    "object": "list",
    "data": [
      {
        "id": "start=1775347200:end=1775433600:entity_id=8c1f...",
        "start_time": 1775347200,
        "end_time": 1775433600,
        "readable_time": "2026-04-02",
        "timezone": "UTC",
        "campaign_id": "8c1f2a3b-4d5e-6f70-8192-a3b4c5d6e7f8",
        "campaign_name": "Spring Sale",
        "impressions": 500,
        "clicks": 50,
        "spend": 0.70,
        "ctr": 0.1
      }
    ],
    "count": 1,
    "first_id": "start=1775347200:end=1775433600:entity_id=8c1f...",
    "last_id": "start=1775347200:end=1775433600:entity_id=8c1f...",
    "has_more": false
  }
  ```

  ```json 200 - Split by supply (segments[]=source) theme={null}
  {
    "object": "list",
    "data": [
      {
        "id": "start=1775347200:end=1775433600:entity_id=f3a1...:source=thrad",
        "start_time": 1775347200,
        "end_time": 1775433600,
        "timezone": "UTC",
        "source": "thrad",
        "ad_account_id": "f3a1c2d4-5b6e-47a8-9c0d-1e2f3a4b5c6d",
        "ad_account_name": "Acme Inc.",
        "impressions": 300,
        "clicks": 30,
        "spend": 0.40
      },
      {
        "id": "start=1775347200:end=1775433600:entity_id=f3a1...:source=openai",
        "start_time": 1775347200,
        "end_time": 1775433600,
        "timezone": "UTC",
        "source": "openai",
        "ad_account_id": "f3a1c2d4-5b6e-47a8-9c0d-1e2f3a4b5c6d",
        "ad_account_name": "Acme Inc.",
        "impressions": 200,
        "clicks": 20,
        "spend": 0.30
      }
    ],
    "count": 2,
    "first_id": "start=1775347200:end=1775433600:entity_id=f3a1...:source=thrad",
    "last_id": "start=1775347200:end=1775433600:entity_id=f3a1...:source=openai",
    "has_more": false
  }
  ```

  ```json 400 - aggregation_level too high theme={null}
  {
    "error": {
      "message": "`aggregation_level=ad_account` is higher than the campaign endpoint scope.",
      "type": "invalid_request_error",
      "param": "aggregation_level",
      "code": "aggregation_level_too_high"
    }
  }
  ```
</ResponseExample>

## Response

Every endpoint returns the same bare list object — **not** the platform `{ success, data, error, meta }` envelope.

<ResponseField name="object" type="string">
  Always `"list"`.
</ResponseField>

<ResponseField name="data" type="array">
  The metric rows for this page. Each row is a flat object.

  <Expandable title="row properties">
    <ResponseField name="id" type="string">
      The row's opaque id, used as a pagination cursor. Composed as `start=<unix>:end=<unix>:entity_id=<uuid>`, with `:source=<thrad|openai>` and/or `:<segment>=<value>` appended when those breakouts are present.
    </ResponseField>

    <ResponseField name="start_time" type="integer">
      Bucket start as Unix seconds (UTC).
    </ResponseField>

    <ResponseField name="end_time" type="integer">
      Bucket end as Unix seconds (UTC), exclusive.
    </ResponseField>

    <ResponseField name="readable_time" type="string">
      Human-readable bucket label — `2026-04-02` (daily), `2026-04` (monthly), or `2026-04-02T13` (hourly). **Omitted** when `time_granularity=none`.
    </ResponseField>

    <ResponseField name="timezone" type="string">
      Always `"UTC"`.
    </ResponseField>

    <ResponseField name="<level>_id" type="string">
      The entity public id for this row, keyed by `aggregation_level` — e.g. `campaign_id`, `ad_group_id`, `ad_id`, or `ad_account_id`.
    </ResponseField>

    <ResponseField name="<level>_name" type="string">
      The entity's display name, e.g. `campaign_name`.
    </ResponseField>

    <ResponseField name="source" type="string">
      `thrad` or `openai`. Present only when the rows are split by supply (`segments[]=source`).
    </ResponseField>

    <ResponseField name="country" type="string">
      The `country_code` for this sub-row. Present only with `segments[]=country`.
    </ResponseField>

    <ResponseField name="device" type="string">
      The `device_type` for this sub-row. Present only with `segments[]=device`.
    </ResponseField>

    <ResponseField name="impressions" type="integer">
      Impression count. Present when requested in `fields[]` (default).
    </ResponseField>

    <ResponseField name="clicks" type="integer">
      Click count. Present when requested in `fields[]` (default).
    </ResponseField>

    <ResponseField name="spend" type="float">
      Spend in **dollars** (e.g. `0.70`), not micros. Present when requested in `fields[]` (default).
    </ResponseField>

    <ResponseField name="ctr" type="float">
      Click-through rate as a **fraction** (clicks ÷ impressions, e.g. `0.1` = 10%). `null` when impressions are zero. Present only when requested in `fields[]`.
    </ResponseField>

    <ResponseField name="cpc" type="float">
      Cost per click in **dollars** (spend ÷ clicks). `null` when clicks are zero. Present only when requested in `fields[]`.
    </ResponseField>

    <ResponseField name="cpm" type="float">
      Cost per thousand impressions in **dollars** (spend ÷ impressions × 1000). `null` when impressions are zero. Present only when requested in `fields[]`.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="count" type="integer">
  Number of rows in `data` on this page.
</ResponseField>

<ResponseField name="first_id" type="string">
  Id of the first row in the page. Pass it as `before` to fetch the previous page. `null` for an empty page.
</ResponseField>

<ResponseField name="last_id" type="string">
  Id of the last row in the page. Pass it as `after` to fetch the next page. `null` for an empty page.
</ResponseField>

<ResponseField name="has_more" type="boolean">
  `true` if more rows exist beyond this page.
</ResponseField>

## Choosing supply (Thrad vs ChatGPT)

By default insights combine Thrad and ChatGPT (OpenAI Ads) supply into one number. There are three ways to look at supply individually:

* **`source=thrad|openai`** — report a single supply. `source=all` (the default) is combined.
* **`filters[]={"field":"source","operator":"IN","value":["thrad"]}`** — the OpenAI-style equivalent. If you also pass `source` and the two disagree, you get `invalid_filter`.
* **`segments[]=source`** — keep the combined window but split each row into a `thrad` sub-row and an `openai` sub-row. Each sub-row carries a `source` field and `:source=…` in its `id`, and the two **sum to the combined total**.

## Errors

Errors use the bare OpenAI shape — `{ "error": { "message", "type", "param", "code" } }` — not the platform envelope. `type` is one of `invalid_request_error`, `authentication_error`, `rate_limit_error`, or `server_error`.

| Status | Type                    | Code                                     | When                                                                                                                                                |
| ------ | ----------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `invalid_request_error` | `invalid_value`                          | A parameter has a bad value (`time_granularity`, `aggregation_level`, `source`, `sort`, `limit`, …).                                                |
| `400`  | `invalid_request_error` | `aggregation_level_too_high`             | `aggregation_level` is above the endpoint's scope.                                                                                                  |
| `400`  | `invalid_request_error` | `invalid_time_range`                     | A `time_ranges[]` entry is malformed, has `end` ≤ `start`, an unknown timezone, or (audience segments) more than one range.                         |
| `400`  | `invalid_request_error` | `range_too_wide`                         | A window exceeds the 366-day maximum.                                                                                                               |
| `400`  | `invalid_request_error` | `hourly_range_too_wide`                  | An `hourly` window exceeds 25 hours.                                                                                                                |
| `400`  | `invalid_request_error` | `hourly_unsupported_for_external_source` | `source=openai` requested with `time_granularity=hourly`.                                                                                           |
| `400`  | `invalid_request_error` | `unknown_field`                          | A `fields[]` value is not a known metric.                                                                                                           |
| `400`  | `invalid_request_error` | `invalid_filter`                         | A `filters[]` entry is malformed, references an unknown field, filters an id finer than `aggregation_level`, or its source disagrees with `source`. |
| `400`  | `invalid_request_error` | `invalid_filter_operator`                | A `filters[]` operator is not allowed for that field.                                                                                               |
| `400`  | `invalid_request_error` | `too_many_segments`                      | More than one `segments[]` value.                                                                                                                   |
| `400`  | `invalid_request_error` | `invalid_segment`                        | An unrecognized `segments[]` value.                                                                                                                 |
| `400`  | `invalid_request_error` | `unsupported_segment`                    | `product` segment, or `country`/`device` outside ad-group/ad scope (or with an entity-id filter).                                                   |
| `400`  | `invalid_request_error` | `segment_source_unsupported`             | `country`/`device` segment with `source=openai` (Thrad supply only).                                                                                |
| `400`  | `invalid_request_error` | `segment_granularity_unsupported`        | `country`/`device` segment with `hourly` or `monthly` granularity.                                                                                  |
| `400`  | `invalid_request_error` | `invalid_cursor`                         | `before` and `after` both passed, or an unknown cursor.                                                                                             |
| `400`  | `invalid_request_error` | `unsupported_parameter`                  | `includes[]` or `override_segment_group_order[]` was passed.                                                                                        |
| `401`  | `authentication_error`  | `auth_required`                          | No `Authorization` header was sent.                                                                                                                 |
| `401`  | `authentication_error`  | `invalid_api_key`                        | The `Authorization: Bearer ak_...` key is malformed, unknown, or revoked.                                                                           |
| `404`  | `invalid_request_error` | `not_found`                              | The path entity does not exist or belongs to another organization.                                                                                  |
| `429`  | `rate_limit_error`      | `rate_limit_exceeded`                    | Exceeded the per-key limit of 1000 requests/hour.                                                                                                   |
| `504`  | `server_error`          | `clickhouse_timeout`                     | The analytics query timed out. Narrow the scope, window, or granularity and retry.                                                                  |

<Tip>
  A single response is capped at a fixed maximum number of rows (the decoded set is sorted, filtered, and paginated in memory). If you need more, narrow the scope or window, or page with `before`/`after`.
</Tip>
