> ## Documentation Index
> Fetch the complete documentation index at: https://docs.thrad.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.

> [!NOTE]
>
> Insights money uses the **same micros scale** as budgets and bids (`$1 = 1,000,000`), and every money metric arrives as a pair: an integer `*_micros` value plus a formatted `*_display` string. `ctr` likewise arrives as `ctr_ratio` (a 0–1 fraction) plus `ctr_display` (`"10.00%"`).
>
> ```json theme={null}
> { "spend_micros": 700000, "spend_display": "$0.70",
>   "ctr_ratio": 0.1, "ctr_display": "10.00%" }
> ```
>
> The `*_micros` integer is the canonical, parseable value — never parse `*_display`, whose formatting may change.

> [!WARNING]
>
> **One vocabulary.** You select, filter, sort, and read the *same* field names: `impressions`, `clicks`, `spend_micros`, `cpc_micros`, `cpm_micros`, `ctr_ratio`. There is no bare `spend` or `ctr` field anywhere in this API.

## Authorizations

**Authorization** `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.

## Query parameters

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

**time\_granularity** `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`.

**aggregation\_level** `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.

**time\_ranges\[]** `array`

One or more JSON window objects, each `{"start": "...", "end": "..."}` where both bounds are **ISO 8601 timestamps** carrying a UTC offset or trailing `Z`. One format — there is no `type` discriminator and no `unix_range` / `date_range` / `hour_range` variant. When omitted, a trailing 30-day window is used.

```json theme={null}
{"start": "2026-04-01T00:00:00Z", "end": "2026-04-03T00:00:00Z"}
```

> [!NOTE]
>
> `end` must be after `start` (`invalid_time_range`), and neither bound may be `null`. An offset-less timestamp is rejected rather than assumed UTC. Each window may span **at most 366 days** (`range_too_wide`); with `time_granularity=hourly`, **25 hours or less** (`hourly_range_too_wide`).

**fields\[]** `array`

Which metrics to project onto each row, named by their **output field**: `impressions`, `clicks`, `spend_micros`, `cpc_micros`, `cpm_micros`, `ctr_ratio`. Defaults to `impressions`, `clicks`, `spend_micros`. Each money field yields its `*_micros` value **and** its `*_display` companion; `ctr_ratio` yields `ctr_ratio` + `ctr_display`. An unknown name returns `unknown_field`.

**filters\[]** `array`

One or more JSON `{ "field", "operator", "value" }` objects. Three kinds of field are accepted:

**filters\[] field kinds**

**metric filter** `object`

`field` is an output metric name (`spend_micros`, `cpc_micros`, `cpm_micros`, `ctr_ratio`, `impressions`, `clicks`). Operator must be `GREATER_THAN` or `LESS_THAN` — `IN` on a metric returns `invalid_filter_operator`. **`value` is a number in the field's own unit**: micros for money (`spend_micros > 100000000` means "over \$100"), a 0–1 ratio for `ctr_ratio`, integers for counts. Metric filters are applied in memory to the computed rows.

**entity-id filter** `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`).

**source filter** `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`.

An unknown field returns `invalid_filter`; a disallowed operator returns `invalid_filter_operator`.

**sort\[]** `array`

One or more JSON `{ "field", "direction" }` objects, applied in order. `field` must be one of the same output metric names (`spend_micros`, `cpc_micros`, `cpm_micros`, `ctr_ratio`, `impressions`, `clicks`); `direction` is `asc` or `desc`. Anything else returns `invalid_value`. Row `id` is always the final deterministic tiebreak.

**segments\[]** `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`.

**source** `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.

**limit** `integer` (default: 20)

Maximum rows in the page, `1`–`2000` (default `20`). Out of range returns `invalid_value`.

**before** `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`.

**after** `string`

Cursor for the next page — pass a prior response's `last_id`. Mutually exclusive with `before`.

> [!NOTE]
>
> `includes[]` and `override_segment_group_order[]` are **not supported**. Passing either returns `400 unsupported_parameter` — they are never silently ignored.

```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_micros' \
  --data-urlencode 'fields[]=ctr_ratio' \
  --data-urlencode 'time_ranges[]={"start":"2026-04-01T00:00:00Z","end":"2026-04-03T00:00:00Z"}'
```

```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[]={"start":"2026-04-02T00:00:00Z","end":"2026-04-03T00:00:00Z"}'
```

```bash cURL — ads ranked by spend, over $100 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_micros' \
  --data-urlencode 'fields[]=cpc_micros' \
  --data-urlencode 'sort[]={"field":"spend_micros","direction":"desc"}' \
  --data-urlencode 'filters[]={"field":"spend_micros","operator":"GREATER_THAN","value":100000000}' \
  --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_micros", "ctr_ratio"],
        "time_ranges[]": json.dumps(
            {"start": "2026-04-01T00:00:00Z", "end": "2026-04-03T00:00:00Z"}
        ),
    },
)
for row in resp.json()["data"]:
    print(row["readable_time"], row["spend_display"], row["ctr_display"])
