# REST API Reference (/docs/api-reference)

The RankZero REST API exposes the same data as our [MCP server](/docs/claude-mcp) over plain HTTP+JSON, so automations and integrations can pull per-brand KPIs server-to-server without speaking MCP.

## Base URL

```
https://www.rankzero.io/api/v1
```

(Same origin as the RankZero app.)

## Authentication

Every request must send a bearer key:

```
Authorization: Bearer <YOUR_API_KEY>
```

Keys are issued on request. A key is **scoped to one account** - it can only read that account's brands (brands the account owns or shares via its organization). There is no all-brands access.

Missing or invalid key → `401`.

```bash
curl https://www.rankzero.io/api/v1/brands \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
```

## Addressing a brand

The canonical identifier is the brand's **`id`** (a stable UUID). Discover your brands and their ids with `GET /brands`. As a convenience you may also pass a brand's **domain** or **name**:

* Matches exactly one brand → resolves.
* Matches more than one (e.g. two brands on the same domain) → resolves when exactly one of them is active, otherwise `409 {"error":"ambiguous brand, use id"}`.
* Matches none (or a brand outside your scope) → `404 {"error":"unknown brand"}`.

## Reporting period

KPI endpoints accept `?period=`, one of `24h`, `7d`, `30d`, `90d`, `6m`, `12m`, `16m`. Default `7d`.

`/ga4` and the `/citations` series (`?dimensions=week|date`) additionally accept an exact `?from=&to=` calendar range (see below). Everywhere else, `?period=` is the window.

The long windows (`6m`, `12m`, `16m`) are intended for the aggregate Search Console trend - `16m` is the full retention Google Search Console keeps. They are reliable for totals-over-time; a dimensioned GSC breakdown (`?dimensions=query|page`) returns 1000 rows by default and at most 25000 with `?limit=`, so over long spans it truncates rather than paginating. GSC data also lands with a \~2-3 day lag, so the trailing few days of any window are incomplete.

***

## Endpoints

### `GET /brands`

Lists the brands your key can access.

```bash
curl https://www.rankzero.io/api/v1/brands \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
```

```json
[
  { "id": "3f8a1c2e-9b4d-4e7a-bc1f-2d6e5a0b9c11", "name": "Tesla", "domain": "tesla.com" }
]
```

### `GET /brands/{brand}/kpis`

Headline snapshot: visibility, Google Search Console totals, and GA4 sessions by source.

```bash
curl "https://www.rankzero.io/api/v1/brands/tesla.com/kpis?period=7d" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
```

```json
{
  "brand": "Tesla",
  "period": "7d",
  "tag": null,
  "asOf": "2026-06-08T12:00:00.000Z",
  "visibility": { "percent": 11.4, "rank": 8 },
  "gsc": { "clicks": 31, "impressions": 1606, "avgPosition": 12.4, "ctr": 0.019 },
  "ga4": {
    "sessionsBySource": [
      { "source": "chatgpt.com", "sessions": 3, "aiSessions": 3, "ai": true, "aiSource": "ChatGPT" },
      { "source": "google", "sessions": 22, "aiSessions": 0, "ai": false, "aiSource": null }
    ],
    "totalSessions": 42,
    "aiSessions": 3
  }
}
```

* `gsc` / `ga4` are `null` when that integration isn't connected for the brand (visibility is still returned).
* `ga4.sessionsBySource` is **unfiltered** - every traffic source is returned - but each row carries our own verdict: `aiSessions` is the part of that source's traffic that counts as AI search, `ai` is `aiSessions > 0`, and `aiSource` names the assistant ("ChatGPT", "Meta AI") or is `null`. The two session counts differ when one source arrives by several mediums - an ad click from chatgpt.com is paid, not AI search. You can still apply your own allowlist to the raw `source` if you prefer.
* `gscWindow` is the range the Search Console figures cover; it ends on the last
  settled day, so it is not "the last 7 days from today" the way visibility and GA4 are.
* `?compare=previous` adds `gscPrev` and `comparePeriod` for the same-length
  preceding window. Search Console only: visibility and GA4 stay single-window. For
  per-row comparisons use `/gsc?dimensions=…&compare=previous`.
* `?tag=<tag>` narrows **visibility only** to active prompts carrying that tag.
  `gsc` and `ga4` measure the whole site and have no prompt set behind them, so they
  are unchanged by it: a tagged response mixes the two scopes, and echoes `tag` so
  you can tell which reading applies. See `/overview` for what a tagged percentage
  does and does not mean.

### `GET /brands/{brand}/overview`

Own-brand visibility plus the full industry ranking (competitors).

```json
{
  "brand": "Tesla",
  "period": "7d",
  "tag": null,
  "asOf": "2026-06-08T12:00:00.000Z",
  "visibility": { "percent": 11.4, "rank": 8 },
  "ranking": [
    { "name": "Tesla", "url": "tesla.com", "isOwnBrand": true, "rank": 8, "visibilityPercent": 11.4, "sentiment": 62 }
  ]
}
```

* `?tag=<tag>` narrows the ranking to the runs of active prompts carrying that tag.
  `tag` is echoed back, `null` when you did not send one. Use `/prompts?tag=<tag>` to
  list the exact prompts a tagged figure was computed over.
* A tagged `visibilityPercent` is a share **within that prompt set**, not that tag's
  contribution to the brand-wide figure. Tag percentages therefore do not sum to the
  untagged one, and a tag with a handful of prompts is noisy.
* The filter reads a prompt's tags at query time, not at run time, so retagging a
  prompt moves its history in these numbers too.

### `GET /brands/{brand}/gsc`

Google Search Console totals for the period. `window` is the range the numbers
actually cover: Search Console settles \~2-3 days behind, so the window ends on the
last settled day, not today. Label these numbers with `window`, not `period`.

```json
{
  "brand": "Tesla",
  "period": "7d",
  "window": { "startDate": "2026-06-01", "endDate": "2026-06-07" },
  "asOf": "2026-06-08T12:00:00.000Z",
  "gsc": { "clicks": 31, "impressions": 1606, "avgPosition": 12.4, "ctr": 0.019 }
}
```

**Breakdown by dimension.** Add `?dimensions=` (comma-separated; one or more of `query`, `page`, `date`, `country`, `device`, `searchAppearance`) to get per-row performance instead of totals - the raw material for opportunity scans (near-miss queries at position 11 to 20, high-impression/low-CTR pages). Optional `?limit` (default 1000). Each row carries the requested dimension(s) plus `clicks`, `impressions`, `ctr`, `position`. `rows` is `null` if GSC isn't connected.

```bash
curl "https://www.rankzero.io/api/v1/brands/tesla.com/gsc?dimensions=query&period=30d&limit=1000" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
```

```json
{
  "brand": "Tesla",
  "period": "30d",
  "window": { "startDate": "2026-05-09", "endDate": "2026-06-07" },
  "asOf": "2026-06-08T12:00:00.000Z",
  "dimensions": ["query"],
  "rows": [
    { "query": "electric suv range", "clicks": 12, "impressions": 480, "ctr": 0.025, "position": 13.4 }
  ]
}
```

**Period over period.** Add `?compare=previous` to get the same-length window
immediately before the current one alongside it. The two windows are equal length
and share no day, so the delta between them is real.

On totals it adds `gscPrev` and `comparePeriod` - the true site-wide
week-over-week:

```bash
curl "https://www.rankzero.io/api/v1/brands/tesla.com/gsc?period=7d&compare=previous" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
```

