# AI Visibility Analytics

What AI models said about you — mention rate, share of voice, and the three citation metric families, with the raw counts behind every number.

Your prompts run against AI models on a schedule. These endpoints report **what those
answers said about you** — how often you were named, who was named alongside you, and which
pages got cited.

Everything under `/org/analytics` is **read-only** and scoped to the organization the key
belongs to. It needs Senso's GEO product on your organization; without it, a request answers `403`.

```bash
curl "https://apiv2.senso.ai/api/v1/org/analytics/summary" \
  -H "X-API-Key: $SENSO_API_KEY"
```


## The shape of a response

Most endpoints return these blocks:

| Block | What it holds |
|-------|---------------|
| `window` | The effective date range: `from`, `to`, `days`, and `latest_data_day`, the most recent day with data for your model and location filter |
| `totals` | Every raw count — all numerators **and** all denominators |
| `metrics` | Every ratio derived from `totals`, each as `{ value, display }` |
| `data_quality` | A `low` / `medium` / `high` confidence flag from the answered-run count |
| `notes` + `definitions` | Prose caveats and one-line metric definitions, generated per response |

Each endpoint adds what it is for: `/summary` adds `previous_window` and `deltas`, the time series add `series`, and the lists add their rows with `total`, `limit` and `offset`. The citation-source endpoints carry their two denominators in a `denominators` block instead of `totals` and `metrics`. `/answers/latest` returns its answers with `notes` and `definitions` only.

A ratio is **`null` whenever its denominator is zero**. Null means "not measured" — it is never a silent `0%` that reads like a measured result.


## The metrics

### Mention rate

**Mention rate** is the share of answered runs in which your brand was named.

```
mention_rate = mentioned_count / answered_count
```

An **answer** (or run) is one execution of one prompt against one model in one location on one day. `answered_count` counts the non-empty answers; runs that returned nothing are excluded, so an outage does not count against you.

Mention rate is **answer grain**: an answer that names you three times still counts once. If you want occurrences, use `mention_total`.

### Share of voice

**Share of Voice** is your share of every brand mention the models made. It is instance grain: it counts every occurrence of a brand name, not answers, so a brand named twice in one answer counts twice.

```
share_of_voice = mention_total / brand_mention_total
```

`brand_mention_total` is mention instances across **every brand the models named** — your tracked competitors, brands you have never heard of, all of them. This is the same Share of Voice the Senso app shows on your dashboard.

Every window figure for Share of Voice — on summary, mentions and prompts — divides by `brand_mention_total`, so it means the same thing on every endpoint. Two figures are built differently: a prompt row's `latest.share_of_voice` (below), and each stored answer's own `sov_pct` on `/answers/latest`, which is on a 0–100 scale rather than 0–1.

`totals` also carries `tracked_mention_total` — mention instances across you plus your tracked competitors. It is **not** the Share of Voice denominator. It depends on your configuration: add a competitor and it grows, and with no tracked competitors it equals `mention_total`. `brand_mention_total` does not move when you edit your competitor list.

To compare yourself only against the competitors you track, compute it yourself:

```
head_to_head = mention_total / tracked_mention_total   # your own math, not a Senso metric
```

Do not call that result Share of Voice, and do not compare it with the number on the dashboard.

This API does not enumerate the untracked brands by name. They exist only inside `brand_mention_total`.

### Two share-of-voice figures on a prompt row

Rows from `/org/analytics/prompts` carry share of voice twice. The two are not redundant — they answer different questions over different spans:

| Field | Question it answers | Built from |
|-------|---------------------|-----------|
| `share_of_voice` | Across the whole window, what was your share of brand mentions for this prompt? | every answer in the window |
| `latest.share_of_voice` | In the most recent answers, what is your share of brand mentions for this prompt? | the average share of voice across the most recently collected answer from each model |

The Senso app's prompt table shows the latest figure. So when you are reconciling a single prompt against the app, compare `latest.share_of_voice`. Comparing the window aggregate will disagree, and it will be right to disagree, because it measures a different span.

Use the window figure for trends and for ranking prompts against each other: it is built from many answers, so one unusual run moves it far less than it moves the latest figure.

