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

# Breakdowns API

> Your traffic split by page, source, country, city, device, browser, operating system, campaign, hostname and outbound link domain, or by AI assistant and source category.

## Base URL and authentication

```
https://api.traceten.com/v1/breakdowns
```

Takes `Authorization: Bearer <YOUR_API_KEY>` ([details](/api/authentication)) and the `stats:read` permission.

`site_id` is the site's **snippet key**, the `ttid_` value your install snippet carries as `data-site`. You can read it from [`GET /v1/sites`](/api/sites).

## The breakdowns

| Endpoint                               | Rows are                               |
| -------------------------------------- | -------------------------------------- |
| `GET /v1/breakdowns/pages`             | Pages, by sessions that viewed them    |
| `GET /v1/breakdowns/referrers`         | Traffic sources, by name               |
| `GET /v1/breakdowns/countries`         | Countries                              |
| `GET /v1/breakdowns/cities`            | Cities                                 |
| `GET /v1/breakdowns/devices`           | `desktop`, `mobile`, `tablet`, `bot`   |
| `GET /v1/breakdowns/browsers`          | Browsers                               |
| `GET /v1/breakdowns/operating-systems` | Operating systems                      |
| `GET /v1/breakdowns/campaigns`         | `utm_campaign` values                  |
| `GET /v1/breakdowns/hostnames`         | Hostnames, if you track more than one  |
| `GET /v1/breakdowns/exit-links`        | Outbound link domains visitors left to |