```json
{
  "brand": "Tesla",
  "period": "7d",
  "window": { "startDate": "2026-06-01", "endDate": "2026-06-07" },
  "comparePeriod": { "startDate": "2026-05-25", "endDate": "2026-05-31" },
  "asOf": "2026-06-08T12:00:00.000Z",
  "gsc": { "clicks": 31, "impressions": 1606, "avgPosition": 12.4, "ctr": 0.019 },
  "gscPrev": { "clicks": 24, "impressions": 1402, "avgPosition": 13.1, "ctr": 0.017 }
}
```

On a dimensioned call every row carries its previous-window counterparts instead:

```bash
curl "https://www.rankzero.io/api/v1/brands/tesla.com/gsc?dimensions=query&period=7d&compare=previous" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
```

```json
{
  "dimensions": ["query"],
  "rows": [
    {
      "query": "electric suv range",
      "clicks": 12, "impressions": 480, "ctr": 0.025, "position": 13.4,
      "clicksPrev": 4, "impressionsPrev": 210, "positionPrev": 18.2
    }
  ]
}
```

* **Emerging queries** are `impressionsPrev === 0 && impressions > 0`: present now,
  absent before.
* Rows are **current-window rows only**. A query that ranked last week and is gone
  this week has no row to hang on, so it will not appear; use two single-window
  calls if you need the disappearances.
* A `*Prev` of `0` means "no row in the previous window", which for `positionPrev`
  is *did not rank*, not *ranked first*. Filter those out before averaging positions.
* With `date` among the dimensions, the previous window is aligned **day for day**:
  day N of the window is compared with day N of the window before it. (Until
  2026-08-27 a date breakdown reported every `*Prev` as `0`, because the two windows
  never share a date and the rows were matched on the date itself.)
* Summed breakdown rows **undercount** the site-wide totals - Search Console
  anonymizes rare queries - so the honest period-over-period headline is the totals
  compare above, not the sum of the rows.

### `GET /brands/{brand}/ga4`

GA4 sessions broken down by source, each row labelled with whether it counts as
AI search. `window` is the date range the numbers cover: GA4 has no reporting lag,
so it is simply `period` applied to today (or the explicit range described below).

```json
{
  "brand": "Tesla",
  "period": "7d",
  "window": { "startDate": "2026-06-02", "endDate": "2026-06-08" },
  "asOf": "2026-06-08T12:00:00.000Z",
  "ga4": {
    "sessionsBySource": [
      { "source": "chatgpt.com", "sessions": 3, "aiSessions": 3, "ai": true, "aiSource": "ChatGPT" },
      { "source": "google", "sessions": 22, "aiSessions": 0, "ai": false, "aiSource": null }
    ],
    "totalSessions": 42,
    "aiSessions": 3
  }
}
```

Every source is returned, AI or not. The classification is the same one behind
`aiSessions` in the daily series and the AI splits on `?keyEvents=true`, so the
three agree. Per row, `sessions` is all of that source's traffic and `aiSessions`
the part that counts as AI - they differ when a source arrives by several mediums,
since an ad click from chatgpt.com is paid rather than AI search. `ai` is
`aiSessions > 0` and `aiSource` is the assistant's display name, `null` otherwise.
The row `aiSessions` sum to the top-level `aiSessions`.

**Daily series.** Add `?dimensions=date` for one row per day instead of the
period total - the series behind an organic/AI traffic chart. Each row carries
`sessions` (all traffic), `organicSessions` and `aiSessions`, using the same
audience definitions as `?keyEvents=true` and the rest of RankZero: AI Search is
GA4's own AI Assistant channel plus our allowlist for the assistants that channel
does not cover, Organic Search is GA4's own channel plus the search engines GA4
leaves out of it. The two are disjoint,
and neither counts Google's AI Overviews or AI Mode as AI: Google defines those as
Organic Search and sends nothing on the destination URL that would separate them
from an ordinary organic click. Rows are zero-filled, so an N-day window always
returns exactly N rows, ascending by date. `rows` is `null` if GA4 isn't connected.

```bash
curl "https://www.rankzero.io/api/v1/brands/tesla.com/ga4?dimensions=date&period=30d" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
```

```json
{
  "brand": "Tesla",
  "period": "30d",
  "window": { "startDate": "2026-05-10", "endDate": "2026-06-08" },
  "asOf": "2026-06-08T12:00:00.000Z",
  "dimensions": ["date"],
  "rows": [
    { "date": "2026-05-10", "sessions": 412, "organicSessions": 191, "aiSessions": 3 }
  ]
}
```

**Event breakdown.** Add `?dimensions=eventName` for one row per event name the
property received over the window, with the same three audience splits: `count`
(all traffic), `organicCount` and `aiCount`. Rows come back sorted by `count`,
descending, and `rows` is `null` if GA4 isn't connected.

```bash
curl "https://www.rankzero.io/api/v1/brands/tesla.com/ga4?dimensions=eventName&period=30d" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
```

```json
{
  "brand": "Tesla",
  "period": "30d",
  "window": { "startDate": "2026-05-10", "endDate": "2026-06-08" },
  "asOf": "2026-06-08T12:00:00.000Z",
  "dimensions": ["eventName"],
  "rows": [
    { "eventName": "page_view", "count": 20382, "organicCount": 2896, "aiCount": 41 },
    { "eventName": "calendly_appointment", "count": 4, "organicCount": 1, "aiCount": 0 }
  ]
}
```

This is deliberately not the same question as `?keyEvents=true`. That reports what
someone has marked as a key event inside GA4, so a property can be collecting a
booking on every visit and still report zero conversions, and nothing in the
response distinguishes "no conversions happened" from "nobody marked one".
`?dimensions=eventName` shows what the property is actually receiving, which is
what tells the two apart. Use `keyEvents` for reporting and `eventName` when you
need to check that the reporting is measuring anything at all.

**Landing page breakdown.** Add `?dimensions=landingPage` for one row per landing
page over the window, with the same three audience splits: `sessions`,
`organicSessions` and `aiSessions`, and the key events those sessions produced in
`keyEvents`, `organicKeyEvents` and `aiKeyEvents`. Rows come back sorted by `sessions`,
descending, and `rows` is `null` if GA4 isn't connected. This is the question a
site total cannot answer: whether AI traffic arrived on the pages you changed, or
everywhere at once because the channel itself is growing. Pull the window before
the change and the window after with `?from=&to=` and compare the same pages.

```bash
curl "https://www.rankzero.io/api/v1/brands/tesla.com/ga4?dimensions=landingPage&from=2026-03-01&to=2026-05-29" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
```

```json
{
  "brand": "Tesla",
  "period": null,
  "window": { "startDate": "2026-03-01", "endDate": "2026-05-29" },
  "asOf": "2026-06-08T12:00:00.000Z",
  "dimensions": ["landingPage"],
  "rows": [
    { "landingPage": "/", "sessions": 18204, "organicSessions": 6120, "aiSessions": 88, "keyEvents": 412, "organicKeyEvents": 133, "aiKeyEvents": 4 },
    { "landingPage": "/model-y", "sessions": 4410, "organicSessions": 2987, "aiSessions": 61, "keyEvents": 96, "organicKeyEvents": 71, "aiKeyEvents": 2 }
  ],
  "truncated": false
}
```

`truncated` is `true` when one of the three reports hit GA4's 10,000-row cap. A
page outside a truncated report reads as `0` in that column, so while it is set a
`0` is not a measured zero. The home page and `(not set)`, GA4's value when it
could not resolve a landing page, are rows like any other.

`date`, `eventName` and `landingPage` are the dimensions this endpoint takes;
anything else is a `400`, and they cannot be combined. A dimensioned response carries `rows`
**instead of** `ga4` (same as `/gsc`), so ask without `?dimensions` when you want
the by-source totals. Either one composes with `?keyEvents=true`, which stays its
own `keyEvents` block.