> The same distinction applies to the rest of the `latest` block. `latest.answer_count`, `latest.mentioned_count` and `latest.models` describe the most recently collected answers, not the window. Those answers can be older than the window, and can come from a model that has no data in it.

### Average rank

```
avg_rank = rank_sum / mentioned_count
```

The mean ordinal position of your brand across the answers that mentioned it — 1 means named first. **Lower is better**, and it ships as `{ "value": 2.4, "display": "#2.4" }` so nobody reads it as a percentage.

`avg_rank` is `null` when you were never mentioned. That is "never ranked", not "ranked last".

### Sentiment

**Sentiment** is three counts: how many answers that named you were positive, neutral or negative. They add up to `mentioned_count`.

### Citation tiers

Every cited URL is classified into exactly one tier:

| Tier (stored value) | Product name | Meaning |
|---------------------|--------------|---------|
| `primary` | Owned | A page on one of your own websites' domains |
| `tracked` | Tracked | A URL matching one of your configured tracked sources |
| `secondary` | External | Any other, third-party domain |

The tiers **partition citations** — each cited URL is exactly one tier — but they do **not** partition answers. One answer can carry citations of two or three tiers at once.

> **"External" means the `secondary` tier only:** third-party domains. Tracked sources are their own tier.

### The two denominators: D and S

Every citation metric divides by one of exactly two denominators:

```
D = cited_run_count   answers with at least 1 citation (any tier)   → Rate, Coverage
S = cited_total       citation instances (every cited URL)          → Share
```

`D` is answer grain: an answer citing five of your pages contributes **1**. `S` is instance grain: that same answer contributes **5**. A metric built on `D` and a metric built on `S` are never comparable — never average them, never sum them, never chart them on a shared axis without labels.

`/summary` and `/citations` return both in `totals`, and the citation-source endpoints in `denominators`, so you can check which one a number used.

### Family 1 — Citation Rate (answer grain, per tier)

*Of the answers that cited anything, what share cited this tier?*

```
primary_citation_rate  = primary_cited_run_count  / D
tracked_citation_rate  = tracked_cited_run_count  / D
external_citation_rate = external_cited_run_count / D
```

The three rates are **independent, not a partition**. An answer citing both an owned page and a third-party page counts toward both numerators, so the three can sum past 100%. That is expected and correct.

### Family 2 — Citation Coverage (answer grain, per source)

*Of the answers that cited anything, what share cited THIS domain or THIS page?*

```
citation_coverage(source) = (distinct answers citing this source) / D
```

Citation Coverage is Citation Rate's per-source sibling: same denominator `D`, narrower numerator. It appears on `/citations/domains` and `/citations/pages`. Because the numerator counts **distinct answers**, coverage is always at most 100%.

A tier rate is **not** the sum of its sources' coverages — two sources cited in the same answer would double-count it.

### Family 3 — Citation Share (instance grain, per tier or per source)

*Of all the citation instances we saw, what fraction went to this tier or this source?*

```
primary_citation_share  = primary_cited_total  / S
tracked_citation_share  = tracked_cited_total  / S
external_citation_share = external_cited_total / S

citation_share(source)  = (citation instances for this source) / S
```

The three tier shares **do** partition citations and sum to exactly 100%.

Per-source shares also sum to 100% across **all** sources in the window — not across the page of results you happen to be looking at.

### Rate and Share disagree, and that is the insight

Rate and Coverage count **distinct answers**. Share counts **individual cited URLs**. Take a 30-day window:

| Count | Value |
|-------|-------|
| `cited_run_count` (D) | 100 |
| `primary_cited_run_count` | 40 |
| `cited_total` (S) | 800 |
| `primary_cited_total` | 60 |

| Metric | Math | Result |
|--------|------|--------|
| `primary_citation_rate` | 40 / 100 | 40.0% |
| `primary_citation_share` | 60 / 800 | 7.5% |
| `citations_per_answer` | 800 / 100 | 8.00 |

Both numbers are correct and they describe different things. You were cited in **40% of the answers that cited anything** — good visibility. But you were only **7.5% of the citations inside those answers** — every answer that cites you cites roughly eight sources, and seven of them are not yours.