They all take the same parameters and return the same envelope. Only the row shape and a few notes differ — including that two of them, `hostnames` and `exit-links`, also take optional filters (see [Filtering hostnames and exit links](#filtering-hostnames-and-exit-links)).

## Shared request

```bash theme={null}
curl "https://api.traceten.com/v1/breakdowns/devices?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12&from=2026-08-01&to=2026-08-07&limit=10" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

| Parameter  | Type    | Required | Notes                                              |
| ---------- | ------- | -------- | -------------------------------------------------- |
| `site_id`  | string  | yes      | The snippet key.                                   |
| `from`     | string  | no       | `YYYY-MM-DD`, inclusive. Must be sent with `to`.   |
| `to`       | string  | no       | `YYYY-MM-DD`, inclusive. Must be sent with `from`. |
| `timezone` | string  | no       | Must be `UTC`.                                     |
| `limit`    | integer | no       | 1 to 100. Defaults to 20.                          |
| `offset`   | integer | no       | Rows to skip. Defaults to 0.                       |
| `fields`   | string  | no       | Comma-separated field names, to trim each row.     |

Send `from` and `to` together or send neither; with neither, the window is the last 7 days. Rows come back ordered by session count, highest first.

Filters are not supported on most of these endpoints. They read pre-aggregated rollups that carry no per-row conditions, so `filter_country=is:US` returns a `422` rather than a full unfiltered list under a `200`. Two endpoints are the exception: `/v1/breakdowns/hostnames` and `/v1/breakdowns/exit-links` take `dimension`/`value` filters, described in [Filtering hostnames and exit links](#filtering-hostnames-and-exit-links) below.

## Shared response

````json theme={null}
{
  "status": "success",
  "data": [
    { "key": "desktop", "visitors": 3104, "sessions": 3820, "revenue_usd_cents": 512000, "conversion_count": 88 },
    { "key": "mobile", "visitors": 1760, "sessions": 2011, "revenue_usd_cents": 244000, "conversion_count": 41 },
    { "key": "unknown", "visitors": 251, "sessions": 273, "revenue_usd_cents": 0, "conversion_count": 0 }
  ],
  "pagination": { "limit": 20, "offset": 0, "has_more": false, "total": 3 },
  "meta": {
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "from": "2026-08-01",
    "to": "2026-08-07",
    "timezone": "UTC",
    "total_sessions": 6104,
    "total_visitors": 4930,
    "total_revenue_usd_cents": 812400,
    "total_conversion_count": 142,
    "unmatched_revenue_usd_cents": 3100,
    "unmatched_conversion_count": 2,
    "source": "sessions_mv_store",
    "session_window": "active"
### `meta.total_sessions` is the denominator

Every session contributes exactly one key to each dimension, so `total_sessions` is the total for the whole dimension, not just for the rows on this page. Work out the long tail by subtracting the rows you have rather than paging to the end of a list that may run to thousands of entries.

Each row also carries `visitors`: the distinct visitors behind its sessions, counted by browser cookie, so one person on two devices is two visitors. Visitors do **not** add up across rows, because one visitor can appear under two browsers or two cities. `meta.total_visitors` is the window's own visitor count. Do not subtract rows from it to get a visitors tail; the result can be wrong or even negative.

`unmatched_revenue_usd_cents` is revenue whose session was not active in your window, so it carries no device or country. It is reported rather than dropped so you can name the gap instead of quietly under reporting.

### `unknown` is a real bucket

A `key` of `unknown` means the pipeline resolved nothing for that session: no user agent on the event, or geolocation that could not place the visitor. It is a genuine group, not a placeholder, and filtering it out will stop your percentages adding to 100.

On browsers and operating systems, `unknown` and `other` are **different**. `unknown` means no user agent arrived at all, which usually points at a broken install. `other` means a user agent arrived and could not be matched to a known browser, which is ordinary long-tail drift and is expected to grow as browsers strip detail out of the `User-Agent` header. Do not merge them.

## Cities and the privacy floor

`/v1/breakdowns/cities` applies a k-anonymity floor. A city with fewer than `meta.k_anonymity_min_sessions` sessions in your window is left out of the response entirely: neither its name nor its session count is returned.

```json
{
  "status": "success",
  "data": [{ "key": "Austin", "sessions": 412, "revenue_usd_cents": 61200, "conversion_count": 9 }],
  "pagination": { "limit": 20, "offset": 0, "has_more": false, "total": 1 },
  "meta": {
    "k_anonymity_min_sessions": 5,
    "suppressed_cities": 7,
    "suppressed_sessions": 18,
    "source": "sessions_mv_store"
  }
}
````

`suppressed_cities` and `suppressed_sessions` tell you how much was withheld so your totals still reconcile. They never identify which cities.

<Warning>
  **The floor counts sessions, not people.** Five sessions can be one person who came back five
  times, so a city appearing in this list is not a guarantee that five different people visited from
  it. Do not describe it that way in your own product.
</Warning>

The floor exists because plotting a named city off the back of a handful of sessions can identify a person in a small town. See [GDPR](/privacy/gdpr) and [Geography](/dashboard/geography).

## Countries and cities do not reconcile

The two lists come from different places, and both are correct.

* `/v1/breakdowns/countries` reads a daily rollup that Traceten keeps indefinitely. Its `sessions` is a distinct-count **estimate**, so merge these figures rather than adding them. `pageviews` on the same row is exact and does add up.
* `/v1/breakdowns/cities` reads individual session records, which are kept for **at least 730 days and at most 760 days** ([Data retention](/privacy/data-collected#data-retention)). Its counts are exact, and it has the privacy floor above.

That retention boundary is enforced, not merely noted: `/v1/breakdowns/cities`, `/devices`, `/browsers`, `/operating-systems` and `/campaigns` return `422` for a window whose `from` falls outside the last 730 days (today counts as day 1), or spanning more than 730 days. They refuse rather than return a partial answer, because past the boundary the query does not come back empty, it comes back confidently low. Ask `/v1/breakdowns/countries`, `/pages`, `/hostnames` or `/referrers` for a longer window, all of which read rollups kept indefinitely.

The two `sessions` figures will not match even inside the retention window. `meta.source` names which store answered each request.

An empty `country_code` is a real row: geolocation resolved no country. It is returned rather than dropped for the same reason `unknown` is.

## Pages count AI traffic only; hostnames count all traffic

`/v1/breakdowns/pages` carries `"ai_only": true` in `meta`. It counts only sessions referred by an AI assistant, which is what the dashboard's Top Pages card shows. Sessions from non-AI channels (direct, organic search, social, email, paid, referral) and sessions whose source could not be placed are excluded. It is **not** a total of all traffic to a page, and you should not present it as one.

`/v1/breakdowns/hostnames` carries `"ai_only": false`. It counts every session that viewed a page on the host, whatever its source, so its `sessions` field is a site total and not an AI figure. Do not compare a host's `sessions` with a page's `ai_sessions`: they count different populations.

Each hostname row also carries `visitors`, the distinct visitors behind those sessions. It is **absent**, rather than 0, when your window's `from` is more than 730 days ago (today counts as day 1), because it is counted from raw events, which are kept for 730 days. The rest of the row still answers that window.

```json theme={null}
{
  "status": "success",
  "data": [
    {
      "url": "https://example.com/pricing",
      "ai_sessions": 812,
      "conversions": 44,
      "avg_confidence": 0.91,
      "confidence_samples": 640,
      "top_source": "ChatGPT",
      "visitors": 505,
      "revenue_usd_cents": 214000
    }
  ],
  "meta": { "source": "top_pages_mv_store", "ai_only": true }
}
```

<Warning>
  `ai_sessions` counts sessions that **viewed** the page. `visitors` and `revenue_usd_cents` count sessions that **started** on it, and revenue is credited to the page a visitor landed on, not the page a purchase fired on.

  A deep page nobody lands on will legitimately show hundreds of `ai_sessions` and zero `visitors`. Sort by one or the other, and label your chart with whichever you sorted by. "The checkout page earned $12,000" is not a claim this data makes; "visitors who landed here later spent $12,000" is.
</Warning>

`visitors` and `revenue_usd_cents` are **absent** from a row, rather than 0, when your window reaches past the session-record retention boundary (at least 730 days, at most 760). Unlike the five session-grain breakdowns above, this endpoint does not refuse such a window: its other columns come from a rollup that is kept indefinitely and stay correct, so it drops only the two fields it cannot answer.

`avg_confidence` is a 0.0 to 1.0 mean over the page's exit events, the only events the classifier scores, and `confidence_samples` is how many of those events are behind it. `avg_confidence` is `null`, never `0`, when nothing was measured, and `confidence_samples` is `0` on exactly those rows. `null` is routine rather than an error: a pageview sent from a server SDK produces no exit event, and neither does a page load whose unload beacon is lost. `0.0` means measured and not confident, which is a different statement from not measured, so render an absent value as unknown. Use `confidence_samples` as the weight if you roll several pages or windows into one average.

<Warning>
  **`url` includes the query string, and the query string is only partly redacted.**

  The tracking snippet replaces the value of a [known set of personal-data parameter names](/privacy/data-collected#what-we-do-not-collect) (`email`, `phone`, and similar) plus anything whose name contains `token`, `secret` or `key`. **Any other parameter survives verbatim.** A URL such as `/search?q=jane@example.com` is stored and returned as written.

  **That redaction happens in the browser, and it is the only place it happens.** A pageview sent from a server SDK, or posted directly to `/v1/server/events`, is stored exactly as you sent it: no parameter is redacted, not even `email` or `*_token`. If you send pageviews from your own server, sanitise the URL before you send it.

  This is the same field the dashboard's Top Pages card has always shown, so it is not new data. What is new is that it is now machine readable, so it can end up in a spreadsheet export, a BI warehouse or a Slack digest without anyone reading it first. If you pipe this endpoint somewhere durable, strip or hash the query string on your side.
</Warning>

On hostnames, ports are dropped and case is normalised, so `SHOP.example.com:8443` and `shop.example.com` are one row. A host's `sessions` is a distinct count: a session that viewed three pages on one host counts once for that host. `visitors` is distinct in the same way, so neither field adds up across hosts.

## Exit links count outbound clicks, across all traffic

`/v1/breakdowns/exit-links` ranks the external domains visitors left your site for, by distinct sessions. It carries `"ai_only": false` in `meta`: every session counts, whatever its source.

```json theme={null}
{
  "status": "success",
  "data": [
    { "exit_domain": "github.com", "sessions": 214, "visitors": 188 },
    { "exit_domain": "www.stripe.com", "sessions": 97, "visitors": 95 }
  ],
  "meta": { "source": "exit_links_by_day_mv_store", "ai_only": false }
}
```

<Warning>
  **Only outbound link clicks are counted.** A row is recorded when a visitor clicks a link that takes their current tab to another site. Closing the tab, typing a new address, using the back button, or opening a link in a new tab records nothing. These counts are not the number of sessions that ended, and you cannot turn them into an exit rate.
</Warning>

A link to the page's own hostname, to a parent or child of that hostname, or to anything under your configured cookie domain is not outbound and never appears here.

`exit_domain` is the destination's hostname as the browser reported it, in lowercase, with no path and no query string. It is not shortened to a registrable domain, so `www.stripe.com` and `stripe.com` are separate rows.

A session that left for two different domains counts once under each, so the rows do not add up to a number of sessions, and this breakdown returns no `meta.total_sessions`. `visitors` is the distinct visitors behind a domain's sessions, counted by browser cookie; a visitor who left for the same domain in two visits counts once, and visitors do not add up across rows either.

Hostnames have no `visitors` count: the rollup behind that breakdown holds session counts only.

Exit links are kept for 730 days. A `from` older than that returns `422` rather than a silently low count. Counting starts when this breakdown became available; outbound clicks from before then are not included.

## Filtering hostnames and exit links

`/v1/breakdowns/hostnames` and `/v1/breakdowns/exit-links` accept `dimension`/`value` query parameters to scope rows to sessions matching one or more conditions. No other breakdown endpoint accepts them; sending `dimension` to, say, `/v1/breakdowns/countries` returns `422`.

```bash theme={null}
curl "https://api.traceten.com/v1/breakdowns/hostnames?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12&from=2026-08-01&to=2026-08-07&dimension=country&value=US&dimension=device&value=mobile" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

| Parameter   | Type   | Required | Notes                                                                                                                        |
| ----------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `dimension` | string | no       | Repeatable. One of `device`, `os`, `browser`, `country`, `city`, `channel`, `source`, `campaign`, `ref`, `page`, `hostname`. |
| `value`     | string | no       | Repeatable, paired positionally with `dimension`. 1 to 256 characters.                                                       |

`dimension` and `value` are paired by position: the example above is `country=US AND device=mobile`, not `country=US OR device=mobile`, and every pair must match for a session to count. You can stack up to 8 distinct dimensions, each with up to 20 values.

<Warning>
  **A filtered request is slower than an unfiltered one.** These two endpoints normally read a pre-aggregated rollup. A filtered request instead joins the matching sessions to raw events, so it costs more and can time out on a wide date range for a high-traffic site. If it does, you get a `500` rather than a partial or hung response. Narrow the date range and retry.
</Warning>

## Referrers means source name

`/v1/breakdowns/referrers` returns the source Traceten resolved, not the raw HTTP `Referer` header. The header is missing for most AI-referred traffic, which is the reason this product exists. For a channel the row names the referring host; for an AI assistant it names the assistant.

```json theme={null}
{
  "status": "success",
  "data": [
    {
      "source_kind": "ai",
      "ai_source": "ChatGPT",
      "source_name": "chatgpt.com",
      "detection_method": "rules",
      "visitors": 980,
      "sessions": 1204,
      "bounces": 96,
      "avg_time_on_page": 51.2
    }
  ],
  "meta": {
    "source": "sources_by_day_mv_store",
    "other_sessions": 318,
    "other_visitors": 270,
    "other_bounces": 44
  }
}
```

Everything below the cut is summarised in `meta.other_sessions`, `meta.other_visitors` and `meta.other_bounces` rather than returned as a row, so the list stays bounded without losing traffic. They are **page-relative**: paging deeper fetches more named sources, so the remainder shrinks, and on the last page it is the complete tail. They are `null` when nothing was cut off. `sessions` is a distinct-count estimate here too: merge, do not sum.

`visitors` counts distinct visitors, by browser cookie, whose visit **started** from that source. `sessions` counts sessions under every source any of their events came from, so for a small share of visits the two are measured against slightly different sources. Visitors do not add up across rows; `other_visitors` is recounted over the tail, not summed.

## Campaigns are an open set

`/v1/breakdowns/campaigns` carries `"open_set": true` in `meta`, because `utm_campaign` is free text off a visitor's URL rather than a fixed list. Two consequences:

* The list is a top-N. It is never "all campaigns", however large a `limit` you send.
* Its `unknown` row is normally the **largest**, because most visitors arrive with no campaign at all. That is correct and it is not filtered out.

Campaign values are informational. They have never influenced how Traceten detects an AI source.

<Warning>
  **`utm_campaign` is free text off the visitor's URL and is not redacted.** It reaches this
  endpoint as written. It is a campaign label in normal use, but nothing stops a mistyped or
  generated link putting something else there, so treat it as untrusted input in anything you build
  on top, exactly as you would `url` above.
</Warning>

## There is no regions breakdown

`/v1/breakdowns/regions` does not exist and returns `404`.

Traceten collects geography at city granularity and nothing between city and country. There is no region, state or postcode field to report, by design rather than by omission. Use `/v1/breakdowns/countries` or `/v1/breakdowns/cities`.

## Pagination

`has_more` tells you whether another page exists.

`pagination.total` is the exact number of rows available, but it is **only present on the breakdowns that hold the whole list already**: devices, browsers, operating systems, campaigns, cities and countries. It is **absent** on pages, hostnames and referrers, whose rows come from a database-side top-N where a total would need a second pass over the same data. Branch on whether the field is there; do not treat a missing `total` as zero.

These lists are top-N results computed inside the database, so they have a ceiling: paging past roughly 100 rows on the device, browser, operating system, campaign and city breakdowns will return `has_more: false` even though a longer tail exists in principle. Use `meta.total_sessions` to account for that tail rather than paging for it.

## The two AI breakdowns

`/v1/breakdowns/ai-sources` and `/v1/breakdowns/source-kinds` live on the same path prefix but are not shaped like the nine above. They take their own parameters, cap the window at 730 days, and return their own response bodies. Everything from here down describes those two.

## `ai_source` and `source_kind` are different questions

These two get confused more than anything else in the API, so before the endpoints:

| Field         | Answers                   | Values                                                         |
| ------------- | ------------------------- | -------------------------------------------------------------- |
| `ai_source`   | Which assistant sent them | `ChatGPT`, `Claude`, `Perplexity`, `Gemini`, `Copilot`, `Grok` |
| `source_kind` | What category of traffic  | `ai`, `non_ai`, `unknown`                                      |

They are separate axes over the same sessions, not two levels of one hierarchy. A number from `/ai-sources` and a number from `/source-kinds` describe overlapping populations, so adding them together double counts.

There is a fourth `source_kind` value, `unmatched`, that you will only ever see on [`GET /v1/detection/coverage`](/api/detection). It means no browsing session could be matched to a conversion at all. It cannot appear on either endpoint here, because both are derived from sessions and `unmatched` is the absence of one.

## `GET /v1/breakdowns/ai-sources`

One row per AI assistant over the window, with the detection method and confidence behind it.

```bash theme={null}
curl "https://api.traceten.com/v1/breakdowns/ai-sources?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12&from=2026-08-01&to=2026-08-07" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

````json theme={null}
{
  "status": "success",
  "data": {
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "from": "2026-08-01",
    "to": "2026-08-07",
    "sources": [
      {
        "source_kind": "ai",
        "ai_source": "ChatGPT",
        "source_name": "chatgpt.com",
        "detection_method": "referrer_match",
        "avg_confidence": 0.94,
        "sessions": 3100,
        "bounces": 402,
        "avg_time_on_page": 51.7,
        "is_other": false,
        "revenue_usd_cents": 419000,
        "revenue_display": 4190.0,
        "conversion_count": 61
      }
    ],
    "revenue_below_cut_usd_cents": 0,
    "conversions_below_cut": 0,
    "limit": 25,
    "truncated": false,
    "currency": "USD",
    "converted": false
### Parameters

| Parameter | Type    | Required | Notes                                                       |
| --------- | ------- | -------- | ----------------------------------------------------------- |
| `site_id` | string  | yes      | The snippet key.                                            |
| `from`    | string  | no       | `YYYY-MM-DD`, inclusive. Must be sent with `to`.            |
| `to`      | string  | no       | `YYYY-MM-DD`, inclusive. Must be sent with `from`.          |
| `limit`   | integer | no       | 1 to 100, default 25. Named sources before the roll-up row. |

Send `from` and `to` together, or neither. With neither, the window is the last 7 days.

Two limits apply. The range may not span more than 730 days, and `from` may not be more than 730 days ago. Both come from how long session-level data is kept: past that point the store returns fewer rows rather than an error, so we refuse the window instead of quietly returning a smaller number.

`timezone` is not accepted. These figures are stored as UTC calendar days, so a local day boundary cannot be applied to them. Sending anything other than `UTC` returns `422` rather than UTC numbers labelled with your zone.

### Response fields

| Field                         | Type             | Notes                                                             |
| ----------------------------- | ---------------- | ----------------------------------------------------------------- |
| `site_id`                     | string           | Echoed back in the shape you sent.                                |
| `sources`                     | array            | One row per assistant, most sessions first.                       |
| `revenue_below_cut_usd_cents` | integer          | Attributed AI revenue whose source fell below `limit`. See below. |
| `conversions_below_cut`       | integer          | The conversion count behind that figure.                          |
| `limit`, `truncated`          | integer, boolean | `truncated` is `true` when a roll-up row was returned.            |
| `currency`, `converted`       | string, boolean  | Your reporting currency, and whether conversion happened.         |
| `missing_rate_dates`          | array            | Only present when some date in the window had no exchange rate.   |

Each `sources` row:

| Field               | Type             | Notes                                                                                                                                                                           |
| ------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ai_source`         | string           | The assistant. `""` on the roll-up row.                                                                                                                                         |
| `source_kind`       | string           | Always `ai` here, or `other` on the roll-up row.                                                                                                                                |
| `source_name`       | string           | The assistant's host, such as `chatgpt.com`. Empty when the source names no specific provider; `other` on the roll-up row.                                                                                                               |
| `detection_method`  | string           | The dominant detection method for this source. See below.                                                                                                                       |
| `avg_confidence`    | number \| `null` | 0.0 to 1.0. `null` when nothing was measured, never `0`.                                                                                                                        |
| `sessions`          | integer          | Distinct sessions over the whole window.                                                                                                                                        |
| `bounces`           | integer          | Sessions with one pageview and between 0 and 15 seconds of measured active time. A session with no measured active time is NOT a bounce: 0 there means unmeasured, not instant. |
| `avg_time_on_page`  | number           | Seconds.                                                                                                                                                                        |
| `revenue_usd_cents` | integer          | Attributed revenue in US cents.                                                                                                                                                 |
| `revenue_display`   | number \| `null` | The same money in your reporting currency. `null` when unstatable.                                                                                                              |
| `conversion_count`  | integer          | Revenue-generating conversions.                                                                                                                                                 |
| `is_other`          | boolean          | `true` on the single roll-up row. Match on this, never on a name.                                                                                                               |

### Every classification carries its method and its confidence

There is no binary "AI or not AI" anywhere in this API. Every classified row tells you how the classification was made and how sure we are, so you can audit our numbers rather than take them.

`detection_method` values are `referrer_match`, `utm_match`, `user_agent_match`, `ml_classifier`, `non_ai_referrer`, `non_ai_utm` and `unknown`. A source can be detected several ways in one window; the value returned is the method behind the most sessions.

`avg_confidence` is a 0.0 to 1.0 mean over exit events, the only events the classifier scores, weighted by how many of those events each day contributed, so a quiet day cannot swing it. It is `null` when we have sessions for a source but no confidence measurements in the window. That is deliberate: `0.0` means "measured, and not confident", which is a different statement from "not measured".

### The two revenue figures, and the two things missing from them

`revenue_usd_cents` is how revenue is stored: integer US cents. `revenue_display` is the same money in your reporting currency, converted at the rate for the day it was earned, so a figure you read last month does not move when rates do. When a date has no rate, that row's `revenue_display` is `null` and the date appears in `missing_rate_dates`. It is never the US dollar figure relabelled.

Two amounts are deliberately outside the `sources` list:

- Revenue from an AI source below the `limit` cut is reported once as `revenue_below_cut_usd_cents`. It is not folded into the roll-up row, because a roll-up is a bucket and would read as if it were a source.
- Revenue we could not match to any session is not here at all. It is usually the largest single bucket on a real site, and it is reported by [`GET /v1/detection/coverage`](/api/detection).

## `GET /v1/breakdowns/source-kinds`

The coarse split: how traffic and revenue divide across `ai`, `non_ai` and `unknown`, with the named sources inside each.

```bash
curl "https://api.traceten.com/v1/breakdowns/source-kinds?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12&from=2026-08-01&to=2026-08-07" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
````

```json theme={null}
{
  "status": "success",
  "data": {
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "from": "2026-08-01",
    "to": "2026-08-07",
    "currency": "USD",
    "kinds": [
      {
        "source_kind": "ai",
        "sessions": 3100,
        "visitors": 2740,
        "conversion_count": 61,
        "revenue_usd_cents": 419000,
        "sources": [
          {
            "ai_source": "ChatGPT",
            "source_name": "chatgpt.com",
            "detection_method": "referrer_match",
            "sessions": 2410
          }
        ]
      }
    ],
    "totals": {
      "visitors": 11200,
      "new_visitors": 8400,
      "sessions": 12450,
      "conversions": 210,
      "revenue_usd_cents": 1840000
    },
    "retention_floor": null,
    "limit": 25
  }
}
```

### Parameters

| Parameter | Type    | Required | Notes                                                                                     |
| --------- | ------- | -------- | ----------------------------------------------------------------------------------------- |
| `site_id` | string  | yes      | The snippet key.                                                                          |
| `from`    | string  | no       | `YYYY-MM-DD`, inclusive. Must be sent with `to`.                                          |
| `to`      | string  | no       | `YYYY-MM-DD`, inclusive. Must be sent with `from`.                                        |
| `limit`   | integer | no       | 1 to 100, default 25. Named sources listed inside EACH kind, not a bound on the response. |

Same window rules as `/ai-sources`: 730 day maximum span, `from` no more than 730 days ago, no `timezone`.

`limit` bounds each kind's list separately, and it is pushed into a separate query per kind rather than applied to one shared list afterwards. That distinction is the point: a site with hundreds of named non-AI sources would otherwise fill a single ranking before any AI source appeared, and slicing that list per kind afterwards would hand you an empty AI list on the endpoint family built to surface AI traffic.

Each kind carries its own `truncated`, telling you whether that kind's list was cut.

### Response fields

| Field             | Type             | Notes                                                     |
| ----------------- | ---------------- | --------------------------------------------------------- |
| `site_id`         | string           | Echoed back in the shape you sent.                        |
| `currency`        | string           | Always `USD` here. See below.                             |
| `kinds`           | array            | One row per source category.                              |
| `totals`          | object           | Site-wide figures over the same sessions.                 |
| `retention_floor` | string \| `null` | Non-null only when your window touches the 730 day floor. |
| `limit`           | integer          | Echoed back.                                              |

Each `kinds` row:

| Field               | Type    | Notes                                             |
| ------------------- | ------- | ------------------------------------------------- |
| `source_kind`       | string  | `ai`, `non_ai` or `unknown`.                      |
| `sessions`          | integer | Sessions that started in the window, exact count. |
| `visitors`          | integer | Distinct browsers, not people.                    |
| `conversion_count`  | integer | Revenue-generating conversions.                   |
| `revenue_usd_cents` | integer | US cents. Always USD.                             |
| `truncated`         | boolean | This kind's `sources` list was cut at `limit`.    |
| `sources`           | array   | Named sources, each with `detection_method`.      |

`totals` carries `visitors`, `new_visitors`, `sessions`, `conversions` and `revenue_usd_cents`.

<Warning>
  Adding up `kinds[].visitors` gives a **larger** number than `totals.visitors`, and that is correct
  rather than a bug. Visitor counts are distinct-browser counts, and one browser can appear under
  two categories across two sessions. Each figure is merged separately, so use `totals.visitors` for
  a site-wide number and never sum the rows to reproduce it.
</Warning>

A visitor is a **browser**, not a person: the same human on a phone and a laptop is two.
`new_visitors` counts browsers whose session was their first ever page load, so label it
"new browsers" or "new visitors", never "new users" or "new people".

### `unknown` is a real bucket

`unknown` is a session whose entry source we could not place, either because the row predates the field or because no signal identified it. It is never dropped, because dropping it would make the shares stop adding up to your traffic.

Do not confuse it with `unmatched` on the coverage endpoint. `unknown` means "we saw the session and could not name its source". `unmatched` means "we never found a session at all".

### Revenue here is US dollars

`revenue_usd_cents` on this endpoint is US cents and there is no `revenue_display` field, even if your reporting currency is not USD.

This is not an oversight. Our rule is to convert money at the exchange rate of the day it was earned, and this endpoint reports one total for the whole window rather than a figure per day. There is no honest rate to apply to that total, and applying today's rate would silently reprice history. `/ai-sources` keeps its per-day rows and is converted.

### Do not sum the inner list to get the outer number

`kinds[].sessions` is an exact count of sessions in that category. `kinds[].sources[].sessions` is a top N list, bounded by `limit`, and it is measured slightly differently: the kind total counts sessions that started in the window, the inner list counts sessions active in it. The two will not add up, and the outer number is the one to report.

`retention_floor` is `null` on almost every request. It becomes non-null only when your `from` lands on the oldest day still covered by the 730 day session retention, which is the one date where part of the window may already have expired. When it is set, treat it as the oldest date this response is trustworthy for.

## Errors

### The dimension breakdowns

Beyond the [standard errors](/api/errors):

| Status | When                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `404`  | `/v1/breakdowns/regions`, which does not exist.                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `422`  | A `filter_*` parameter, a non-UTC `timezone`, a half-open date range, a `limit` above 100, an unknown field in `fields`, or an unrecognised query parameter. On `/v1/breakdowns/exit-links`, also a `from` more than 730 days back. On `/v1/breakdowns/hostnames` and `/v1/breakdowns/exit-links`, also an unpaired `dimension`/`value`, an unrecognised `dimension`, more than 8 distinct dimensions, more than 20 values for one dimension, or a `value` outside 1 to 256 characters. |
| `500`  | On `/v1/breakdowns/hostnames` or `/v1/breakdowns/exit-links` with `dimension`/`value` set, a filtered query that exceeded its time or memory budget. Narrow the date range and retry.                                                                                                                                                                                                                                                                                                   |

### `ai-sources` and `source-kinds`

| Status | Cause                                                                                                                                                        |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `401`  | Missing, malformed, revoked or expired key.                                                                                                                  |
| `403`  | The key is valid but lacks `stats:read`. The body names the permission.                                                                                      |
| `404`  | No such site, or the key does not cover it.                                                                                                                  |
| `422`  | Bad `site_id`, `from` without `to`, a range over 730 days, a `from` past the 730 day floor, a `timezone`, or an unknown parameter. The body names the field. |
| `429`  | Rate limited. Back off and retry.                                                                                                                            |
| `500`  | The query failed. You will never get zeroes standing in for an error.                                                                                        |

An unknown query parameter is a `422` rather than being ignored. A silently dropped filter returns the wrong number with a `200` beside it, and nothing looks broken.

## Next

* [Stats API](/api/stats) for the totals these breakdowns split up.
* [Sources API](/api/sources) for the per-day source series.
* [Detection](/api/detection) for the bot filter and attribution coverage behind the AI figures.
* [AI crawlers](/api/ai-crawls) for what AI crawlers read on your site.