**Exact date range.** `?from=YYYY-MM-DD&to=YYYY-MM-DD` replaces `?period=` with a
calendar range, so a monthly report can pull the month on its own masthead instead
of a rolling 30 days that drifts every time it is re-rendered. Both bounds are
inclusive, they must be given together, and the range may span at most 400 days
(GA4 quota behaves differently from the date-aggregated Search Console path, so
there are no `6m`/`12m`/`16m` windows here). `to` is clamped to today, and `period`
comes back `null` because the window is no longer a rolling one - read `window`.

The range applies to the **whole** endpoint, `?keyEvents=true` included, so
revenue for a named month is one call:

```bash
curl "https://www.rankzero.io/api/v1/brands/tesla.com/ga4?from=2026-05-01&to=2026-05-31&keyEvents=true" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
```

* Daily rows and `sessionsBySource` are separate GA4 reports, so their session
  totals can differ by a fraction of a percent over the same window. GA4 counts
  sessions per dimension combination; neither number is wrong, they answer
  slightly different questions. Don't derive one from the other.
* **Cost and pacing.** GA4's limit is concurrency, not volume: each report costs
  one or two tokens of a property's 40,000 per hour, but only ten requests may be
  in flight against one property at a time. `?dimensions=date&keyEvents=true` is
  nine reports, so this API paces its own calls per property and retries the
  concurrency error rather than letting a single request exhaust the property.
  Pull different brands in parallel as fast as you like; keep repeated calls
  against one brand sequential, and expect a second or two for the nine-report
  form.

**What is hiding in direct traffic.** Add `?directEstimate=true` for `directEstimate`, which asks how much unattributed traffic could be AI whose referrer was stripped - links opened from the ChatGPT apps, copy-pasted URLs, anything behind `rel="noreferrer"`.

```json
{
  "directEstimate": {
    "direct": 6436, "homeOrUnknown": 2195, "deep": 4241,
    "rate": 53.0, "organicRate": 69.6, "excess": 0, "aiPages": 43
  }
}
```

`rate` is the share of placeable direct sessions that landed on one of the `aiPages` pages AI assistants cite. On its own that looks like an AI signal, so `organicRate` is the control: the same share for Organic Search, whose referrer is intact and which therefore contains no disguised AI.

**`excess` is the only number worth quoting** - the sessions by which `rate` outruns `organicRate`. It is `0` on most properties, and that is the finding rather than a failure: measured across live properties, organic lands on AI-cited pages *more* often than direct does on 13 of 17, because those are simply the site's content pages. `excess` is also forced to `0` when the comparison cannot carry weight - too little organic traffic to be a control, a page set so broad it separates nothing, or a gap inside the noise floor.

An estimate, never part of `aiSessions`, and no substitute for [the attribution tag](/docs/attribution-tag). Opt-in: three extra GA4 reports.

**Key events, conversion rates, and revenue.** Add `?keyEvents=true` to also get the property's GA4 key events (what GA4 called conversions before 2024) by event name, plus per-audience conversion rates and revenue - so you can answer "do AI referrals convert, and what are they worth", not just "did they land". It costs six extra GA4 reports, so it is opt-in.

```bash
curl "https://www.rankzero.io/api/v1/brands/tesla.com/ga4?period=30d&keyEvents=true" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
```

```json
{
  "brand": "Tesla",
  "period": "30d",
  "window": { "startDate": "2026-05-10", "endDate": "2026-06-08" },
  "asOf": "2026-06-08T12:00:00.000Z",
  "ga4": { "sessionsBySource": [ { "source": "chatgpt.com", "sessions": 3, "aiSessions": 3, "ai": true, "aiSource": "ChatGPT" } ], "totalSessions": 42, "aiSessions": 3 },
  "keyEvents": {
    "currency": "EUR",
    "rows": [
      {
        "event": "purchase",
        "keyEvents": 18, "users": 16, "sessions": 17, "sessionRate": 0.0121, "revenue": 4210.5,
        "aiKeyEvents": 4, "aiUsers": 4, "aiSessions": 4, "aiSessionRate": 0.0308, "aiRevenue": 980,
        "organicKeyEvents": 9, "organicUsers": 8, "organicSessions": 9, "organicSessionRate": 0.0164, "organicRevenue": 2110.25
      }
    ],
    "all":     { "sessions": 1402, "users": 1180, "keyEvents": 24, "sessionRate": 0.0157, "revenue": 4210.5, "totalRevenue": 4210.5, "transactions": 18, "revenuePerSession": 3 },
    "ai":      { "sessions": 130,  "users": 121,  "keyEvents": 5,  "sessionRate": 0.0308, "revenue": 980,    "totalRevenue": 980,    "transactions": 4,  "revenuePerSession": 7.54 },
    "organic": { "sessions": 548,  "users": 470,  "keyEvents": 12, "sessionRate": 0.0201, "revenue": 2110.25,"totalRevenue": 2110.25,"transactions": 9,  "revenuePerSession": 3.85 }
  }
}
```

* **Conversion rate.** Per row, `sessions` is the sessions that contained the event and `sessionRate` is that over the audience's total sessions - so `aiSessionRate` is *this event's conversion rate for AI traffic*. On the audience blocks, `sessionRate` is GA4's own `sessionKeyEventRate`: the share of that audience's sessions containing **any** key event. `ai.sessionRate` vs `organic.sessionRate` is the headline "AI visitors convert at X% vs organic at Y%" - check `ai.sessions` first, since a handful of sessions makes the rate noise. Rates are fractions (0-1), like `gsc.ctr`.
* **Revenue (ecommerce).** `ai.revenue` and `organic.revenue` are the ecommerce purchase revenue from those sessions, with `transactions` (purchases) and `revenuePerSession` alongside; `totalRevenue` also counts subscription and ad revenue. Per row, `aiRevenue` / `organicRevenue` attribute revenue to that key event, so a `purchase` row shows what AI traffic actually bought. All amounts are in the property's `currency`, and are `0` on properties without ecommerce tracking.
* `all` / `ai` / `organic` share one definition of the audience with the rest of RankZero. **AI Search** is GA4's own AI Assistant channel plus our allowlist for the assistants it does not yet cover. **Organic Search** is GA4's own Organic Search channel plus the engines GA4 omits from it: GA4 only counts a source as organic if it is on Google's own search-source list or the medium is exactly `organic`, so Brave, Startpage, Kagi, Mojeek, Swisscows, MetaGer, Petal, Presearch and Qwant arrive as referrals and would otherwise fall into neither audience. Brave alone is 21k sessions across our properties.
* The two are **disjoint**: an assistant that arrives tagged `medium=organic` (GA4 files it under Organic Search) counts as AI only, so `ai` and `organic` never double-count the same session.
* Google's AI Overviews and AI Mode count as **Organic Search**, per Google's own definition - there is no destination-side signal that separates them from an ordinary organic click. Brave is the same: its AI answers and its web results arrive under one referrer, since `search.brave.com` sends `strict-origin-when-cross-origin` and the path never reaches you. Both sit in `organic`, not `ai`.
* `keyEvents` is `null` when GA4 isn't connected, and `rows` is empty when the property has no events marked as key events in GA4 (Admin → Events → *Mark as key event*). If **every** event is marked, the audience `sessionRate` saturates at 1 - that is the property's configuration, not the API.

### `GET /brands/{brand}/competitors`

The brand's tracked competitors. `url` is the bare domain, `variations` is the set of strings mention matching keys on, and `isOwnBrand` marks the brand's own row - created with the brand, and not one of the rivals your plan counts.