```

```json 200 - Daily rows theme={null}
{
  "object": "list",
  "data": [
    {
      "id": "start=1775088000:end=1775174400:entity_id=8c1f2a3b-4d5e-6f70-8192-a3b4c5d6e7f8",
      "start_time": "2026-04-02T00:00:00.000000Z",
      "end_time": "2026-04-03T00:00:00.000000Z",
      "readable_time": "2026-04-02",
      "timezone": "UTC",
      "currency": "usd",
      "campaign_id": "8c1f2a3b-4d5e-6f70-8192-a3b4c5d6e7f8",
      "campaign_name": "Spring Sale",
      "impressions": 500,
      "clicks": 50,
      "spend_micros": 700000,
      "spend_display": "$0.70",
      "ctr_ratio": 0.1,
      "ctr_display": "10.00%"
    }
  ],
  "count": 1,
  "first_id": "start=1775088000:end=1775174400:entity_id=8c1f2a3b-4d5e-6f70-8192-a3b4c5d6e7f8",
  "last_id": "start=1775088000:end=1775174400:entity_id=8c1f2a3b-4d5e-6f70-8192-a3b4c5d6e7f8",
  "has_more": false
}
```

```json 200 - Split by supply (segments[]=source) theme={null}
{
  "object": "list",
  "data": [
    {
      "id": "start=1775088000:end=1775174400:entity_id=f3a1c2d4-5b6e-47a8-9c0d-1e2f3a4b5c6d:source=thrad",
      "start_time": "2026-04-02T00:00:00.000000Z",
      "end_time": "2026-04-03T00:00:00.000000Z",
      "timezone": "UTC",
      "currency": "usd",
      "source": "thrad",
      "ad_account_id": "f3a1c2d4-5b6e-47a8-9c0d-1e2f3a4b5c6d",
      "ad_account_name": "Acme Inc.",
      "impressions": 300,
      "clicks": 30,
      "spend_micros": 400000,
      "spend_display": "$0.40"
    },
    {
      "id": "start=1775088000:end=1775174400:entity_id=f3a1c2d4-5b6e-47a8-9c0d-1e2f3a4b5c6d:source=openai",
      "start_time": "2026-04-02T00:00:00.000000Z",
      "end_time": "2026-04-03T00:00:00.000000Z",
      "timezone": "UTC",
      "currency": "usd",
      "source": "openai",
      "ad_account_id": "f3a1c2d4-5b6e-47a8-9c0d-1e2f3a4b5c6d",
      "ad_account_name": "Acme Inc.",
      "impressions": 200,
      "clicks": 20,
      "spend_micros": 300000,
      "spend_display": "$0.30"
    }
  ],
  "count": 2,
  "first_id": "start=1775088000:end=1775174400:entity_id=f3a1c2d4-5b6e-47a8-9c0d-1e2f3a4b5c6d:source=thrad",
  "last_id": "start=1775088000:end=1775174400:entity_id=f3a1c2d4-5b6e-47a8-9c0d-1e2f3a4b5c6d: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"
  }
}
```

## Response

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

**object** `string`

Always `"list"`.

**data** `array`

The metric rows for this page. Each row is a flat object.

**row properties**

**id** `string`

The row's **opaque** id, used as a pagination cursor. It happens to be composed as `start=<unix>:end=<unix>:entity_id=<uuid>` (plus `:source=…` / `:<segment>=…` when those breakouts are present), but those internal bytes are not part of the contract — echo the id back, never parse it. Read the window from `start_time` / `end_time`.

**start\_time** `string`

Bucket start as an ISO 8601 timestamp in UTC.

**end\_time** `string`

Bucket end as an ISO 8601 timestamp in UTC, exclusive.

**readable\_time** `string`

Human-readable bucket label — `2026-04-02` (daily), `2026-04` (monthly), or `2026-04-02T13` (hourly). **Omitted** when `time_granularity=none`.

**timezone** `string`

Always `"UTC"`.

**currency** `string`

The lowercase ISO code the money metrics on this row are denominated in (`"usd"` today). Present on every row.

**\<level>\_id** `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`.

**\<level>\_name** `string`

The entity's display name, e.g. `campaign_name`.

**source** `string`

`thrad` or `openai`. Present only when the rows are split by supply (`segments[]=source`).

**country** `string`

The `country_code` for this sub-row. Present only with `segments[]=country`.

**device** `string`

The `device_type` for this sub-row. Present only with `segments[]=device`.

**impressions** `integer`

Impression count. Present when requested in `fields[]` (default).

**clicks** `integer`

Click count. Present when requested in `fields[]` (default).

**spend\_micros** `integer`

Spend in micros (`700000` = \$0.70). Present when requested in `fields[]` (default).

**spend\_display** `string`

The same spend, currency-formatted (`"$0.70"`). Emitted alongside `spend_micros`. Display only.

**ctr\_ratio** `float`

Click-through rate as a **fraction** (clicks ÷ impressions, e.g. `0.1` = 10%). `null` when impressions are zero. Request it with `fields[]=ctr_ratio`.

**ctr\_display** `string`

The same rate as a percent string (`"10.00%"`), or `null`. Emitted alongside `ctr_ratio`.

**cpc\_micros** `integer`

Cost per click in micros (spend ÷ clicks). `null` when clicks are zero. Request it with `fields[]=cpc_micros`; paired with `cpc_display`.

**cpm\_micros** `integer`

Cost per thousand impressions in micros (spend ÷ impressions × 1000). `null` when impressions are zero. Request it with `fields[]=cpm_micros`; paired with `cpm_display`.

**cpc\_display / cpm\_display** `string`

Currency-formatted companions to `cpc_micros` / `cpm_micros`, e.g. `"$0.014"`. Sub-cent values keep up to 4 decimal places so they are not flattened to `"$0.01"`. Display only.

**count** `integer`

Number of rows in `data` on this page.

**first\_id** `string`

Id of the first row in the page. Pass it as `before` to fetch the previous page. `null` for an empty page.

**last\_id** `string`

Id of the last row in the page. Pass it as `after` to fetch the next page. `null` for an empty page.

**has\_more** `boolean`

`true` if more rows exist beyond this page.

## 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 same selection expressed as a filter. 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 error 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, is missing `start`/`end`, has a null or offset-less bound, has `end` ≤ `start`, 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`.


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