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

# Data model

> The resources the Surfais API serves, how they fit together, and the rules you can rely on when you read them.

Surfais asks your prompts to five AI platforms and records what each answer says about your brands. The API serves those records and the numbers computed from them. This page maps the resources and states the rules to build on. It is a reference: go to the section you need.

For how to walk a list, see [Pagination and date windows](/api/pagination).

## The hierarchy

| Resource     | What it is                                                                                                                                                                                  | Read it with                                                                                  |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| Organisation | The account that owns everything else. An organisation key reaches one organisation. A partner key reaches every organisation linked to the partner.                                        | `GET /orgs`, `GET /orgs/{orgId}`                                                              |
| Brand        | An **own brand** (`is_own: true`) that the organisation tracks, or a **competitor** (`is_own: false`) that an own brand is measured against. A platform partner's property is an own brand. | `GET /orgs/{orgId}/brands`, `GET /orgs/{orgId}/brands/{brandId}`                              |
| Prompt       | A question asked to the AI platforms on behalf of one own brand.                                                                                                                            | `GET /orgs/{orgId}/prompts`, `GET /orgs/{orgId}/prompts/{promptId}`                           |
| Result unit  | One answer: one prompt, on one platform, in one country, on one scan date.                                                                                                                  | `GET /orgs/{orgId}/prompts/{promptId}/results`, `GET /orgs/{orgId}/brands/{brandId}/mentions` |
| Mention      | Inside a result unit: whether a tracked brand was named, with its `position` and `sentiment`.                                                                                               | The same two endpoints                                                                        |
| Citation     | Inside a result unit: a source the answer retrieved or cited.                                                                                                                               | `GET /orgs/{orgId}/prompts/{promptId}/results`                                                |

**Competitors are linked per own brand.** `GET /orgs/{orgId}/brands/{brandId}` lists an own brand's `competitors`. One competitor can be linked to several own brands, and an own brand can itself be tracked as another own brand's competitor.

Each own brand also has aggregates computed from its result units:

| Aggregate                                           | Read it with                                        |
| --------------------------------------------------- | --------------------------------------------------- |
| Score series                                        | `GET /orgs/{orgId}/brands/{brandId}/scores`         |
| Share of voice against its competitors              | `GET /orgs/{orgId}/brands/{brandId}/share-of-voice` |
| Cited sources                                       | `GET /orgs/{orgId}/brands/{brandId}/sources`        |
| The latest numbers for every own brand, as one list | `GET /orgs/{orgId}/brands/summary`                  |

The summary has one row per own brand. There is no organisation-level score: combine the rows yourself if you need one.