```json
{
  "brand": "Tesla",
  "asOf": "2026-06-08T12:00:00.000Z",
  "competitors": [
    { "id": "…", "name": "Rivian", "url": "rivian.com", "isOwnBrand": false, "variations": ["rivian"] }
  ]
}
```

### `POST /brands/{brand}/competitors`

Tracks competitors. The body takes an array, so a batch is one call.

```bash
curl -X POST "https://www.rankzero.io/api/v1/brands/tesla.com/competitors" \
  -H "Authorization: Bearer $RANKZERO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "competitors": [
        { "name": "Rivian", "url": "https://www.rivian.com/r1s", "variations": ["Rivian Automotive"] }
      ] }'
```

* `name` is required (max 120 characters) and is what mention matching keys on. Matching for competitors is **case-sensitive**, so send the brand's own casing: `HubSpot`, not `Hubspot`.
* `url` is required and may be anything domain-shaped (`https://www.rivian.com/r1s` and `rivian.com` both work). It is stored as the bare domain, and that domain is what duplicates are decided on. A value that is not a domain is rejected rather than stored as one.
* `variations` optional (max 20 per competitor), **added to** the forms generated from the name rather than replacing them. Use it for the other names a brand answers to: `["X"]` on Twitter.
* `isOwnBrand` cannot be set through the API. The own-brand row is created with the brand, and a second one would have the brand competing with itself in every ranking.
* `source` is set by the server, never by the caller. It currently records `manual`, so API-created competitors are **not** distinguishable from dashboard entries yet; that needs a `competitor_source` enum value.
* Max 50 competitors per request.

Per-item, not all-or-nothing: a batch where three of twenty are already tracked creates seventeen and names the three in `errors`.

```json
{
  "brand": "Tesla",
  "asOf": "2026-06-08T12:00:00.000Z",
  "created": 1,
  "competitors": [
    { "id": "…", "name": "Rivian", "url": "rivian.com", "isOwnBrand": false, "variations": ["rivian", "Rivian Automotive"] }
  ],
  "trackedCompetitors": { "used": 8, "max": 20, "remaining": 12 },
  "errors": [
    { "name": "Lucid", "url": "lucidmotors.com", "error": "already tracked for this brand", "existingCompetitorId": "…" }
  ]
}
```

* Duplicates are decided on the normalised domain, so `https://www.lucidmotors.com/air` and `lucidmotors.com` are the same competitor. A domain repeated inside one batch is reported as `named more than once in this request`, and the brand's own domain comes back as such rather than being tracked twice.
* `trackedCompetitors` is your plan budget after the call, so a client can throttle itself. The own-brand row never counts against it.
* A batch that is entirely duplicates is a `200` with `created: 0`, not an error. A body with nothing usable in it is a `400`, and carries the same `errors` list.

The **plan's competitor limit** is the one thing that fails the whole call. Exceeding it returns `409` with the numbers rather than creating some and dropping the rest:

```json
{
  "error": "Competitor limit reached: 18 of 20 tracked on the starter plan, 5 more requested.",
  "plan": "starter", "limit": 20, "current": 18, "requested": 5
}
```

Each new competitor is then backfilled against the brand's recent answers, so it arrives with the history it would have had rather than at zero. That pass runs *after* the response, and is best effort: a large batch may not finish backfilling. Answers from that point on are attributed normally either way.

**Who you track changes what the numbers say**, so add deliberately rather than in bulk. Visibility is a share of the answers that mentioned *someone on the list*, so a newly tracked competitor can pull answers into that denominator which previously counted for nobody, and every existing row's percentage, your own brand's included, falls a little without a single mention changing. The list is also the entire vocabulary of competitor reporting: a rival you do not track cannot appear in any of it. Adding twenty competitors at once and reading the movement as a change in your brand's performance is the mistake this warning exists to prevent.

**And the change reaches backwards.** The backfill described above re-scans the brand's 250 most recent answers, so answers you have already been reported on can gain a mention and enter the denominator of a period you have already looked at. A figure from last month can read lower the next time you open it, with no new data and no change in your own mentions. Two things follow. A comparison that spans a competitor addition is not like for like, so treat the addition as a break in the series rather than a data point in it. And because the re-scan stops at 250 answers, periods further back than that keep the numbers they had: a long window can straddle the boundary, with its recent half restated and its older half not. Where you have the choice, add competitors between reporting periods rather than inside one.

### `GET /brands/{brand}/competitor-suggestions`

Competitors suggested for tracking, newest first, the same list as the suggestions on the dashboard's Competitors page. `?status` is `pending` (the default), `approved`, `rejected`, `all`, or a comma-separated list. `source` is `ai_detected` (named in one of the brand's AI answers, with the snippet in `context`) or `manual` (added on the dashboard or through this API).

RankZero stops suggesting competitors from AI answers once a brand holds more than 25 suggestions in any status, and approving or rejecting one keeps it in the count. `aiDiscovery.running` says whether that is still happening for the brand, so a quiet list can be told apart from a paused one.

```json
{
  "brand": "Tesla",
  "asOf": "2026-10-09T12:00:00.000Z",
  "count": 1,
  "totals": { "pending": 1, "approved": 4, "rejected": 2 },
  "aiDiscovery": { "running": true, "suggestions": 7, "cap": 25 },
  "suggestions": [
    {
      "id": "…",
      "name": "Rivian",
      "url": "rivian.com",
      "source": "ai_detected",
      "status": "pending",
      "context": "…the Rivian R1S and the Model X both…",
      "variations": ["rivian"],
      "promptRunId": "…",
      "createdAt": "…"
    }
  ]
}
```

### `POST /brands/{brand}/competitor-suggestions`

Adds competitors to the suggestion list as `pending`, for someone to approve or reject, without tracking them. Use it when you have found a competitor somewhere else (in search results, say) and want it reviewed rather than tracked straight away.

```bash
curl -X POST "https://www.rankzero.io/api/v1/brands/tesla.com/competitor-suggestions" \
  -H "Authorization: Bearer rz_live_..." -H "Content-Type: application/json" \
  -d '{ "suggestions": [
        { "name": "Rivian", "url": "rivian.com", "context": "ranks for 14 of our target searches" }
      ] }'
```

* `name` and `url` follow the rules of `POST /competitors`. `context` is optional (max 500 characters) and is shown beside the suggestion.
* A domain that is already tracked, already suggested (in any status, so a rejected one is not offered again) or named twice in the batch is refused in `errors` and the rest are added.
* Max 50 per request. Suggestions added here count towards the 25 that pause discovery from AI answers.

The response carries `created`, the `suggestions` as written and per-item `errors`.

### `PATCH /brands/{brand}/competitor-suggestions`

Approves or rejects suggestions by id: `{ "approve": ["…"], "reject": ["…"] }`, max 50 ids in all.

* **Approving tracks the competitor** through exactly the same path as `POST /competitors`: the same validation, plan limit and history backfill, and the same warning about what adding a competitor does to every visibility figure. Approve one at a time when you want each one's history backfilled.
* A suggestion whose domain is already tracked is marked approved too, with the competitor's id in `errors`.
* Rejecting marks the suggestion rejected. An approved suggestion cannot be rejected here, because its competitor is tracked; stop tracking it on the Competitors page. A rejected one can still be approved.
* Ids resolve within the addressed brand only. The plan limit fails the whole call with the `409` that `POST /competitors` returns, and then nothing is approved or rejected.

The response carries the `approved` and `rejected` suggestions, the `competitors` the call created, `trackedCompetitors` (the plan budget after the call, `null` when nothing was approved) and per-item `errors`.

### `GET /brands/{brand}/citations`