High rate with low share means: *you get cited, but you are outnumbered inside every answer.* That is a content-volume problem, not a discoverability problem. Reversing them — low rate, high share — means the opposite: when you show up you dominate the answer, but you rarely show up.

### Citations per answer

```
citations_per_answer = S / D
```

An **intensity**, not a rate: how densely answers cite. It is unbounded above and never carries a percent sign. `4.2` means the average citing answer cited 4.2 URLs.


## Counts ship with every ratio

Every payload carries the additive raw counts — numerators **and** denominators — next to the computed ratio, so:

- **You can check our math.** Every ratio in `metrics` is derivable from the numbers in `totals`.
- **You can recombine windows.** Counts are additive; ratios are not. Sum the counts across periods, then divide once.
- **You can build a metric we did not ship.** If you want "answers that cited you as a share of all answered runs", divide `primary_cited_run_count` by `answered_count` yourself — just do not call it a Citation Rate, because it is not on `D`.

To recompute the headline metrics from a `/summary` response's `totals`:

```
mention_rate           = mentioned_count         / answered_count
primary_citation_rate  = primary_cited_run_count / cited_run_count   (D)
primary_citation_share = primary_cited_total     / cited_total       (S)
```

Guard each division: any of those denominators can be zero, which is exactly why the API returns `null` rather than a number.


## Notes, definitions, and data quality

These endpoints are built for **agents acting for a human** as much as for developers reading a spec, so every response explains itself.

**`notes[]`** is an array of prose caveats generated for *that* response — not boilerplate. They tell you things the numbers cannot, for example:

- that the window defaulted to the 30 days ending on your most recent day with data, rather than today
- that `D = 0`, so every citation metric is `null` rather than `0%`
- that part of your window is older than the 396 days of data Senso keeps
- that no brand mentions were recorded at all, so `share_of_voice` is `null` rather than `0%`

**`definitions{}`** maps the main metrics and terms in that response to one-line definitions, naming the denominator. They are the same definitions `GET /org/analytics/glossary` returns.

**`data_quality`** is a deterministic flag from the answered-run count over the window: `low` below 100, `medium` below 400, `high` otherwise. It ships with `answered_count` and a `reasons[]` array. Low does not mean the number is wrong — it means a single run moves it a lot.

If you are surfacing these numbers through an agent or an LLM, **pass `notes[]` and `definitions{}` through to the model and let it surface them.** They exist precisely so a summary does not invent a denominator.


## Answer text: latest only

`latest_answers` and `GET /org/analytics/answers/latest` return **the single most recent answer per prompt, model, and location** — with the full response text, its citations, and its competitor mentions.

Historical answer **text is not retained**. History exists only as the daily additive counts in the series endpoints. So:

- Do not treat the latest answers as a sample of your window. Each is the most recently collected answer for its combination, which for a model or location you no longer run can be months old. Check its `run_at`.
- Each answer carries its own `run_at`, and those can differ from row to row.
- Each answer's `citations` list holds at most 50 entries.
- `competitor_mentions` maps each **tracked** competitor named in the answer to how often it was named, such as `{"Profound": 1}`. Untracked brands are counted in `brand_mention_total` but are never enumerated by name.

### `from` and `to` on `/answers/latest` filter collection time, not history

`GET /org/analytics/answers/latest` accepts `from` and `to` (`YYYY-MM-DD`, same validation as everywhere else: `from` on or before `to`, and `to` at most 365 days after it). They filter on **`run_at` — when each stored answer was collected**. Both are optional, and unlike the window-scoped endpoints there is no 30-day default here: omit them and you get every stored latest answer.

They do **not** turn this endpoint into a historical feed. It still returns only the newest answer per prompt, model, and location, so narrowing the window **hides** the combinations whose latest answer falls outside it. It never returns an older answer in place of the newest one, because older answer text is not stored.

> Read it as "show me only the combinations refreshed in this period", never as "show me what the models said in this period".

A window in which none of the latest answers were collected returns an empty list, not the whole snapshot.