The raw text of AI answers is not served. See [what v1 does not include](/api/introduction#what-v1-does-not-include).

### Prompt fields

| Field       | Meaning                                                                                                                                                                                                                                      |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `brand_id`  | The own brand the prompt belongs to.                                                                                                                                                                                                         |
| `text`      | The question, as an end user would type it. The text is the prompt's identity, so it cannot be edited.                                                                                                                                       |
| `countries` | The markets the prompt runs in. See [Countries and tags](#countries-and-tags).                                                                                                                                                               |
| `platforms` | The AI platforms it runs on: `chatgpt`, `claude`, `gemini`, `perplexity` and `ai_overviews`.                                                                                                                                                 |
| `tags`      | Your free-text labels.                                                                                                                                                                                                                       |
| `active`    | Only active prompts are scanned and counted against the plan's caps. Deactivating a prompt keeps the prompt and its history.                                                                                                                 |
| `source`    | Where the prompt came from: `ui`, `api`, `onboarding`, `csv`, `suggested` or `gsc`. It is set when the prompt is created and never changes. `api` means the prompt was created through this API, and `created_by_key_id` then names the key. |

`GET /orgs/{orgId}/prompts` returns active and inactive prompts. Filter with `brand_id`, `active`, `tag` and `country`. To change what a brand tracks, see [Sync prompts](/api/guides/sync-prompts).

## Identifiers and your own references

Organisation, brand and prompt ids are UUIDs. A path id that is not a UUID answers the same `404 not_found` as an id that is not in the organisation.

Dates are UTC calendar dates, written `YYYY-MM-DD`. Timestamps are RFC 3339.

`external_ref` lets you store your own identifier and get it back, so you do not need a mapping table.

| Where it lives                                                     | Set it with                                       | It comes back on                                                                                 |
| ------------------------------------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| An own brand                                                       | `external_ref` in `POST /orgs/{orgId}/properties` | Every brand read, `GET /orgs/{orgId}/brands/summary`, and `brand_external_ref` in webhook events |
| The link between a partner and an organisation (partner keys only) | `PATCH /orgs/{orgId}/link`                        | `external_ref` on `GET /orgs` and `GET /orgs/{orgId}`, and `org_external_ref` in webhook events  |

With an organisation key, the organisation's `external_ref` is always `null`.

A brand's `external_ref` is unique within the organisation, compared without regard to case. Provisioning a second brand with the same value answers `409 external_ref_exists`. See [Provision a property](/api/guides/provision-a-property).

## Only finalised results are served

Results, mentions and sources include a result unit only once it is final. After an answer is parsed, a later check can still withdraw a mention, so a unit that is still being processed is never served.

* **Preview runs are excluded.** Runs made for the preview during onboarding never appear.
* **One unit per prompt, platform, country and scan date.** A second scan on the same day replaces the earlier unit. It does not add a row.
* **`country` is always upper-case** on a unit, whatever case it was stored in.
* **Units arrive one by one.** A scan finalises its units as it goes, so a read made during a scan sees part of that day's units. See [reading results while a scan is running](/api/pagination#reading-results-while-a-scan-is-running).
* **A unit can disappear.** Deleting a prompt in the dashboard removes its results.

Scores work differently. The scores for a scan date are published when the scan has finished, not unit by unit.

### Mention fields

| Field       | Meaning                                                                                                                                                       |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mentioned` | Whether the answer named the brand.                                                                                                                           |
| `position`  | The brand's rank among the brands named in the answer, starting at 1. `null` when the brand was not mentioned.                                                |
| `sentiment` | The tone of the mention, from 0 to 100. `null` when it was not scored. A competitor's sentiment can be `null` by design. See [Sentiment](/metrics/sentiment). |

## Freshness: two different fields

Brand detail and the summary each carry a scan time and a change token. They answer different questions.

| Field                  | On                    | Meaning                                                                                                                                                                                                    |
| ---------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `last_scan_at`         | Brand detail, summary | When the brand's most recent result unit was finalised. `null` until the first one. Preview runs never count, and a result that is later withdrawn stops counting, so the value can stay on an older scan. |
| `last_scan_changed_at` | Brand detail          | A **change token**. It moves whenever anything about the brand's data or settings changes.                                                                                                                 |
| `changed_at`           | Summary               | The same change token, under a shorter name.                                                                                                                                                               |
| `last_scan_run_id`     | Summary               | The id of the result behind `last_scan_at`. Treat it as opaque.                                                                                                                                            |

The change token moves when:

* a result unit is finalised, withdrawn or deleted, or a scan is stopped early
* the brand's scores are published
* the brand's cited-source ratings are refreshed
* the brand itself is edited: its name, domain, geography, `external_ref` or archived state
* a competitor is linked or unlinked, or a linked competitor's name or domain changes
* a prompt is moved to or from the brand
* a result’s mentions or citations are corrected, for example after a brand alias changes
* the organisation's scheduling inputs change, such as its plan or scan frequency

<Tip>
  To ask "has anything changed?", poll the change token and compare it for equality. Do not poll `last_scan_at`: it misses every change that is not a new result. `GET /orgs/{orgId}/brands/summary` returns the token for every own brand in one list.
</Tip>

`next_scheduled_scan` on brand detail is the next UTC date the [scan schedule](/scans/scan-schedule) will queue a scan for the brand. It is `null` when nothing is scheduled: a plan with no schedule, an organisation set to on-demand scanning, a competitor, an archived brand, or an organisation that has not completed its first scan.

<Warning>
  `next_scheduled_scan` is computed when you ask, from the schedule and the clock. The change token does not cover it, so do not cache it until the token moves. Read it when you need it. It is a plan, not a promise: a scheduled scan can be delayed or fail.
</Warning>

## Scores

`GET /orgs/{orgId}/brands/{brandId}/scores` serves three different series. Points are in ascending date order.

| Request                                   | What `score` is                                                                                                    | `score_version`        | Brands accepted            |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---------------------- | -------------------------- |
| No `platform`, no `country`               | The headline [AIS Score](/metrics/ais-score), out of 100                                                           | Recorded on each point | Own brands and competitors |
| `?platform=chatgpt`                       | That platform's diagnostic composite. It is not the headline.                                                      | Always `null`          | Own brands and competitors |
| `?country=GB`, with or without `platform` | A per-market visibility index. Every point says so with `score_kind: "visibility_index"`. It is not the AIS Score. | Recorded on each point | Own brands only            |

The AIS Score cannot be split by country, so there is no per-country AIS Score. The per-market series is a separate index: do not expect its values to sum or average to the all-market point.

`?country=` must be a market Surfais scans. Any other two-letter code answers `400 validation_error` with `details[].code` set to `unsupported_country`. A supported market with no data returns an empty list.

Every point on the first two series also carries `composite_score`, a diagnostic composite. It is not the headline either. `score` can be `null`, for example for a competitor that several own brands track.

## `score_version` and `panel_version`

Each score point records two version tags.

* **`score_version`** names the scoring model that produced the point. It is stored with the point when it is scored. It is never the model in use today. It is `null` on older points that were scored before versions were recorded.
* **`panel_version`** names the measurement panel: the set of AI models that were queried. It changes independently of `score_version`, because the panel can be updated without changing the formula. It is `null` on older points too.

Treat both as opaque tags and compare them for equality only. **Compare two points only when both tags match.** A difference between points from two scoring models, or two panels, is not a movement in the brand's visibility.

The API applies this rule itself. `GET /orgs/{orgId}/brands/summary` reports both tags on `latest` and `previous`, and withholds the comparison when they disagree: `delta` is `null` and `delta_reason` says why.

| `delta_reason`           | Meaning                                                                           |
| ------------------------ | --------------------------------------------------------------------------------- |
| `score_version_mismatch` | Both points name a scoring model, and the two differ.                             |
| `score_version_unknown`  | At least one point records no scoring model, so the two cannot be shown to match. |
| `panel_version_mismatch` | The scoring model is the same, but the points were measured by different panels.  |
| `panel_version_unknown`  | At least one point records no panel.                                              |

The scoring model is tested first, so a pair that differs on both reports `score_version_mismatch`. `delta_reason` is `null` when `delta` is served. It is also `null` when `latest` or `previous` is missing, or when either `score` is `null`, because there is nothing to subtract.

`GET /orgs/{orgId}/brands/{brandId}/share-of-voice` follows the same rule. Its response names one `score_version` and `panel_version` pair, and a point from a known different pair is left out of the `ais_score` means only.

If you alert on score movement yourself, reset your baseline whenever either tag changes.

## Share of voice

`GET /orgs/{orgId}/brands/{brandId}/share-of-voice` returns the own brand and each of its competitors over a date window. The own brand comes first (`is_self: true`), then the competitors by `sov`, highest first.

Every entry is measured on the requested own brand's scans: the answers to its prompts. A competitor that two own brands track can therefore show different numbers in each brand's share of voice.

| Field        | Meaning                                                                                                                   |
| ------------ | ------------------------------------------------------------------------------------------------------------------------- |
| `sov`        | The entry's `mentions` as a percentage of every entry's `mentions`. `null` when nobody was mentioned.                     |
| `visibility` | The mean of the entry's daily presence over `days`: the share of that day's answers that named it. `null` with no days.   |
| `days`       | The days on which this own brand's prompts measured the entry. It is the denominator of `visibility`, not of `ais_score`. |
| `mentions`   | The entry's mentions over the window.                                                                                     |
| `ais_score`  | The entry's mean AIS Score over the window, from points that match the response's `score_version` and `panel_version`.    |

`ais_score` is `null` when no such point exists. It is also `null` for a competitor that several own brands track, and for an own brand that appears here as a competitor. The entry's `mentions`, `visibility` and `days` are still reported.

`sov` on `GET /orgs/{orgId}/brands/summary` follows the same rule on the brand's latest scored date: the brand's mentions divided by its own plus its competitors' mentions.

<Note>
  The API's `sov` is measured across the tracked field: the own brand and the competitors it tracks. The [Share of Voice](/metrics/share-of-voice) signal inside the AIS Score also counts brands you do not track, so the two numbers can differ.
</Note>

A field wider than 1,000 members, counting the own brand and its competitors, cannot be measured in one snapshot. The endpoint answers `500 field_too_large` instead of a share computed against part of the field. Do not retry it. Track fewer competitors for that brand, or read each brand's own series through `GET /orgs/{orgId}/brands/{brandId}/scores`.

## What a competitor id can do

| Endpoint                                                      | Given a competitor's id                                             |
| ------------------------------------------------------------- | ------------------------------------------------------------------- |
| `GET /orgs/{orgId}/brands/{brandId}`                          | Served. `competitors` is empty and `next_scheduled_scan` is `null`. |
| `GET /orgs/{orgId}/brands/{brandId}/scores` without `country` | Served. `avg_sentiment` is `null` by design.                        |
| `GET /orgs/{orgId}/brands/{brandId}/scores?country=`          | `400 validation_error`                                              |
| `GET …/mentions`, `GET …/share-of-voice`, `GET …/sources`     | `400 validation_error`                                              |

The refusal is a `400 validation_error` whose `details[].code` is `own_brand_only`. See [Errors](/api/errors).

A competitor has one score series, not one per own brand that tracks it. When several own brands track the same competitor, each point holds the numbers from whichever own brand's scan scored it last. The same caveat covers an own brand that another own brand tracks as a competitor. For a competitor's numbers in one own brand's field, use share of voice.

## Citations and sources

Citations are captured from 19 August 2026 onward. Earlier result units return an empty `citations` list, and `GET /orgs/{orgId}/brands/{brandId}/sources` counts nothing before that date.

Each citation on a result unit has a `url`, a `domain` and `cited`: `true` when the answer attributed the source, `false` when the source was retrieved but not attributed, and `null` when that is unknown. Citations are listed in the order they were retrieved.

`GET …/sources` aggregates citations by domain over a window:

| Field               | Meaning                                                                                                                                                                                                                                                          |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `domain`            | The cited hostname, lower-cased, with no scheme, `www.`, port or path.                                                                                                                                                                                           |
| `citation_count`    | Citations of the domain across the brand's result units in the window.                                                                                                                                                                                           |
| `cited_rate`        | The percentage of those citations that the answer attributed, out of the citations whose attribution is known. `null` when none is known.                                                                                                                        |
| `category`          | `owned`, `editorial`, `ugc`, `reference` or `other`, relative to this own brand. `owned` means this brand's own sites. `null` for a domain that has not been rated for this brand.                                                                               |
| `influenceability`  | A rating of whether this brand can influence the source, such as `competitor-owned` for another company's own site. Relative to this own brand, like `category`. `null` when the domain has not been rated for this brand, or had not been assessed when it was. |
| `opportunity_score` | The brand's latest opportunity score for the domain, from 1 to 3. `null` when there is none.                                                                                                                                                                     |

<Note>
  Treat `domain` as an opaque key, not as a hostname to resolve. It is stored as the answer cited it, so it can hold values that are not valid DNS names.
</Note>

## Countries and tags

**Countries are market tokens.** `countries` on a prompt, and `country` on a result unit, always come back upper-case. They are ISO 3166-1 alpha-2 codes for every prompt written through this API or the dashboard's country picker. Prompts created another way, such as during onboarding or by CSV import, can hold other values, such as `OTHER`. Treat a token as opaque and pass it back as you received it.

`?country=` on prompts, results and mentions is case-insensitive and accepts any token the API returns. A token that nothing holds returns an empty page, not an error. The one strict case is `?country=` on the score series, which must be a market Surfais scans. See [Countries & markets](/setup/countries).

**Tags are free text, and case is part of the tag.** `Launch` and `launch` are two different tags. `?tag=` matches exactly: it is case-sensitive and is not trimmed. A tag that nothing holds returns an empty page.

## Archived brands

A plan downgrade archives the own brands that the new plan no longer covers, and deactivates their prompts. An archived brand has `sunset_at` set.

* It is left out of `GET /orgs/{orgId}/brands` and `GET /orgs/{orgId}/brands/summary`.
* It stays readable by id, and so does its history.
* A write that adds tracking answers `409 brand_archived`, with `details.brand_id`. That covers creating or syncing prompts, editing or reactivating one of its prompts, linking a competitor and requesting a scan.
* Reductions still work: deactivating one of its prompts, and unlinking a competitor.

The same write succeeds once the brand is restored. See [Errors](/api/errors).

## Usage and caps

`GET /orgs/{orgId}` returns `usage`: what the organisation holds, against its plan's caps.

```json usage theme={null}
{
  "prompts": { "used": 42, "cap": 50 },
  "brands": { "used": 3, "cap": 5 },
  "countries": { "used": ["DE", "GB"], "cap": 3 },
  "competitors": { "cap": 10 }
}
```

| Field             | Counts                                                    |
| ----------------- | --------------------------------------------------------- |
| `prompts.used`    | Active prompts.                                           |
| `brands.used`     | Own brands that are not archived.                         |
| `countries.used`  | The distinct countries held by active prompts, as a list. |
| `competitors.cap` | The competitors allowed per own brand.                    |

A `cap` of `null` means the cap is not enforced for this organisation. The competitor cap is the exception: it applies to every organisation, so it is reported even where the other three read `null`.

The numbers above are examples. Read your own from the response, and see [Plans & limits](/billing/plans-limits) for what each plan includes.

A write that would exceed a cap answers `422` with a code that names the cap. See [Errors](/api/errors).