Sources cited in AI answers, with content-type categorization. Accepts `?period`, `?limit` (default 50), `?country`, `?provider` (`chatgpt`|`gemini`|`perplexity`|`anthropic`|`google_ai_overview`).

```json
{
  "brand": "Tesla",
  "period": "30d",
  "asOf": "2026-06-08T12:00:00.000Z",
  "sources": [
    {
      "source_url": "https://www.youtube.com/watch",
      "page_title": "…",
      "domain": "youtube.com",
      "usage_pct": 22.22,
      "avg_citations": 1,
      "prompt_texts": ["best electric SUV"],
      "content_type": "video"
    }
  ]
}
```

**Citation trend.** Add `?dimensions=week` for one row per ISO week (Monday start, UTC), or `?dimensions=date` for one row per UTC day, instead of the top sources. Each row says how many AI answers ran and how many of them cited your own site as a source, so the share of answers citing you is `citedRuns / runs`, tracked over time. Rates are left to you, so they can be computed over any grouping of rows.

* `runs` are successful answers and `failedRuns` the ones a provider did not return. A week where a provider failed shows up as fewer `runs`, not as a lower citation rate, so read both.
* `prompts` is the number of distinct prompts with a successful answer in that bucket.
* `citedRuns` counts answers with at least one source on your domains; `citations` counts those sources, so one answer citing three of your pages adds 1 and 3.
* `mentionedRuns` counts answers that named your brand, for comparing being named with being cited.
* `byProvider` splits every count by assistant, with the `models` that answered in that bucket, so a model change on one provider is visible as the moment it happened.

Your domains default to the brand's own domain. Pass `?domains=a.com,b.com` to set them, for example across a domain move; `www.` is always matched along with the bare domain. `?tag=` narrows the series to runs of active prompts carrying that tag, as it does on `/kpis` and `/overview`. The window is `?period=` or an exact `?from=YYYY-MM-DD&to=YYYY-MM-DD` range (inclusive, at most 400 days, `period` then comes back `null`). Weekly windows widen to whole weeks, and the resolved `from` and `to` are echoed. Rows are ascending and zero-filled, and `partial` is `true` on the bucket that is still in progress.

```bash
curl "https://www.rankzero.io/api/v1/brands/tesla.com/citations?dimensions=week&period=90d" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
```

```json
{
  "brand": "Tesla",
  "domains": ["tesla.com"],
  "dimensions": ["week"],
  "period": "90d",
  "tag": null,
  "from": "2026-03-09",
  "to": "2026-06-14",
  "asOf": "2026-06-08T12:00:00.000Z",
  "rows": [
    {
      "start": "2026-03-09",
      "partial": false,
      "runs": 330,
      "failedRuns": 1,
      "prompts": 12,
      "citedRuns": 127,
      "citations": 150,
      "mentionedRuns": 129,
      "byProvider": [
        {
          "provider": "chatgpt",
          "models": ["gpt-5-mini"],
          "runs": 85,
          "failedRuns": 0,
          "citedRuns": 20,
          "citations": 20,
          "mentionedRuns": 21
        }
      ]
    }
  ]
}
```

### `GET /brands/{brand}/mentions`

Every prompt run in the period, newest first, with the competitors (own brand
included) that its answer named. Accepts `?period`, `?page` (default 1) and
`?pageSize` (default 50, max 500; `?limit` is the older name for the same thing).

`count` is the total number of runs in the period, not the size of this page - so
"named in N of M AI answers" is answerable: `M` is `count`, `N` is what you count
across the pages. `window.from` is the resolved lower bound the rows were taken
from.

```bash
curl "https://www.rankzero.io/api/v1/brands/tesla.com/mentions?period=30d&page=1&pageSize=50" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
```

```json
{
  "brand": "Tesla",
  "period": "30d",
  "window": { "from": "2026-05-09T12:00:00.000Z", "to": "2026-06-08T12:00:00.000Z" },
  "asOf": "2026-06-08T12:00:00.000Z",
  "page": 1,
  "pageSize": 50,
  "count": 1284,
  "mentions": [
    {
      "prompt_id": "…",
      "run_id": "…",
      "prompt_text": "best electric SUV",
      "model": "chatgpt",
      "output_preview": "…",
      "brands_mentioned_name": ["Tesla", "Rivian"],
      "brands_mentioned_url": ["tesla.com", "rivian.com"],
      "brands_mentioned_ids": ["…", "…"],
      "run_at": "2026-06-08T06:12:41.000Z",
      "country": "United States"
    }
  ]
}
```

* **`period` is the window.** One row per run, so a longer period really does
  return more: `count` grows with it and the pages reach further back. (Before
  2026-08-27 this endpoint returned the latest run per prompt and capped at 50
  rows undocumented, which made every period return the same slice.)
* `brands_mentioned_*` are the brand's tracked competitors, own brand included;
  `isOwnBrand` per competitor comes from `/competitors`.
* Rows are ordered by `run_at` descending, `run_id` breaking ties, so walking
  `?page=1..n` sees every run once and none twice even when a batch lands in the
  same instant. Page 1 is the newest runs whatever the period, since every window
  ends now; it is the later pages that reach further back.
* A page past the last one is an empty `mentions` array with the real `count`,
  not an error, so you can page until it comes back empty.
* A run whose answer was never stored is neither listed nor counted.

### `GET /brands/{brand}/prompts`

The brand's **tracked** prompts - the ones being monitored (paginated, 50/page). Accepts `?page`, `?search`, `?isActive`, `?tag`. Distinct from the suggestion queue below.

`?isActive` selects the set: `true` (the default, so callers written before this parameter existed see no change), `false`, or `all`. Any other value is a 400 rather than a silent fall back to the default.

`?tag` returns only the prompts carrying that tag, matching the same way `?tag` does on `/kpis` and `/overview`, so you can list the exact prompt set a tagged visibility figure was computed over. Tags are free text with no fixed vocabulary, so an unknown tag is an empty page rather than an error.

`source` records where each prompt came from: `manual`, `ai_generated`, `own_brand`, or a `suggestion_*` origin (`suggestion_gsc`, `suggestion_bing`, `suggestion_paa`, `suggestion_generated`, `suggestion_rankzero`, `suggestion_perplexity`) when it was promoted from the suggestion queue. `intents` / `tags` are the prompt's classification (may be `null`).

```json
{
  "brand": "Tesla",
  "asOf": "2026-06-08T12:00:00.000Z",
  "page": 1,
  "pageSize": 50,
  "count": 15,
  "prompts": [
    {
      "id": "…",
      "text": "best electric SUV",
      "country": "United States",
      "isActive": true,
      "source": "suggestion_bing",
      "intents": ["commercial"],
      "tags": null,
      "createdAt": "…"
    }
  ]
}
```

### `POST /brands/{brand}/prompts`

Creates tracked prompts. The body takes an array, so a batch is one call.

```bash
curl -X POST "https://www.rankzero.io/api/v1/brands/tesla.com/prompts" \
  -H "Authorization: Bearer $RANKZERO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "prompts": [
        { "text": "best electric SUV", "country": "US", "tags": ["commercial"], "isActive": true }
      ] }'
```

* `text` is required (max 500 characters).
* `country` defaults to **the brand's own country**, not a global default. A value you send has to resolve to a real country: `US`, `us` and `United States` all work, `Atlantis` is rejected rather than quietly stored.
* `tags` optional (max 20 per prompt), `isActive` optional and defaults to `true`.
* `source` is set by the server, never by the caller. It currently records `manual`, so API-created prompts are **not** distinguishable from dashboard entries yet; that needs a `prompt_source` enum value.
* Max 100 prompts per request.