```bash
curl -G "https://apiv2.senso.ai/api/v1/org/analytics/answers/latest" \
  -H "X-API-Key: $SENSO_API_KEY" \
  --data-urlencode "from=2026-09-22" \
  --data-urlencode "to=2026-09-28"
```

For what the models said over a period, use `/org/analytics/mentions` or `/org/analytics/citations` — those read the daily counts and are genuinely historical.


## The window

Window-scoped endpoints take `from` and `to` as `YYYY-MM-DD`, both optional, with `to` at most 365 days after `from`.

Leave them off and the window defaults to the **30 days ending on the most recent day that has data for your active model and location filter** — not today. This matters: monitoring runs on a schedule, so anchoring on today would make every unparameterized request look empty between runs. The day used is echoed back as `window.latest_data_day`, and the defaulting is called out in `notes[]`.

Senso keeps 396 days of daily data. A window reaching further back covers only the days still kept, and `notes[]` says so.

`/answers/latest` takes the same two parameters with the same validation, but they mean something different there — they filter on each answer's `run_at` and cannot return history. See **`from` and `to` on `/answers/latest`** above.


## Endpoint reference

All paths are relative to `https://apiv2.senso.ai/api/v1` and all are `GET`.

| Path | What it answers | Key parameters |
|------|-----------------|----------------|
| `/org/analytics/glossary` | What does each metric mean, and what is its denominator? | none |
| `/org/analytics/filters` | Which models, locations, prompt types, tags and competitors actually have data? | none |
| `/org/analytics/summary` | Where do we stand, and how does that compare with the previous equal-length window? | `from`, `to`, `models`, `location`, `prompt_type`, `tag` |
| `/org/analytics/mentions` | How has visibility moved over time — mentions, share of voice, rank, sentiment? | shared filters, `group_by` (`day` or `week`) |
| `/org/analytics/citations` | How has citation performance moved over time, at both grains and in both metric families? | shared filters, `group_by` |
| `/org/analytics/citations/domains` | Which domains do the models cite, and how much of that is ours? | shared filters, `tier`, `domain_contains`, `sort`, `limit`, `offset` |
| `/org/analytics/citations/pages` | Which exact URLs get cited, and which prompts drive them? | shared filters, `tier`, `domain`, `domain_contains`, `url_contains`, `sort`, `limit`, `offset` |
| `/org/analytics/prompts` | Which prompts are we winning, and which are we invisible on? | shared filters, `search`, `sort`, `order`, `limit`, `offset` |
| `/org/analytics/prompts/{promptId}` | One prompt end to end: its metric history plus its latest full answers | shared filters, `include_answers` |
| `/org/analytics/answers/latest` | What did the models actually say, verbatim? | `from`, `to` (on `run_at`), `models`, `location`, `prompt_type`, `tag`, `mentioned`, `cited`, `citation_tier`, `limit`, `offset` |

The **shared filters** accepted by every window-scoped endpoint:

| Parameter | Values | Notes |
|-----------|--------|-------|
| `from`, `to` | `YYYY-MM-DD` | Both optional; `from` on or before `to`, and `to` at most 365 days after it |
| `models` | comma-separated model ids | Use the ids from `/org/analytics/filters`, exactly as listed. An id Senso does not recognize is a `400`; a recognized model with no data for you returns empty results |
| `location` or `locations` | comma-separated locations | Exact match and case sensitive. Both spellings are the same filter |
| `prompt_type` | `awareness`, `consideration`, `evaluation`, `decision` | The funnel stages, in any case. Any other value is a `400` |
| `tag` | tag name | Not for the two citation-source endpoints |

**Location codes match exactly, and are not hierarchical.** `us/california` does not match `US/California`, and `US` does not include `US/California`. A location or tag with no data returns empty results, not an error. Use `/org/analytics/filters` to see the exact codes and tags your organization has data for.

### Which filters each endpoint takes

