> ## 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.

# Visibility API

> Whether answer engines mention and cite you, how that compares to competitors, the verbatim answers behind every number, and the brands and questions those numbers are measured over.

## Base URL and authentication

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

Takes `Authorization: Bearer <YOUR_API_KEY>` ([details](/api/authentication)). Reading figures needs the `stats:read` permission. Changing what is measured, under [Configuring what gets measured](#configuring-what-gets-measured), needs `config:write`.

These endpoints measure a different thing from the rest of the API. Everywhere else, Traceten counts what happened on your site. Here, Traceten asks answer engines your own tracked questions on a schedule and records what they said. Nothing on this page is derived from your visitors, and nothing here appears in your session, visitor or revenue figures.

It is a poll of the prompt set you defined, not an observation of what real people asked. [How AI visibility is measured](/ai-visibility/methodology) covers the sampling method and every formula below; [AI answer engines](/ai-visibility/engines) covers what each engine is and what we send it.

## Three numbers, never one score

Traceten does not publish a single "visibility score". Three separate measures answer three different questions, and blending them would hide which one moved.

| Measure            | What it counts                                             | Where it appears       |
| ------------------ | ---------------------------------------------------------- | ---------------------- |
| **Presence rate**  | Answered runs that mentioned your brand                    | `brand.presence`       |
| **Share of voice** | Your mentions as a share of every tracked brand's mentions | `brand.share_of_voice` |
| **Citation rate**  | Answered runs that cited one of your domains               | `brand.citation`       |

A mention is not a citation. An engine can name you without linking to you, which usually means the model already knows your brand. It can also cite your page without naming you, which means your content answered the question and you got no credit in the text. Those are different problems with different fixes, so the two numbers never merge.

Position weighting is a fourth measure, `prominence`, and it is deliberately kept out of presence. See [Prominence](#prominence).

## Confidence intervals, and when a change is real

Every rate carries a [Wilson 95% interval](https://en.wikipedia.org/wiki/Binomial_proportion_confidence_interval#Wilson_score_interval) as `low` and `high`.

```json theme={null}
"presence": { "rate": 0.34, "low": 0.19, "high": 0.53 }
```

Report the band, not just the rate. A 34 percent presence rate measured over 15 runs and one measured over 1,500 are different claims, and only the interval shows which you have.

`comparison.presence_changed` is `true` only when this window's interval and the previous window's interval **do not overlap**. Two rates that differ while their bands overlap are flat. This is stricter than comparing the rates, and deliberately so: a move from 3 of 10 to 4 of 10 looks like a 33 percent improvement and is indistinguishable from noise.

## The denominator is `answered_runs`, not `runs`

An engine does not always answer. Google renders an AI Overview for some questions and not others, and a run where nothing rendered is a real observation about the search results page, not a sample of whether you were named in one.

So every rate on this page divides by answered runs. Both figures are returned, plus `coverage`, which is `answered_runs / runs`.

More precisely, a brand's rates divide by **`scored_answered_runs`**: the answered runs in the scans that brand was actually scored in, which is not always the site total. A competitor you added half way through a window has rows only from the day you configured it, and charging it for the days before it existed would halve its presence rate for no reason. For a brand present throughout, `scored_answered_runs` equals `answered_runs`.

Read them together:

* Low `coverage` means the engine is not answering this question at all. Rewriting your page will not change it. A different prompt might.
* Low `presence` with high `coverage` means the engine answers the question and does not mention you.

## Empty states that are not zero

Three states look like a 0 percent presence rate and are not. Rendering any of them as 0 tells a customer they are invisible when the truth is that nothing was measured.

| Field                     | Meaning                                                                                                                                                                                                                                |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `has_data: false`         | No runs landed **in this window**. Window scoped, not lifetime: with a 10 day scan cadence, a 7 day request can legitimately contain no runs for a site that is being scanned normally.                                                |
| `has_unscored_runs: true` | At least one scan in the window produced no brand row, because none was confirmed for it to look for. Goes false again once every scan in the window is being scored.                                                                  |
| `presence: null`          | Either no run in the window was answered, or **this brand** was not scored in any of them. `scored_answered_runs` says which: 0 means the brand had no rows, which is the case for a brand you configured part way through the window. |

`citation` and `share_of_voice` go null under the same conditions as `presence`, so all three move together. A brand that was never scored returns null for every rate rather than a 0 percent that would draw a false drop on the day it was created.

### `has_data` means something narrower on the citation endpoints

On `/summary`, `/timeseries` and `/competitors`, `has_data: false` means no runs landed in the window.

On `/citations`, `/citations/by-page` and `/runs` it means the window produced no rows of that kind: no citations, no cited pages of yours, no stored answers. Those are measured zeroes, not absent measurements. A scan can run, answer, and cite nobody.

## `GET /v1/visibility/summary`

Presence, share of voice and citation rate for a window, with the previous window for comparison.

```bash theme={null}
curl "https://api.traceten.com/v1/visibility/summary?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12&from=2026-08-24&to=2026-09-02" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

```json theme={null}
{
  "status": "success",
  "data": {
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "has_data": true,
    "window": { "from": "2026-08-24", "to": "2026-09-02", "days": 10 },
    "runs": 100,
    "answered_runs": 88,
    "coverage": 0.88,
    "rep_sets": 20,
    "scored_brands": 4,
    "has_unscored_runs": false,
    "brand": {
      "brand_id": "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "name": "Acme Analytics",
      "kind": "self",
      "archived": false,
      "mention_runs": 30,
      "citation_runs": 12,
      "scored_runs": 100,
      "scored_answered_runs": 88,
      "presence": { "rate": 0.34, "low": 0.25, "high": 0.45 },
      "share_of_voice": 0.27,
      "citation": { "rate": 0.136, "low": 0.079, "high": 0.225 }
    },
    "prominence": {
      "score": 0.42,
      "ranked_mentions": 22,
      "unranked_mentions": 8,
      "best_position": 1
    },
    "prominence_window": { "from": "2026-08-24", "to": "2026-09-02", "days": 10 },
    "prominence_retention_days": 365,
    "engines": [
      {
        "engine": "google_ai_overviews",
        "label": "Google AI Overviews",
        "runs": 50,
        "answered_runs": 41,
        "prompts_scanned": 10,
        "prompts_answered": 8
      },
      {
        "engine": "perplexity_sonar",
        "label": "Perplexity (Sonar API)",
        "runs": 50,
        "answered_runs": 47,
        "prompts_scanned": 10,
        "prompts_answered": 10
      }
    ],
    "comparison": {
      "window": { "from": "2026-08-14", "to": "2026-08-23", "days": 10 },
      "presence": { "rate": 0.31, "low": 0.22, "high": 0.42 },
      "presence_changed": false
    }
  }
}
```

That response says: **34 percent presence over the 10 days ending September 2**, somewhere between 25 and 45 percent. The previous 10 days read 31 percent. The bands overlap, so `presence_changed` is `false` and this is not a rise.

### Runs and prompts are different denominators

`engines[]` carries both, and they answer different questions.

`runs` and `answered_runs` count engine **calls**: each prompt is asked several times so the interval means something, so these are the repetitions. `prompts_scanned` and `prompts_answered` count distinct **prompts**.

"Google showed an AI Overview for 8 of your 10 prompts" is the second pair. It is not `answered_runs / runs`, which in the example above is 41 of 50 and is a statement about repetitions.

`prompts_scanned` is prompts scanned **in this window**, never the number configured on your site. The two differ whenever a prompt was added mid-window or is paused, and using your configured count as the denominator would label the answer "your 20 prompts" while the numerator covered only what ran. Say "scanned".

An engine that answers fewer prompts than it was asked is not a gap. Google declining to render an AI Overview for a question is information about the question.

### Engine labels

Render `engines[].label`, never the `engine` value. Traceten measures each vendor's **API**, not the consumer product a person uses in a browser, and the two do not always return the same answer. Printing a bare "ChatGPT" over an API measurement would be a claim about a product Traceten did not query.

### Prominence

`prominence` is position weighting: the mean of `1 / rank` across mentions the engine ranked. Higher is better, and 1.0 means every ranked mention came first.

It is reported separately from presence and is never a component of it. Presence asks whether you were named. Prominence asks how near the top. Multiplying them produces a single number that cannot be acted on.

Two fields keep it honest:

* `unranked_mentions` counts mentions the weighting could not describe, because the answer was prose rather than a ranked list. A score computed from 22 of 30 mentions does not describe all 30.
* `prominence_window` is the window prominence was **actually** computed over. Position data is kept for `prominence_retention_days`, which is shorter than the presence trend. Ask for a longer window and this field narrows. Read it, not the window you sent. `null` means the whole request is older than the retained data, and `prominence` is then `null` too.

### Asking about a competitor

`brand_id` selects the **subject** of the response, not just a row filter. Pass a competitor's brand id and the `brand` block describes them.

Two consequences:

* `share_of_voice` comes back `null`. Narrowing the rows to one brand leaves its own mentions as the denominator, which would read 100 percent for whoever you asked about.
* A `brand_id` that names no brand on your site is a 422, not a silent fall back to your own brand.

`/v1/visibility/timeseries` accepts the same parameter and behaves identically, including returning `share_of_voice: null` on every point.

While `brand_id` is set, the site wide block on `/summary` is folded from the filtered rows: `runs`, `answered_runs`, `coverage`, `rep_sets`, `scored_brands`, `engines` and `has_unscored_runs` then describe that brand's rows rather than the whole site. `totals_scope` says which you are looking at, `"site"` or `"brand"`.

`/v1/visibility/runs` rejects a `brand_id` that names no brand on your site, and rejects `brand_id` together with `present=false`: a brand is only ever mentioned in an answered run, so that pairing has no results by construction.

Sending `brand_id=` with an empty value selects the rows for scans that ran while no brand was confirmed. Those carry cost and coverage only, so `brand` comes back `null` rather than a block of zeroes under someone's name. `/v1/visibility/runs` rejects the empty value instead, because those runs have no answers behind them.

## `GET /v1/visibility/timeseries`

Visibility over time, bucketed by scan window.

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

```json theme={null}
{
  "status": "success",
  "data": {
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "has_data": true,
    "window": { "from": "2026-07-01", "to": "2026-09-02", "days": 64 },
    "granularity": "scan_window",
    "points": [
      {
        "scan_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
        "window": { "from": "2026-08-24", "to": "2026-09-02", "days": 10 },
        "scan_window": { "from": "2026-08-24", "to": "2026-09-02", "days": 10 },
        "partial": false,
        "status": "complete",
        "failure_reason": null,
        "runs": 100,
        "answered_runs": 88,
        "coverage": 0.88,
        "mention_runs": 30,
        "citation_runs": 12,
        "presence": { "rate": 0.34, "low": 0.25, "high": 0.45 },
        "citation": { "rate": 0.136, "low": 0.079, "high": 0.225 },
        "share_of_voice": 0.27
      }
    ]
  }
}
```

**One point is one scan window, not one day.** `granularity` is always `scan_window`.

A scan does not run all at once. It trickles across its window to spread cost and stay inside provider rate limits, so the individual days inside a window carry whichever runs happened to land on them. Plotting those days produces a sawtooth that looks like violent week to week swings and is an artefact of the schedule.

Label each point with its `window`. That is the part of the scan window your request actually covers, which is not always the whole scan: ask for September 1 to 2 of a scan that ran from August 24 and the point holds two days of runs, so calling it "the 10 days ending September 2" would describe evidence you did not receive. `scan_window` is what the scan itself covered and `partial` is `true` whenever the two differ. A partial point rests on less evidence than a whole one, so the two are not directly comparable.

Do not re-bucket these points to days.

`status` and `failure_reason` describe the scan behind the point. A `failed` or partial scan produces a point with fewer runs, so a dip in presence that coincides with a failure is a measurement problem, not a visibility problem.

A point with `scan_id: null` holds runs that matched no recorded scan window. Those runs are reported in their own bucket rather than folded into a neighbour, which would move them into a window that did not produce them.

## `GET /v1/visibility/competitors`

Share of voice across every brand you track.

```bash theme={null}
curl "https://api.traceten.com/v1/visibility/competitors?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12&from=2026-08-24&to=2026-09-02" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

```json theme={null}
{
  "status": "success",
  "data": {
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "has_data": true,
    "window": { "from": "2026-08-24", "to": "2026-09-02", "days": 10 },
    "answered_runs": 88,
    "has_unscored_runs": false,
    "brands": [
      {
        "brand_id": "c2d3e4f5-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
        "name": "Northwind Metrics",
        "kind": "competitor",
        "archived": false,
        "is_self": false,
        "mention_runs": 44,
        "citation_runs": 20,
        "scored_runs": 100,
        "scored_answered_runs": 88,
        "presence": { "rate": 0.5, "low": 0.396, "high": 0.604 },
        "citation": { "rate": 0.227, "low": 0.152, "high": 0.325 },
        "share_of_voice": 0.4
      },
      {
        "brand_id": "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
        "name": "Acme Analytics",
        "kind": "self",
        "archived": false,
        "is_self": true,
        "mention_runs": 30,
        "citation_runs": 12,
        "scored_runs": 100,
        "scored_answered_runs": 88,
        "presence": { "rate": 0.34, "low": 0.25, "high": 0.45 },
        "citation": { "rate": 0.136, "low": 0.079, "high": 0.225 },
        "share_of_voice": 0.27
      }
    ]
  }
}
```

`share_of_voice` is a share of **mentions**, not of runs. One answer that names three brands adds to three numerators, so these values relate to each other and not to `answered_runs`. They sum to 1 across every brand you track, which may include brands beyond the two shown above.

There is no `brand_id` filter on this endpoint. Narrowing to one brand would leave the share of voice denominator holding a single member, and every row would read 100 percent.

Each row divides by its own `scored_answered_runs`, not by the top level `answered_runs`, so `mention_runs / answered_runs` will not reproduce `presence.rate` for a brand you configured part way through the window. Use the value on the row.

A brand with `archived: true` is no longer scanned. It keeps its name so an older series stays labelled.

## `GET /v1/visibility/by-prompt`

One row per prompt you track: presence with its interval, the brands that beat you on that question, and the pages the engines cited for it.

```bash theme={null}
curl "https://api.traceten.com/v1/visibility/by-prompt?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12&from=2026-08-24&to=2026-09-02" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

```json theme={null}
{
  "status": "success",
  "data": {
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "has_data": true,
    "window": { "from": "2026-08-24", "to": "2026-09-02", "days": 10 },
    "evidence_window": { "from": "2026-08-24", "to": "2026-09-02", "days": 10 },
    "totals_scope": "site",
    "has_unscored_runs": false,
    "prompts": [
      {
        "prompt_id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
        "prompt_text": "best AI traffic attribution tool for SaaS",
        "topic": "attribution",
        "intent": "category_discovery",
        "status": "active",
        "runs": 20,
        "answered_runs": 18,
        "mention_runs": 6,
        "citation_runs": 3,
        "scored_runs": 20,
        "scored_answered_runs": 18,
        "presence": { "rate": 0.333, "low": 0.164, "high": 0.564 },
        "citation": { "rate": 0.167, "low": 0.058, "high": 0.397 },
        "share_of_voice": 0.24,
        "top_competitors": [
          {
            "brand_id": "c2d3e4f5-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
            "name": "Northwind Metrics",
            "kind": "competitor",
            "archived": false,
            "is_self": false,
            "mention_runs": 14,
            "citation_runs": 8,
            "scored_runs": 20,
            "scored_answered_runs": 18,
            "presence": { "rate": 0.778, "low": 0.548, "high": 0.906 },
            "citation": { "rate": 0.444, "low": 0.247, "high": 0.66 },
            "share_of_voice": 0.56
          }
        ],
        "top_citations": [
          {
            "url": "https://northwind.example.com/compare",
            "path": "/compare",
            "domain": "northwind.example.com",
            "is_owned": false,
            "brand_id": "c2d3e4f5-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
            "citations": 9
          }
        ]
      }
    ],
    "pagination": { "limit": 25, "offset": 0, "has_more": false }
  }
}
```

Use this rather than calling `/summary?prompt_id=` and `/competitors?prompt_id=` once per prompt. That is two requests per prompt against your per-minute read budget, and the cited-pages half cannot be asked for a single prompt any other way: `/citations` takes no `prompt_id`.

Prompts come back most-scanned first.

### The interval matters more here than anywhere else

A per-prompt sample is a handful of repetitions, not the whole site's runs, so the bands are wide. Two prompts are only really different when their intervals do not overlap. Ranking prompts by `presence.rate` alone ranks noise.

### `runs: 0` is not zero presence

A prompt you configured but that was not scanned in this window comes back with `runs: 0` and `presence: null`. It has not been measured. Rendering it as 0 percent says the engines were asked and did not name you, which is a different and false statement.

### `top_citations` covers `evidence_window`, not `window`

The cited-pages list is built from citation detail rows, which are kept for a shorter period than the rates. `evidence_window` is the window that list was actually read over. It is narrower than `window` on a long request, and `null` when the whole request is older than the horizon, while `presence` still answers for the full window.

`evidence_retention_days` reports how long that detail is kept. Read it from the response rather than assuming a figure.

An empty `top_citations` inside a narrowed `evidence_window` means unmeasured, not uncited.

### `top_competitors` is the top five, not the list

Up to five brands per prompt, ordered by share of voice, with your own marked `is_self`. It answers "who beat us on this question". For the complete brand list use [`/v1/visibility/competitors`](#get-v1visibilitycompetitors).

### Optional parameters

| Parameter  | Values                            | Meaning                            |
| ---------- | --------------------------------- | ---------------------------------- |
| `engine`   | Any value from `engines[].engine` | One answer engine                  |
| `brand_id` | UUID                              | The brand each `presence` is about |
| `limit`    | 1 to 100                          | Prompts per page. Default 25.      |
| `offset`   | 0 or more                         | Rows to skip                       |

`brand_id` behaves as it does on `/summary`: it selects the subject of each `presence` **and** narrows the rows. `totals_scope` then reads `brand`, and `top_competitors` collapses to that one brand with a null share of voice. Omit it whenever the question is comparative.

`top_citations` is the one field `brand_id` does not narrow. Restricting it to the subject brand's own domains would turn "the pages cited instead of ours" into "our pages that were cited", which is the opposite question.

## `GET /v1/visibility/citations`

Which URLs the engines cited, yours and everyone else's.

```bash theme={null}
curl "https://api.traceten.com/v1/visibility/citations?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12&from=2026-08-24&to=2026-09-02&limit=50" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

```json theme={null}
{
  "status": "success",
  "data": {
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "has_data": true,
    "window": { "from": "2026-08-24", "to": "2026-09-02", "days": 10 },
    "citations": [
      {
        "url": "https://acme.example.com/guides/attribution",
        "path": "/guides/attribution",
        "domain": "acme.example.com",
        "is_owned": true,
        "brand_id": "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
        "name": "Acme Analytics",
        "kind": "self",
        "archived": false,
        "citations": 18,
        "days_cited": 4,
        "peak_prompts_in_a_day": 3,
        "peak_engines_in_a_day": 2
      }
    ],
    "truncated": false
  }
}
```

### Optional parameters

| Parameter  | Values           | Meaning                                       |
| ---------- | ---------------- | --------------------------------------------- |
| `is_owned` | `true` / `false` | Only your URLs, or only everyone else's       |
| `limit`    | 1 to 250         | Rows to return, most cited first. Default 50. |

`citations` is a total and adds up across days.

`peak_prompts_in_a_day` and `peak_engines_in_a_day` are the busiest **single day**, not window totals. The underlying counts are per day distinct counts, and distinct counts do not add: the same prompt citing the same URL on Monday and again on Tuesday is one prompt, not two. A window wide distinct count is not derivable from this endpoint, so Traceten returns the honest per day figure under a name that says what it is.

`brand_id: null` means the cited domain matches no brand you configured. It is neither yours nor a tracked competitor.

## `GET /v1/visibility/citations/by-page`

Your own cited pages, joined to what happened on them.

```bash theme={null}
curl "https://api.traceten.com/v1/visibility/citations/by-page?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12&from=2026-08-24&to=2026-09-02" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

```json theme={null}
{
  "status": "success",
  "data": {
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "has_data": true,
    "window": { "from": "2026-08-24", "to": "2026-09-02", "days": 10 },
    "landing_revenue": { "available": true, "floor_date": "2026-06-05" },
    "pages": [
      {
        "url": "https://acme.example.com/guides/attribution",
        "path": "/guides/attribution",
        "citations": 18,
        "days_cited": 4,
        "peak_prompts_in_a_day": 3,
        "peak_engines_in_a_day": 2,
        "answer_fetch_crawls": 64,
        "answer_fetch_providers": 3,
        "answer_fetch_crawls_host_blind": true,
        "ai_sessions": 412,
        "conversions": 9,
        "landing_visitors": 5200,
        "landing_revenue_usd_cents": 3690000,
        "ai_landing_visitors": 260,
        "ai_landing_revenue_usd_cents": 184500
      }
    ],
    "truncated": false
  }
}
```

Both joins are deterministic. They read observations Traceten already stored. Nothing on this endpoint is modelled or estimated.

### The crawl side

`answer_fetch_crawls` counts an AI company fetching this page to answer a question someone asked right then. It is the closest observable proxy for a real user prompt, and Traceten records it already through [AI crawler tracking](/api/ai-crawls).

`answer_fetch_crawls` is keyed on the URL **path** alone, because the crawl rollup stores no hostname. If you serve several hostnames, two cited URLs that share a path (`example.com/pricing` and `blog.example.com/pricing`, and `/` most of all) carry the **same** count, and adding them together overstates. Every row carries `answer_fetch_crawls_host_blind: true` as the reminder.

**This count is lower than the same page's total on `/v1/ai-crawls/by-page`, and that is correct.** This endpoint counts genuine crawls only: not spoofed, and actually graded. The crawl endpoints report every crawl, including impersonators and rows that could not be checked. A spoofed crawl carries the impersonated company's name, so counting it here would credit a real assistant with an impersonator's fetch.

### The traffic side: two axes

The traffic columns vary along **two** axes, not one. Mixing them is the mistake this table exists to prevent.

| Field                          | Viewed or landed | Which traffic |
| ------------------------------ | ---------------- | ------------- |
| `ai_sessions`                  | Viewed the page  | AI referred   |
| `conversions`                  | Viewed the page  | AI referred   |
| `landing_visitors`             | Landed on it     | All sources   |
| `landing_revenue_usd_cents`    | Landed on it     | All sources   |
| `ai_landing_visitors`          | Landed on it     | AI referred   |
| `ai_landing_revenue_usd_cents` | Landed on it     | AI referred   |

**Never divide across an axis.** `ai_sessions` counts AI referred sessions only, so `landing_revenue_usd_cents / ai_sessions` divides all traffic revenue by AI traffic sessions. On a page where AI is 5 percent of visits that overstates revenue per AI session by about 20 times, with every input individually correct and nothing on the response looking wrong.

To report revenue per AI session, pair `ai_landing_revenue_usd_cents` with `ai_sessions`.

Both populations are returned because both answer a real question: what this cited page is worth to the business, and how much of that AI brought in.

The landing figures count sessions that **started** on this page. A visitor who arrived on `/guides/attribution`, moved to `/pricing` and bought counts against `/guides/attribution`. That is what makes it the right number for a cited page: the citation is what brought them in.

**When `landing_revenue.available` is `false`, all four landing fields are absent from every row.** The window reaches further back than session level data is kept, so the number is unknown rather than zero. Render a dash. A zero is a measurement, and this is the absence of one. `floor_date` is the oldest date the join can answer for.

## `GET /v1/visibility/runs`

The raw answers behind every other number on this page.

```bash theme={null}
curl "https://api.traceten.com/v1/visibility/runs?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12&from=2026-08-24&to=2026-09-02&limit=10" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

```json theme={null}
{
  "status": "success",
  "data": {
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "has_data": true,
    "window": { "from": "2026-08-24", "to": "2026-09-02", "days": 10 },
    "evidence_window": { "from": "2026-08-24", "to": "2026-09-02", "days": 10 },
    "evidence_retention_days": 90,
    "runs": [
      {
        "run_id": "7f8e9d0c-1b2a-4c3d-8e5f-6a7b8c9d0e1f",
        "scan_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
        "date": "2026-08-27",
        "ran_at": "2026-08-27 09:14:02",
        "prompt_id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
        "engine": "perplexity_sonar",
        "engine_label": "Perplexity (Sonar API)",
        "model_id": "sonar-pro",
        "tool_config": { "search_context_size": "medium" },
        "locale": "en-US",
        "rep": 2,
        "present": true,
        "absence_reason": null,
        "answer_text": "For AI traffic attribution, Acme Analytics and Northwind Metrics are the two tools most often recommended...",
        "sources": [
          {
            "url": "https://acme.example.com/guides/attribution",
            "title": "Attribution guide",
            "position": 1
          }
        ],
        "latency_ms": 4210,
        "mentions": [
          {
            "brand_id": "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
            "name": "Acme Analytics",
            "kind": "self",
            "archived": false,
            "matched_text": "Acme Analytics",
            "position": 1,
            "sentiment": "unknown",
            "confidence": 0.95,
            "extraction_method": "alias_exact"
          }
        ],
        "citations": [
          {
            "url": "https://acme.example.com/guides/attribution",
            "path": "/guides/attribution",
            "domain": "acme.example.com",
            "is_owned": true,
            "brand_id": "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
            "position": 1
          }
        ]
      }
    ],
    "pagination": { "limit": 10, "offset": 0, "has_more": false }
  }
}
```

This endpoint exists so that every number decomposes to the text that produced it. `matched_text` appears verbatim inside `answer_text`, so a mention can be highlighted in the answer it came from. `model_id` and `tool_config` are recorded per run, so any figure can be reproduced against the same model and the same search settings.

Answers are never truncated. Cutting one could cut through a matched span and break the property the endpoint exists for, so pages are small instead. `limit` accepts 1 to 50 and defaults to 10.

### Optional parameters

| Parameter   | Values                            | Meaning                                 |
| ----------- | --------------------------------- | --------------------------------------- |
| `prompt_id` | UUID                              | One tracked prompt                      |
| `engine`    | Any value from `engines[].engine` | One answer engine                       |
| `brand_id`  | UUID                              | Runs that mentioned this brand          |
| `present`   | `true` / `false`                  | Only answers, or only recorded absences |
| `limit`     | 1 to 50                           | Answers per page. Default 10.           |
| `offset`    | 0 or more                         | Rows to skip                            |

### `present: false` is a result, not a failure

When an engine renders no answer, Traceten records the run with `present: false`, an empty `answer_text`, and an `absence_reason`. That is a successful measurement of a real outcome. Google declining to render an AI Overview for a question is information about the question. It is not an error and not missing data, and it is what `coverage` on the summary counts.

### Read `evidence_window`, not the window you sent

Answers are kept for `evidence_retention_days`. The rates on `/summary` come from long lived aggregates that go back further.

So a request for a year of evidence returns the evidence that still exists, and `evidence_window` reports the narrower window actually searched. Use it when describing what you got. If the entire request is older than the retention horizon, `evidence_window` is `null` and `runs` is empty, while the summary for the same window still returns a real presence rate.

### Confidence is the only signal of a weak match

Traceten stores short or ambiguous brand aliases at low confidence rather than dropping them, so the evidence stays inspectable. A stoplisted alias still reports `extraction_method: "alias_exact"`, because how a match was found and whether it can be trusted are different questions.

**`confidence` is the only field that separates them.** Mentions below the presence floor appear in this list but were never counted toward the presence rate, so the evidence can legitimately contain more mentions than the rate did.

Do not recompute a presence rate by counting rows here. Use `/summary`.

## Configuring what gets measured

Everything above measures a set you choose: the brands Traceten looks for, and the questions it asks. These endpoints manage that set. They need `config:write`, except the two listings, which need `stats:read`.

**The prompt list is the score.** Every rate on this page is measured over exactly the questions you track and no others. Add a question you rank badly for and your presence rate falls. Remove it and your presence rate rises, with nothing changed on your site. Neither is wrong, but a visibility figure is only comparable over time while the list is stable, so treat a change here as a change to the measurement.

That is also why Traceten will not enrol a suggested prompt for you. If we picked the denominator, we would be setting your grade.

### Two rules that surprise people

**A prompt's text and locale can never be edited.** A run records the prompt's id, not its wording, so editing the text in place would change what a year of history claims to have asked. `POST /v1/visibility/prompts/{id}/replace` archives the old prompt, with its wording and all of its runs intact, and returns a new prompt with a **new id**.

**Archiving is the only delete.** Brands and prompts are never removed. A `brand_id` appears in aggregates that are kept indefinitely, and runs reference a `prompt_id` for as long as they are stored, so a hard delete would leave a chart series that nothing can name.

## `GET /v1/visibility/brands`

The brands matched against every answer. Add `include_archived=true` to include archived ones.

```bash theme={null}
curl "https://api.traceten.com/v1/visibility/brands?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

```json theme={null}
{
  "status": "success",
  "data": {
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "max_brands": 25,
    "brands": [
      {
        "id": "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
        "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
        "name": "Acme Analytics",
        "kind": "self",
        "aliases": ["Acme", "AcmeHQ"],
        "domains": ["acme.com"],
        "confirmed": true,
        "confirmed_at": "2026-08-20T09:14:00.000Z",
        "archived": false,
        "archived_at": null
      }
    ]
  }
}
```

`confirmed: false` means the brand is not being measured at all. Traceten can propose a brand (`kind: "auto"`), and a proposal stays out of every scan until you confirm it. If a brand shows no data, check this first: an unconfirmed brand has no numbers because nothing was looked for.

`max_brands` is 25 per site. It is a matching speed limit, not a plan limit, because every alias of every brand is scanned against every answer. Upgrading does not raise it.

## `POST /v1/visibility/brands`

Track your own brand or a competitor.

```bash theme={null}
curl -X POST "https://api.traceten.com/v1/visibility/brands" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "name": "Acme Analytics",
    "kind": "self",
    "aliases": ["Acme", "AcmeHQ"],
    "domains": ["acme.com"]
  }'
```

| Field     | Type      | Required | Notes                                                               |
| --------- | --------- | -------- | ------------------------------------------------------------------- |
| `site_id` | string    | yes      | Your `ttid_` snippet key, or the site UUID from a dashboard session |
| `name`    | string    | yes      | The canonical name, as it would be written in an answer. Max 200    |
| `kind`    | string    | yes      | `self`, `competitor` or `auto`                                      |
| `aliases` | string\[] | no       | Product names, misspellings, handles. Do not repeat `name`. Max 200 |
| `domains` | string\[] | no       | Owned hosts such as `acme.com`. Subdomains match too. Max 50        |

`self` and `competitor` brands are confirmed the moment you create them, because a person entered them. `auto` brands are created unconfirmed.

Domains are stored as bare, lowercase hosts. A pasted URL such as `https://Acme.com/pricing` is reduced to `acme.com`. Anything that is not a host is rejected with a 422 rather than stored, because a stored value that can never match would show as a citation rate of zero with nothing to debug.

### Read the `warnings` array

A 201 can carry warnings. They are not errors and nothing was rejected.

```json theme={null}
{
  "status": "success",
  "data": {
    "brand": { "id": "b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "name": "Acme Analytics" },
    "warnings": [
      {
        "code": "alias_low_confidence",
        "alias": "pro",
        "message": "\"pro\" is also an ordinary word, so a match on it cannot be told from a coincidence. It is kept and matched, but scored at 0.3, below the 0.5 confidence a mention needs to count toward your presence rate. A longer or more distinctive alias will count."
      }
    ]
  }
}
```

An alias shorter than three characters, or one that is also an ordinary English word, cannot be distinguished from a coincidence in prose. Traceten keeps it and matches it, but scores it at 0.3, below the 0.5 threshold a mention needs to count toward your presence rate. So the alias is saved and it will not move your numbers. Show the warning to whoever entered it: this is the difference between an alias that works and one that quietly never counts.

The second warning, `aliases_truncated`, means the brand's name, aliases and domains together exceed the 200 terms the matcher uses for one brand, so the ones past that will never produce a mention. The brand's own name is always matched first.

Warnings describe the brand as it was **stored**, after blanks and duplicates are dropped, not the list you sent.

## `PATCH /v1/visibility/brands/{id}`

Change a brand's `name`, `kind`, `aliases` or `domains`. Omitted fields are left alone.

```bash theme={null}
curl -X PATCH "https://api.traceten.com/v1/visibility/brands/b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12", "aliases": ["Acme", "AcmeHQ", "Acme Analytics Ltd"]}'
```

`aliases` and `domains` replace the existing lists rather than merging into them. Read the current values first, or the ones you do not resend are dropped.

An archived brand cannot be edited and returns 404. Renaming one would relabel a historical series after the fact, which is the opposite of what archiving protects.

The response carries the same `warnings` array as create.

## `POST /v1/visibility/brands/{id}/confirm`

Admit a proposed (`kind: "auto"`) brand into scanning and scoring.

```bash theme={null}
curl -X POST "https://api.traceten.com/v1/visibility/brands/b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d/confirm" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12"}'
```

Idempotent. Confirming an already confirmed brand returns it unchanged and does not move `confirmed_at`.

## `DELETE /v1/visibility/brands/{id}`

Archive a brand. Returns `204`.

```bash theme={null}
curl -X DELETE "https://api.traceten.com/v1/visibility/brands/b1f2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

The brand stops being matched. Its name is kept so existing charts stay labelled, and its aliases and domains are cleared, because those are matching configuration with no labelling value once the brand is never scanned again. That also frees the name for reuse.

Archiving your own `self` brand leaves scans running and billing with nothing to score. Every presence rate then reports that nothing was configured to look for, which is not the same as zero.

## `GET /v1/visibility/prompts`

The questions Traceten asks, and your allowance.

```bash theme={null}
curl "https://api.traceten.com/v1/visibility/prompts?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

```json theme={null}
{
  "status": "success",
  "data": {
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "prompts": [
      {
        "id": "9c8b7a65-4321-4fed-9876-0a1b2c3d4e5f",
        "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
        "text": "best analytics tool for AI traffic",
        "topic": "analytics",
        "intent": "category_discovery",
        "locale": "en-US",
        "origin": "suggested",
        "status": "active",
        "created_at": "2026-08-20T09:20:00.000Z"
      }
    ],
    "quota": {
      "plan": "growth",
      "plan_name": "Growth",
      "base": 5,
      "extra_prompts": 0,
      "limit": 5,
      "used": 3,
      "remaining": 2
    }
  }
}
```

Add `status=active`, `status=paused` or `status=archived` to narrow the list. Omit it for all three.

`quota.used` counts live prompts, meaning `active` plus `paused`. Archived prompts never count. A paused prompt costs nothing to run but still occupies an allowance slot, because you are holding it.

### Intent buckets

`intent` groups a question by what it asks. Never average a rate across buckets: they measure different events, so a blended figure moves when the mix of questions moves and nothing about your visibility has changed.

| Intent               | What it asks                 | Example                                  |
| -------------------- | ---------------------------- | ---------------------------------------- |
| `category_discovery` | A category, no brand named   | "best analytics tool for AI traffic"     |
| `head_to_head`       | Two named brands compared    | "Acme Analytics vs Globex"               |
| `problem_led`        | A problem, no category named | "how do I tell if ChatGPT sends traffic" |
| `brand_direct`       | Your own brand named         | "what does Acme Analytics cost"          |

`topic` is a free form label beside it. That one is yours, with no fixed set.

## `POST /v1/visibility/prompts`

Add a question.

```bash theme={null}
curl -X POST "https://api.traceten.com/v1/visibility/prompts" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "text": "best analytics tool for AI traffic",
    "intent": "category_discovery",
    "topic": "analytics",
    "origin": "suggested"
  }'
```

| Field     | Type   | Required | Notes                                                                       |
| --------- | ------ | -------- | --------------------------------------------------------------------------- |
| `site_id` | string | yes      | Your `ttid_` snippet key, or the site UUID from a dashboard session         |
| `text`    | string | yes      | The question as a buyer would type it. 1 to 1000 characters. Immutable      |
| `intent`  | string | yes      | One of the four buckets above                                               |
| `origin`  | string | no       | `suggested`, `custom` or `derived_from_crawl`. Defaults to `custom`         |
| `topic`   | string | no       | Free form grouping label, max 120 characters                                |
| `locale`  | string | no       | BCP 47 language and region, such as `en-US`. Defaults to `en-US`. Immutable |
| `status`  | string | no       | `active` or `paused`. Defaults to `active`                                  |

`locale` is part of a question's identity, not a display preference, because the results page an engine sees differs by region.

Every character is billed on every repetition on every engine, so `text` is capped at 1000 characters. That is a cost boundary, not a formatting rule.

## `PATCH /v1/visibility/prompts/{id}`

Change a prompt's `topic`, `intent` or `status`.

```bash theme={null}
curl -X PATCH "https://api.traceten.com/v1/visibility/prompts/9c8b7a65-4321-4fed-9876-0a1b2c3d4e5f" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12", "status": "paused"}'
```

This endpoint does not accept `text` or `locale`. Sending either returns 422 `prompt_text_immutable` and points at the replace endpoint.

`status` accepts `active` and `paused` only. Archiving is `DELETE`, and an archived prompt cannot be brought back: re-adding the question creates a new prompt and starts a new series.

## `POST /v1/visibility/prompts/{id}/replace`

The only way to change a question's wording or locale.

```bash theme={null}
curl -X POST "https://api.traceten.com/v1/visibility/prompts/9c8b7a65-4321-4fed-9876-0a1b2c3d4e5f/replace" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "text": "best analytics tool for AI referred traffic"
  }'
```

```json theme={null}
{
  "status": "success",
  "data": {
    "prompt": {
      "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5e",
      "text": "best analytics tool for AI referred traffic",
      "origin": "suggested",
      "status": "active"
    },
    "replaced_prompt_id": "9c8b7a65-4321-4fed-9876-0a1b2c3d4e5f"
  }
}
```

This is not an edit. The old prompt is archived with its wording and every run it produced, and a new prompt is created with a **new id**. Anything holding the old id, such as a saved filter or a dashboard link, now points at history.

The new prompt starts an empty series. Its rate is not comparable to the old one's, because they are different questions. That is precisely why Traceten refuses to edit in place.

`origin` and `status` carry over from the archived prompt, and omitted fields keep their previous values. A one for one swap never fails on your allowance, because the archive happens first.

## `DELETE /v1/visibility/prompts/{id}`

Archive a prompt. Returns `204`.

```bash theme={null}
curl -X DELETE "https://api.traceten.com/v1/visibility/prompts/9c8b7a65-4321-4fed-9876-0a1b2c3d4e5f?site_id=ttid_7Rb4TrC1dTbnD8w3s1TS12" \
  -H "Authorization: Bearer <YOUR_API_KEY>"
```

This changes the denominator of every rate from the next scan onward. Past windows keep their numbers, so a presence rate measured before and one measured after are not over the same set of questions. Any comparison across that point should say so.

Past runs are kept and stay attached to the archived prompt. This cannot be undone.

## `POST /v1/visibility/prompts/suggest`

Propose questions built from your own site.

```bash theme={null}
curl -X POST "https://api.traceten.com/v1/visibility/prompts/suggest" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12"}'
```

```json theme={null}
{
  "status": "success",
  "data": {
    "site_id": "ttid_7Rb4TrC1dTbnD8w3s1TS12",
    "domain": "acme.com",
    "pages_read": 6,
    "robots_blocked": false,
    "failure": null,
    "suggestions": [
      {
        "text": "best AI traffic analytics tools",
        "intent": "category_discovery",
        "topic": "AI traffic analytics",
        "locale": "en-US",
        "source": "heading"
      },
      {
        "text": "what is Acme Analytics",
        "intent": "brand_direct",
        "topic": "Acme Analytics",
        "locale": "en-US",
        "source": "brand"
      }
    ]
  }
}
```

**Nothing is saved.** The candidates come back in the response and no prompt is created. To track one, post it to `/v1/visibility/prompts` with `origin: "suggested"`.

A candidate matching a question already on the site is filtered out, including one you have archived, so a question you removed on purpose is not offered back.

### Your site must be receiving events first

If the site has never reported a tracking event, this endpoint returns 409 `site_not_verified` and fetches nothing.

Traceten will only fetch a domain you have shown you control, and a received event is the only evidence of that we have. Install the snippet and load a page, then try again. You can add prompts by hand in the meantime.

### How Traceten reads your site

It starts at the domain on your site record and fetches over HTTPS only, identifying itself as `Traceten-SiteReader/1.0`. It reads your `robots.txt` first and honours a `Disallow` that covers a page it would have read, including the `*` and `$` wildcards. It follows `sitemap.xml` and your homepage navigation, and stops at 8 pages, 12 requests or 15 seconds, whichever comes first.

Every request is pinned to your host and its `www` variant. That includes redirects: a hop that leaves your site is refused rather than followed, so nothing off your domain can be read through this endpoint.

It reads the title, meta description, headings and navigation labels, and fills templates with them. **No AI model is involved** and no third-party provider is called. Nothing is stored, and no page content is logged.

Because it makes a real outbound request from Traceten's infrastructure, this endpoint needs `config:write` rather than a read permission, even though it saves nothing.

One crawl runs per site at a time. A second request while one is in flight returns 409 `suggest_in_progress`.

### Read the candidates before you create them

Each candidate's `topic` is a phrase copied **verbatim** from one of your pages, and its `text` is that phrase inside a template. Headings and navigation labels are ordinary marketing copy, but they are whatever you put on the page, so a candidate can contain a person's name if a heading does.

That matters because a prompt you create is sent to third-party answer engines on every scan, for as long as you track it: **Perplexity and DataForSEO (which runs it as a Google search, so it reaches Google too)** on every plan, and **Anthropic and OpenAI** as well if you have the engine coverage add-on. See [AI answer engines](/ai-visibility/engines) for exactly what each one receives. Traceten drops anything containing `@`, so an email address in a navigation label never becomes a candidate, but it cannot tell a person's name from a product name. Read the list before accepting it.

Treat the results as a starting point to edit. They are templates built from your own words, not evidence that real buyers ask these questions. Nothing here validates that.

`pages_read: 0` with a `failure` means the site could not be read. `unreachable` means no page answered, `robots_disallowed_all` means your `robots.txt` blocks us, and `no_domain` means the site record has no usable domain. Brand name candidates are still returned, because they need no page content.

## Configuration errors

| Status | `error`                           | Cause                                                                          |
| ------ | --------------------------------- | ------------------------------------------------------------------------------ |
| 403    | `forbidden`                       | The credential lacks `config:write`                                            |
| 404    | `brand_not_found`                 | No such brand on this site, or it is archived and therefore not editable       |
| 404    | `prompt_not_found`                | No such prompt on this site, or it is already archived                         |
| 409    | `brand_already_exists`            | A brand with that name is already tracked on this site                         |
| 409    | `prompt_already_exists`           | The same question is already tracked in that locale                            |
| 409    | `visibility_brand_limit_reached`  | The site is at 25 brands. Archive one first                                    |
| 409    | `visibility_prompt_limit_reached` | The site is at its plan allowance. Archive one first                           |
| 422    | `prompt_text_immutable`           | `text` or `locale` was sent to PATCH. Use the replace endpoint                 |
| 422    | `validation_failed`               | Missing `intent`, an unknown field, a bad domain, or text over 1000 characters |

A duplicate question is a 409 rather than a silent success, because asking the same thing twice pays twice and then averages two samples of one thing as though they were two.

## Privacy

`answer_text` is prose written by a third party answer engine, returned unmodified. It can name people, because answers about a market name the people in it.

The same applies to `sources[].title`, the cited page's own title copied verbatim. A cited profile page arrives as its own title, for example "Jane Doe, VP Product at Acme". Treat titles like answer text, not like URLs.

* It is stored for `evidence_retention_days` and then deleted.
* It carries no visitor identifier and is never joined to one. Nothing in these responses describes a person who visited your site.
* The long lived aggregates behind `/summary` and `/timeseries` hold counts only. They contain no answer text and no query strings.
* `/v1/visibility/runs` responses, including `answer_text`, are cached for 60 seconds in Traceten's managed Redis cache (Upstash) so a page refresh does not re-read the evidence table. That is a second location answer text briefly occupies, it expires on its own, and it is keyed per site and per query.

Cited URLs have their query strings and fragments removed before storage, so a token or an email address in the **cited URL list** is dropped before it is written.

That protection covers the URL list only. A URL written out inside the prose of an answer is part of `answer_text` and is stored verbatim, exactly as the engine wrote it, for `evidence_retention_days`.

## Errors

| Status | `error`             | Cause                                                         |
| ------ | ------------------- | ------------------------------------------------------------- |
| 401    | `unauthorized`      | Missing or invalid credential                                 |
| 403    | `forbidden`         | The credential lacks `stats:read`                             |
| 404    | `site_not_found`    | No site matches `site_id` for this credential                 |
| 422    | `validation_failed` | Unknown parameter, bad date, window over 366 days, `timezone` |
| 429    | `rate_limited`      | Read budget exhausted. Retry after the header says.           |

`timezone` is rejected rather than ignored. These figures are stored at UTC day grain, so a local day boundary cannot be honoured, and answering in UTC under another zone's label would be wrong.

## Next

* [AI Crawlers API](/api/ai-crawls) for the crawl side of a cited page
* [Authentication](/api/authentication) for creating and scoping keys