Per-item, not all-or-nothing: a batch where three of twenty already exist creates seventeen and names the three in `errors`.

```json
{
  "brand": "Tesla",
  "asOf": "2026-06-08T12:00:00.000Z",
  "created": 1,
  "prompts": [ { "id": "…", "text": "best electric SUV", "country": "United States", "isActive": true, "source": "manual", "intents": ["commercial"], "tags": ["commercial"], "createdAt": "…" } ],
  "activePrompts": { "used": 16, "max": 25, "remaining": 9 },
  "errors": [
    { "text": "best electric car", "error": "already tracked for this brand and country", "existingPromptId": "…" }
  ]
}
```

* Duplicates are matched on brand + country + case-insensitive text, **including inactive prompts**. A prompt you deactivated last month is reported with its `existingPromptId` rather than recreated as a second row, so re-running against an overlapping candidate set is safe. PATCH that id active if you want it back.
* `activePrompts` is your plan budget after the call, so a client can throttle itself.
* A batch that is entirely duplicates is a `200` with `created: 0`, not an error: that is the ordinary result of re-running. A body with nothing usable in it is a `400`, and carries the same `errors` list.

The **plan's active-prompt limit** is the one thing that fails the whole call. Exceeding it returns `409` with the numbers rather than creating some and dropping the rest:

```json
{
  "error": "Active prompt limit reached: 24 of 25 active on the starter plan, 6 more requested.",
  "plan": "starter", "limit": 25, "current": 24, "requested": 6
}
```

Creating prompts with `isActive: false` does not spend the budget.

### `PATCH /brands/{brand}/prompts`

Two body shapes, one endpoint. Send one or the other, never both.

**Bulk status**, for retiring and restoring prompts:

```json
{ "promptIds": ["…", "…"], "isActive": false }
```

**Per-prompt edits**, for tags and country:

```json
{ "updates": [
    { "id": "…", "addTags": ["permits-commercial"], "removeTags": ["unclustered"] },
    { "id": "…", "tags": ["only-these"], "country": "DE" },
    { "id": "…", "isActive": true }
] }
```

* `tags` **replaces** the set; `addTags` / `removeTags` **amend** it. Sending both for one prompt is rejected, because the two have no defensible order. Prefer amending: a replace built from a value you read earlier will clobber any tag added in the dashboard meanwhile. `"tags": []` clears every tag.
* `country` follows the same recognition rule as create.
* Ids are resolved **within the addressed brand only**. An id belonging to another brand comes back as `unknown prompt for this brand` and is not written.
* Naming the same id twice in one call rejects the second, so two conflicting edits cannot race inside one request.
* Activating prompts spends plan budget and can return the same `409` as create. Deactivating never does.

Response mirrors create: `updated`, the `prompts` as written, `activePrompts`, and per-item `errors`. A call where nothing matched is a `400` that still lists why each id was refused.

### `GET /brands/{brand}/prompt-suggestions`

The suggestion queue - questions you could add to tracking, distinct from the tracked prompts above. Accepts `?type`, `?status` (single value or comma-separated).

This queue spans **every engine**: `suggestionType` identifies where each came from - `direct_conversion_gsc` / `direct_conversion_bing` (queries the site converts on, per engine), `related_keywords_bing` (Bing keyword research), and PAA / generated / Perplexity variations. `source.impressions` (and clicks/ctr/position where the engine returns them) carry the demand behind each. So Bing keyword suggestions come out **here**, alongside GSC and the rest - filter to Bing with `?type=direct_conversion_bing,related_keywords_bing`.

```json
{
  "brand": "Tesla",
  "asOf": "2026-06-08T12:00:00.000Z",
  "count": 12,
  "suggestions": [
    {
      "id": "…",
      "promptText": "which electric SUV has the longest range",
      "suggestionType": "direct_conversion_bing",
      "status": "suggested",
      "anchorQuery": "electric SUV range",
      "confidenceScore": 0.82,
      "sourceQuery": "longest range electric suv",
      "country": "United States",
      "source": { "clicks": 12, "impressions": 480, "ctr": 0.025, "position": 8.3 },
      "createdAt": "…"
    }
  ]
}
```

### `GET /brands/{brand}/bing`

The brand's **Bing Webmaster** integration - the Bing counterpart to [`/gsc`](#get-brandsbrandgsc), plus the crawl/index health GSC exposes per URL. Bing is the demand signal AI assistants ground on (Copilot and ChatGPT search lean on it), so everything Bing lives here:

* `queries` - the site's Bing query performance, rolled up to one row per `query` with the **same shape as `/gsc`'s query breakdown** (`clicks`, `impressions`, `ctr`, `position`; clicks + impressions summed, CTR derived, position averaged weighted by impressions), sorted by impressions. The demand the site **already earns** - Bing's `/gsc` analog. Fetched live; present only when a Bing site is connected.
* `related` - keyword **research**. Populated only when you pass `?seed=<keyword>`: a live Bing expansion of the seed into related keywords with impression volume (sorted by volume), surfacing demand the site **doesn't yet rank for**. Optional `?country=` (lowercase alpha-2, default `us`) and `?language=` (full locale, default `en-US`); the volume window is the trailing 30 days. Empty without a seed, or when not connected.
* `crawlStats`: the freshest daily crawl breakdown, fetched **live**: `inIndex`, `crawledPages`, `code2xx` / `code4xx` / `code5xx`, `blockedByRobotsTxt`, `containsMalware`, `crawlErrors`, `date`. `inIndex` (pages in Bing's index) is the **aggregate** index count; per-URL coverage is in `indexStatus`. `null` when not connected or Bing returns no data.
* `crawlIssues` - one row per (url, issue): `{ url, label, severity }` (404s, blocked, malware, redirects…). Fetched live; empty when clean or not connected.
* `indexStatus`: **live per-URL index coverage**. RankZero takes the brand's sitemap URLs and inspects a page of them against Bing's index, returning one row per URL: `{ url, indexed, lastCrawl?, reason? }`. `indexed` is **tri-state**: `true` (Bing reports a crawl date, `lastCrawl`, the only per-URL index signal Bing exposes), `false` (a confident not-indexed verdict, `reason` e.g. `Not in Bing index`, `Discovered, not yet crawled`), or `null` (**unknown**: the live lookup was rate-limited or failed, `reason` `lookup throttled` / `lookup failed`). Never treat `null` as not-indexed, it is a transient artifact, not a gap. `indexedCount` / `notIndexedCount` / `unknownCount` cover the returned page and count only their own verdict; `count` is the full sitemap size. Paginate with `?page` (default `1`) and `?limit` (default `25`, max `100`). Use it to find non-indexed live URLs, then submit them via [`/bing/submit`](#post-brandsbrandbingsubmit). `null` when not connected.

Bing keyword **suggestions** are not here - they live in [`/prompt-suggestions`](#get-brandsbrandprompt-suggestions) alongside every other engine (filter with `?type=direct_conversion_bing,related_keywords_bing`).

Not connected → `connected: false`, empty/null live fields, `200` - never an error. `seed` echoes the requested seed (`null` if none).

```bash
# The brand's Bing integration (query impressions + crawl/index health)
curl "https://www.rankzero.io/api/v1/brands/tesla.com/bing" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"

# ...plus live keyword research expanded from a seed
curl "https://www.rankzero.io/api/v1/brands/tesla.com/bing?seed=electric%20suv&country=us&language=en-US" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"

# ...page through per-URL index coverage
curl "https://www.rankzero.io/api/v1/brands/tesla.com/bing?page=1&limit=100" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
```

```json
{
  "brand": "Tesla",
  "asOf": "2026-06-08T12:00:00.000Z",
  "connected": true,
  "siteUrl": "https://tesla.com/",
  "seed": "electric suv",
  "queries": [
    { "query": "electric suv range", "clicks": 34, "impressions": 1240, "ctr": 0.027, "position": 6.8 }
  ],
  "related": [
    { "query": "best electric suv 2026", "impressions": 8400 },
    { "query": "electric suv with longest range", "impressions": 3100 }
  ],
  "crawlStats": {
    "inIndex": 4120,
    "crawledPages": 5300,
    "code2xx": 5100,
    "code4xx": 140,
    "code5xx": 12,
    "blockedByRobotsTxt": 48,
    "containsMalware": 0,
    "crawlErrors": 152,
    "date": "2024-06-07T00:00:00.000Z"
  },
  "crawlIssues": [
    { "url": "https://tesla.com/old", "label": "Client error (4xx)", "severity": "error" }
  ],
  "indexStatus": {
    "asOf": "2026-06-08T12:00:00.000Z",
    "page": 1,
    "pageSize": 25,
    "count": 5300,
    "indexedCount": 22,
    "notIndexedCount": 2,
    "unknownCount": 1,
    "urls": [
      { "url": "https://tesla.com/model-y", "indexed": true, "lastCrawl": "2026-06-07T04:12:00.000Z" },
      { "url": "https://tesla.com/new-page", "indexed": false, "reason": "Not in Bing index" },
      { "url": "https://tesla.com/model-s", "indexed": null, "reason": "lookup throttled" }
    ]
  }
}
```

### `POST /brands/{brand}/bing/submit`

Submit live URLs to Bing for (re)crawl through the brand's **existing** Bing Webmaster connection, the IndexNow equivalent, with no per-site `{key}.txt` files. Pair it with [`/bing`](#get-brandsbrandbing)'s `indexStatus`: page through to find `indexed: false` URLs, then submit them here.

Body: `{ "urls": string[] }` (http(s) URLs; duplicates and malformed entries are dropped into `errors`). Auth and brand resolution are identical to the other `/bing` routes.

Respects Bing's remaining **daily submission quota**: URLs beyond it are not sent and come back in `errors` as `daily submit quota exhausted`. Response:

* `submitted`: count actually sent to Bing.
* `quotaRemaining`: daily quota left after this call (omitted when Bing doesn't report a quota).
* `errors`: one `{ url?, error }` per dropped/failed URL (invalid input, quota, or a Bing error message).

A brand with no Bing connection returns a clean `409 { "error": "brand not connected to Bing" }`. An empty/invalid body or no valid URLs returns `400`.

```bash
curl -X POST "https://www.rankzero.io/api/v1/brands/tesla.com/bing/submit" \
  -H "Authorization: Bearer $RANKZERO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "urls": ["https://tesla.com/new-page", "https://tesla.com/model-y"] }'
```

```json
{
  "brand": "Tesla",
  "asOf": "2026-06-08T12:00:00.000Z",
  "submitted": 2,
  "quotaRemaining": 8,
  "errors": []
}
```

### `GET /brands/{brand}/audit`

The brand's **technical audit** from the weekly crawl, in one read. An audit has two levels, and a finding lives at exactly one of them: the site, or a page.

* `site`: the newest **sitewide record**, one per crawl. robots.txt and the AI crawler policy it sets (`aiCrawlers`), the sitemap and how many URLs it lists, llms.txt, the favicon, the homepage's Organization and WebSite JSON-LD, the share of crawled pages carrying JSON-LD (`structuredData`), HTTPS, and how the www and non-www hosts resolve (`hostRedirect`: `to_www`, `to_apex`, `both_serve`, and so on). `null` until the brand's first crawl drains. For tracked brands it also carries:
  * `performance`: the origin's **Chrome UX Report** record (`crux`: p75 LCP, INP and CLS of real Chrome users, the `tier` that answered, and `passes` against Google's thresholds of 2.5 s, 200 ms and 0.1), and the homepage's newest **PageSpeed Insights** run (`lab`: Lighthouse, mobile, with its performance score, LCP, CLS, TBT and three largest savings). Tier `none` means neither the apex nor the www origin has field data, and its metrics are `null`, never zero.
  * `authority`: `domainRating`, always beside `attribution` (`{ "text": "Domain Rating by Ahrefs", "url": "https://ahrefs.com/" }`, which the Ahrefs licence requires wherever you show the figure), and `checkedAt`, when Ahrefs gave it. Read weekly.
* `todos`: exactly the list the dashboard's to-dos page shows, from the same server-side read: the **latest audit of every URL the sitemap lists**, however many crawls ago it ran. The weekly crawl rotates through a large site, so a page it did not reach this week keeps its findings rather than reading as fixed. Each carries `level`, `site` or `page`, so a site-level issue appears once rather than once per page, and a crawler page finding carries `auditedAt` (and each page's date in `audits`): a finding dated before the latest crawl has not been re-audited, which is not the same as fixed. `todosTruncated` is `true` only for a site with more than 20,000 audited pages. A template finding on a group of more than 50 similar URLs (`tesla.com/products/*`) is reported once for the group: every affected page is in `urls`, and `group` says how many of the group's pages were audited (`sampled`) out of how many the sitemap lists (`size`).
* `pages`: every URL the sitemap lists, sorted, `limit` per call (default `100`, max `500`). Each carries the newest audit's facts (`status`, `finalUrl`, `redirected`, `noindex`, `canonical`, title, H1, word count, hreflang, links, images, Open Graph and Twitter tags, structured data), `selection` (why the crawl last chose it: `recheck`, `changed`, `template`, `traffic` or `rotation`), its `template` group (with its size when over 50 URLs), its sitemap `lastmod`, its `issues`, its **index state per engine**, its `crux` reading (the page's own record where it has one, else its origin's, else `none`), its newest `lab` run where one was taken (the homepage, the top 20 pages by Search Console clicks, and pages whose own field record fails), and `images` (every image it references over 200 KB, heaviest first, each rated `needs-improvement` up to 500 KB or `poor` above, with the `bands` in bytes). A page not audited yet has `audited: false` and null facts.
* `sitemapTotal`, `total` (URLs the last Search Console inspection run checked) and `checkedAt` (when it ran), so you can tell inspected from not.

Index state is one of four values per engine:

| `state`         | Meaning                                                                                                                  |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `indexed`       | Inspected, and the run named every page it found not indexed.                                                            |
| `not_indexed`   | Not indexed, with the coverage `reason` Google gave.                                                                     |
| `crowded_out`   | Inspected, but the cached issues do not name every page the run found not indexed, so this one cannot be called indexed. |
| `not_inspected` | Not among the pages the last inspection run checked. Never read it as indexed.                                           |

An engine that isn't connected is `null`. Bing's cached status has no per-URL verdict, so Bing pages are `not_inspected`; use [`/bing`](#get-brandsbrandbing)'s `indexStatus` for live lookups.

Speed to-dos come from field data only: a page whose own CrUX record fails a threshold is a page to-do, and a failing origin record is one site to-do, each with the lab run attached as diagnosis. A lab run never raises a to-do on its own, because it moves from run to run. Images over 200 KB raise one low-priority to-do for the site, naming every affected page; the ones over 500 KB are listed as `poor`.

Page through `pages` by passing `nextCursor` back as `?cursor=` (an unreadable cursor is `400 invalid cursor`). `site` and `todos` are the same on every page. `?url=` returns just that page (matched exactly, or ignoring www and a trailing slash), which suits a done-test that re-reads one URL.

```bash
curl "https://www.rankzero.io/api/v1/brands/tesla.com/audit?limit=100" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"

# One page, for a done-test
curl "https://www.rankzero.io/api/v1/brands/tesla.com/audit?url=https://tesla.com/model-y" \
  -H "Authorization: Bearer $RANKZERO_API_KEY"
```

```json
{
  "brand": { "id": "3f8a1c2e-9b4d-4e7a-bc1f-2d6e5a0b9c11", "name": "Tesla", "domain": "tesla.com" },
  "checkedAt": "2026-09-15T14:02:10.875Z",
  "sitemapTotal": 427,
  "total": 150,
  "index": {
    "google": { "indexed": 127, "total": 150, "notIndexed": 23, "notIndexedNamed": 23, "checkedAt": "2026-09-15T14:02:10.875Z" },
    "bing": null
  },
  "selection": { "at": "2026-09-15T03:00:12.000Z", "budget": 1000, "inventory": 427, "complete": true, "counts": { "recheck": 12, "changed": 40, "template": 30, "traffic": 280, "rotation": 65 } },
  "site": {
    "checkedAt": "2026-09-15T09:41:02.000Z",
    "homepageUrl": "https://tesla.com/",
    "https": true,
    "httpRedirectsToHttps": true,
    "canonicalHost": "tesla.com",
    "hostRedirect": "to_apex",
    "robotsTxt": { "present": true, "status": 200, "sitemaps": ["https://tesla.com/sitemap.xml"] },
    "aiCrawlers": [{ "agent": "GPTBot", "label": "ChatGPT (training)", "allowed": true, "source": "default" }],
    "sitemap": { "present": true, "url": "https://tesla.com/sitemap.xml", "urlCount": 427 },
    "llmsTxt": { "present": false, "status": 404 },
    "favicon": { "url": "https://tesla.com/favicon.png", "faviconIco": false },
    "organization": { "types": ["Organization"], "name": "Tesla", "logo": true, "contactPoint": false, "address": false, "sameAs": 1 },
    "website": null,
    "structuredData": { "pages": 380, "measured": 420, "share": 0.905 },
    "performance": {
      "thresholds": { "lcpMs": 2500, "inpMs": 200, "cls": 0.1 },
      "crux": { "tier": "origin", "origin": "https://tesla.com", "lcpP75Ms": 1936, "inpP75Ms": 112, "clsP75": 0.01, "passes": { "lcp": true, "inp": true, "cls": true }, "checkedAt": "2026-09-15T09:41:04.000Z" },
      "lab": { "reason": "homepage", "performanceScore": 0.64, "lcpMs": 5657, "cls": 0, "tbtMs": 372, "opportunities": [{ "id": "redirects", "title": "Avoid multiple page redirects", "savingsMs": 780, "savingsBytes": null }], "checkedAt": "2026-09-15T03:12:40.000Z" }
    },
    "authority": {
      "domainRating": 45,
      "attribution": { "text": "Domain Rating by Ahrefs", "url": "https://ahrefs.com/" },
      "checkedAt": "2026-09-15T09:41:05.000Z"
    }
  },
  "todos": [
    { "level": "site", "category": "Sitewide", "issue": "Create llms.txt file", "severity": "info", "source": "crawler", "url": "Sitewide", "urls": [], "group": null, "details": null, "auditedAt": null, "audits": [], "createdAt": "2026-09-15T09:41:02.000Z" },
    { "level": "page", "category": "Images", "issue": "Add alt text to images", "severity": "warning", "source": "crawler", "url": "https://tesla.com/products/model-3", "urls": ["https://tesla.com/products/model-3", "https://tesla.com/products/model-y"], "group": { "key": "tesla.com/products/*", "size": 77, "sampled": 10 }, "details": "Seen on 2 of 10 sampled pages in tesla.com/products/*, a group of 77 pages: …", "auditedAt": "2026-09-08T08:12:40.000Z", "audits": [{ "url": "https://tesla.com/products/model-3", "auditedAt": "2026-09-15T08:12:40.000Z" }, { "url": "https://tesla.com/products/model-y", "auditedAt": "2026-09-08T08:12:40.000Z" }], "createdAt": "2026-09-15T08:12:40.000Z" },
    { "level": "page", "category": "Indexing", "issue": "Not indexed: Discovered - currently not indexed", "severity": "warning", "source": "gsc", "url": "https://tesla.com/new-page", "urls": ["https://tesla.com/new-page"], "group": null, "details": null, "auditedAt": null, "audits": [], "createdAt": "2026-09-15T14:02:10.875Z" }
  ],
  "todosTruncated": false,
  "pages": [
    {
      "url": "https://tesla.com/model-y",
      "audited": true,
      "auditedAt": "2026-09-15T08:10:03.000Z",
      "status": 200,
      "finalUrl": "https://tesla.com/model-y",
      "redirected": false,
      "noindex": false,
      "canonical": "https://tesla.com/model-y",
      "title": "Model Y | Tesla",
      "titleCount": 1,
      "h1Count": 1,
      "wordCount": 812,
      "template": { "group": "tesla.com/*", "size": null },
      "selection": { "reason": "traffic", "selectedAt": "2026-09-15T03:00:12.000Z" },
      "lastmod": "2026-09-08T22:48:10.000Z",
      "index": {
        "google": { "state": "indexed", "reason": null, "checkedAt": "2026-09-15T14:02:10.875Z" },
        "bing": null
      },
      "crux": { "tier": "url", "origin": null, "lcpP75Ms": 1269, "inpP75Ms": 96, "clsP75": 0.01, "passes": { "lcp": true, "inp": true, "cls": true }, "checkedAt": "2026-09-15T03:05:11.000Z" },
      "lab": null,
      "images": { "total": 14, "bands": { "needsImprovementBytes": 200000, "poorBytes": 500000 }, "oversized": [{ "url": "https://tesla.com/images/model-y-hero.png", "bytes": 412803, "rating": "needs-improvement" }] },
      "issues": ["Compress images over 200 KB"]
    }
  ],
  "limit": 100,
  "nextCursor": "aHR0cHM6Ly90ZXNsYS5jb20vbW9kZWwteQ"
}
```

(Page entries are trimmed above; each also carries `metaDescription`, `h1`, `lang`, `hreflang`, link and image counts, `hasOpenGraph`, `hasTwitterCards`, `hasJsonLd`, `structuredDataTypes` and `firstSeenAt`.)

### `GET /search`

Searches the RankZero knowledge base (docs, glossary, blog). Not brand-scoped. Requires `?query` (`?q` also accepted); optional `?locale`.

```json
{
  "query": "visibility",
  "results": [
    { "id": "/docs/metrics/visibility", "type": "page", "url": "/docs/metrics/visibility", "content": "…" }
  ]
}
```

***

## Errors

Errors are flat JSON: `{ "error": "<message>" }`. Write endpoints add fields
alongside `error`: an `errors` array naming each refused input, and, on a plan
limit, the numbers behind the refusal.

| Status | Meaning                                                                                                                                                                        |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `400`  | Malformed body, or nothing in it was usable, or an unreadable `cursor` on `/audit`. On a write, `errors` says which input failed and why.                                      |
| `401`  | Missing/invalid API key (or API not configured).                                                                                                                               |
| `404`  | `unknown brand` - not found, or outside your key's scope.                                                                                                                      |
| `409`  | `ambiguous brand, use id` - a domain/name matched more than one active brand. Also a plan limit on active prompts or tracked competitors, with the numbers behind the refusal. |
| `429`  | `rate limited` - over the account's request budget. Wait `Retry-After` seconds; the `X-RateLimit-*` headers show the budget.                                                   |
| `500`  | `internal error`.                                                                                                                                                              |