| Endpoint | `models` | `location(s)` | `prompt_type` | `from`/`to` | `tag` |
|----------|----------|---------------|---------------|-------------|-------|
| `/glossary`, `/filters` | — | — | — | — | — |
| `/summary` | yes | yes | yes | yes | yes |
| `/mentions` | yes | yes | yes | yes | yes |
| `/citations` | yes | yes | yes | yes | yes |
| `/citations/domains` | yes | yes | yes | yes | **no** |
| `/citations/pages` | yes | yes | yes | yes | **no** |
| `/prompts` | yes | yes | yes | yes | yes |
| `/prompts/{promptId}` | yes | yes | yes | yes | yes |
| `/answers/latest` | yes | yes | yes | yes, on `run_at` | yes |

Three things to read off that table:

- **`tag` is not a filter on the citation-source endpoints.** `/citations/domains` and `/citations/pages` always cover all your prompts, and a `tag` passed to them is ignored. For the citation picture of a tag's prompts, use `/citations` or `/prompts`.
- **`/glossary` and `/filters` take no filters at all.** They describe the vocabulary and the available filter values; they report no metrics.
- **`prompt_type` and `tag` are redundant on `/prompts/{promptId}`.** The path already names one prompt, so a value that does not match it only empties its totals and series. The prompt's `latest_answers` are filtered by `models` and `location` only. They are included by default; `include_answers=false` returns `latest_answers` as an empty list.

With `group_by=week`, each period starts on a Monday, so the first and last weeks of a window can be partial; every period carries its own counts. A day or week with no data is left out of the series rather than returned as zero.

`tier` on the citation-source endpoints, and `citation_tier` on `/answers/latest`, take the stored tier values: `primary`, `tracked` or `secondary`.

Paging defaults to `limit=50` (`limit=25` on `/answers/latest`) and is capped at 100. Sorting on the cited-source endpoints is `sort=citations` (default, by citation instances) or `sort=coverage`; on `/prompts` it is `mention_rate` (default), `share_of_voice`, `citations` (answers that cited anything), `answered`, or `text`, with `order=asc` or `order=desc` (default).


## Examples

### Read the glossary once

Have your agent call this before it quotes any number, so it never invents a denominator.

```bash
curl "https://apiv2.senso.ai/api/v1/org/analytics/glossary" \
  -H "X-API-Key: $SENSO_API_KEY"
```

Each entry carries a `metric` and its `definition`, plus its `denominator` and a `gotcha` where they apply:

```json
{
  "entries": [
    {
      "metric": "primary_citation_rate",
      "definition": "Of the answers that cited anything, the share that cited one of your own domains (tier: primary / Owned).",
      "denominator": "D = cited_run_count",
      "gotcha": "The three tier rates are independent, not a partition — an answer citing both an owned and an external page counts toward both, so they can sum past 100%. That is expected."
    }
  ]
}
```

### The one-call dashboard

The response below is illustrative: its numbers are the worked example from earlier on this page.

```bash
curl -G "https://apiv2.senso.ai/api/v1/org/analytics/summary" \
  -H "X-API-Key: $SENSO_API_KEY" \
  --data-urlencode "from=2026-06-01" \
  --data-urlencode "to=2026-06-30" \
  --data-urlencode "models=chatgpt,perplexity"
```

```json
{
  "window": { "from": "2026-06-01", "to": "2026-06-30", "days": 30, "latest_data_day": "2026-10-01" },
  "totals": {
    "run_count": 1240, "answered_count": 1198,
    "mentioned_count": 431, "mention_total": 602,
    "tracked_mention_total": 2410, "brand_mention_total": 5180,
    "cited_run_count": 100, "primary_cited_run_count": 40,
    "cited_total": 800, "primary_cited_total": 60
  },
  "metrics": {
    "mention_rate": { "value": 0.3597, "display": "36.0%" },
    "share_of_voice": { "value": 0.1162, "display": "11.6%" },
    "avg_rank": { "value": 2.4, "display": "#2.4" },
    "primary_citation_rate": { "value": 0.40, "display": "40.0%" },
    "primary_citation_share": { "value": 0.075, "display": "7.5%" },
    "citations_per_answer": { "value": 8.0, "display": "8.00 citations per cited answer" }
  },
  "previous_window": { "window": { "from": "2026-05-02", "to": "2026-05-31", "days": 30, "latest_data_day": "2026-10-01" }, "totals": { "...": "..." }, "metrics": { "...": "..." } },
  "deltas": { "mention_rate": { "delta": 0.041, "prev": 0.3187, "direction": "improved", "display": "+4.1 pts" } },
  "data_quality": { "level": "high", "answered_count": 1198, "reasons": [] },
  "notes": ["..."],
  "definitions": { "mention_rate": "Share of answered runs in which your brand was named." }
}
```

`latest_data_day` is the most recent day with data for your model and location filter, whichever window you ask for, and `null` if there is none. `previous_window` is the equal-length window immediately before yours, with its own `totals` and `metrics`. `deltas` compares the two for `mention_rate`, `share_of_voice` and `primary_citation_rate`: `delta` is the difference between the two values, `prev` is the earlier value, `display` shows the difference in **absolute percentage points**, and `direction` is `improved`, `declined` or `flat`. A delta is `null` when either window lacked the denominator to compute the metric, so "no data" never reads as "fell to zero".

### Find the pages the models cite instead of yours

```bash
curl -G "https://apiv2.senso.ai/api/v1/org/analytics/citations/pages" \
  -H "X-API-Key: $SENSO_API_KEY" \
  --data-urlencode "tier=secondary" \
  --data-urlencode "sort=coverage" \
  --data-urlencode "limit=10"
```

`tier=secondary` keeps third-party pages only, and `sort=coverage` ranks them by Citation Coverage rather than raw volume. Each page carries its `citation_coverage`, and `denominators.cited_run_count` is the D it divides by. `top_prompts` lists at most five prompts per page — the biggest drivers, not an exhaustive list. A cited URL may not match your published link character for character.

### Find the prompts where you are invisible

```bash
curl -G "https://apiv2.senso.ai/api/v1/org/analytics/prompts" \
  -H "X-API-Key: $SENSO_API_KEY" \
  --data-urlencode "sort=mention_rate" \
  --data-urlencode "order=asc" \
  --data-urlencode "limit=20"
```

Each row carries that prompt's own counts, so read `mention_rate` alongside `answered_count` — a prompt answered twice and a prompt answered 200 times both report a rate. A prompt with no answered runs in the window is not listed at all, so this list cannot show you prompts that were never measured. Compare it with your prompts in `GET /org/prompts` to find those.

### Read what a model actually said

```bash
curl -G "https://apiv2.senso.ai/api/v1/org/analytics/answers/latest" \
  -H "X-API-Key: $SENSO_API_KEY" \
  --data-urlencode "mentioned=false" \
  --data-urlencode "cited=true" \
  --data-urlencode "limit=5"
```

That combination — answers that cited sources but never named you — is usually the most actionable feed in the API.


## Common mistakes

**Dividing by the wrong denominator.** `primary_cited_run_count / answered_count` is not a Citation Rate. Citation Rate and Citation Coverage divide by `D` (`cited_run_count`); Citation Share divides by `S` (`cited_total`). Mixing an answer-grain numerator with an instance-grain denominator produces a number that can exceed 100% for no interpretable reason. Both denominators are in every response — use them.

**Treating `null` as zero.** A `null` ratio means the denominator was zero: no answered runs, no brand mentions, no citations at all. Charting it as `0%` turns "we did not measure this" into "we measured a failure". The same applies to `avg_rank`: `null` is "never mentioned", not "ranked last".

**Averaging per-period rates.** The series points carry different denominators, so the mean of 30 daily mention rates is not the 30-day mention rate. Sum the counts across periods, then divide once. This is exactly why every series point ships its counts.

**Reading `share_of_voice` as a head-to-head number.** Its denominator is `brand_mention_total` — every brand the models named, tracked or not — so it is your share of the whole field, not of a race against your configured competitors. It is comparable across organizations and across time, and it does not move when you edit your competitor list. If you divide by `tracked_mention_total` instead, that number is neither comparable across organizations nor what the Senso app shows.

**Reading "external" as "not owned".** In these endpoints `external` is the `secondary` tier only — third-party domains. Tracked sources are their own tier.

**Summing the three tier rates and expecting 100%.** The rates are independent and can total more than 100%. It is the three tier *shares* that partition citations and sum to exactly 100%.
