# Epovest API for agents

Epovest is an AI visibility platform: it measures how AI assistants such as ChatGPT, Claude, Gemini, Perplexity, Mistral and Grok answer the questions a market asks (which names they cite, with which sources, how that changes over time), and gives businesses the levers to shape those answers and get recommended. This file is the machine-readable reference of the two integration surfaces:

- REST API v1, base URL: `https://api.epovest.com/v1`
- MCP server, endpoint: `https://mcp.epovest.com/mcp`

Both speak JSON only and share the same keys, permissions, limits and error envelopes.

The REST surface is also served as an OpenAPI 3.1 document, for SDK generators and API clients: https://epovest.com/docs/openapi.json

## Guides

Task recipes for an agent helping a person (set up tracking, read and explain results): https://epovest.com/docs/recipes.md

## Authentication

Every call carries an API key in the `Authorization` header:

```
Authorization: Bearer epo_...
```

Keys belong to an Epovest account and are created by its owner in the app at https://app.epovest.com/account/api-keys. The secret starts with `epo_` and is shown once, at creation; store it. A revoked key stops working immediately.

Scopes, chosen when the key is created:

- `read`: every key has it. Listing and reading results.
- `write`: optional. Creating and changing things: trackers, projects, the canon, surfaces, corroborations, logbook entries, quests, top-ups.

A call beyond the scope of its key answers `403 insufficient_scope`.

Writing also takes authority inside the account, on top of the scope. Creating or configuring a tracker, and everything about projects, the canon, surfaces, corroborations and discoveries, takes a manager; starting a tracker, pausing one and topping up take an owner. An API key always carries owner authority, so this never shows up with a key. It does on the one-click connection below, where the token carries the role of the person who approved it: an operator gets refused on `create_tracker`, a manager on `start_tracker` and `topup_credits`. The refusal reuses `403 insufficient_scope`, so on that path read it as "this person may not do this", not as a missing scope, and tell them which role the action needs.

The MCP server also accepts a one-click OAuth connection (OAuth 2.1, authorization code with PKCE), for chat connectors that speak it such as ChatGPT and claude.ai: the person signs in to Epovest and approves the access instead of pasting a key. Discovery starts from the unauthenticated response of https://mcp.epovest.com/mcp. The API key above stays valid in parallel for every other client. The recipes walk through both paths per client: https://epovest.com/docs/recipes.md.

## Rate limit

120 requests per minute per credential, sliding window. Beyond the budget the API answers `429 rate_limited` with a `Retry-After` header and the `X-RateLimit-Limit` and `X-RateLimit-Remaining` counters. MCP calls draw from the same budget: one MCP message counts as one request. Each API key carries its own budget; the tokens issued by the one-click connection below share a single budget per account, so several people connected that way draw from the same 120.

## Credits and reading

Epovest is prepaid: measurement debits a credit balance. Credits never expire, and reading stays open: an account reads its whole series whenever it comes back, however long it has been idle, and spends the balance it already has. A balance that runs out pauses measurement, never reading.

Starting a tracker also requires the balance to cover at least one survey; otherwise `402 insufficient_credits`, whose answer carries `top_up_url`, the page where the person adds credit. Read `GET /credits` to see the available balance, and `POST /credits/topup` to prepare a top-up the user approves on a hosted payment page.

## Conventions

- Dates are ISO 8601 with timezone; survey periods are plain dates, `YYYY-MM-DD`.
- Enumerations use their lowercase value: `chatgpt`, `weekly`, `hd`, `keyword_presence`.
- Monetary amounts are integers in minor units, suffixed `_minor`, next to a `currency` field. They are recalculated from the tracker configuration on every read, never stored.
- Errors always use the envelope `{"error": "slug", "message": "human sentence"}`.
- Paginated routes answer with `pagination` as `{page, per_page, total, pages}`. **`page` is clamped, not rejected**: ask for page 99 of 3 and you get page 3 back, with `pagination.page` saying 3. So stop on `pagination.page >= pagination.pages`, and never on "the list came back non-empty", which never stops.
- `next_survey_at` wants a date AND a time (`2026-08-01T09:00`, with or without seconds or offset). A bare date is refused. Elsewhere, `occurred_at` on the logbook does take a bare date.

## Errors

| HTTP | error | Meaning |
|---|---|---|
| 400 | `bad_request` | Malformed body or parameter; the message says what. |
| 401 | `unauthenticated` | Missing, unknown or revoked key. |
| 402 | `insufficient_credits` | Start refused: the balance does not cover one survey. The answer carries `cost_per_survey_minor` and `available_minor`, so you can name the shortfall to the person instead of sending them to look it up, and `top_up_url`, the page where they add credit: hand it to them. |
| 403 | `insufficient_scope` | The credential may not do this: either the key lacks the scope, or, on a one-click connection, the person lacks the role the action needs (manager, or owner to start, pause and top up). |
| 404 | `not_found` | Unknown for this account: a tracker, a support thread, a surface, a corroboration, a logbook entry, a quest, a source, or a domain that is not among the current suggestions of the tracker. |
| 404 | `unknown_project` | The `?project=` filter names no project of the account. |
| 405 | `method_not_allowed` | Right path, wrong verb. |
| 409 | `project_exists` | Creation refused: a project with this name already exists; the answer carries it. |
| 409 | `not_active` / `not_startable` / `not_editable` | The tracker state does not allow the action. |
| 409 | `keyword_cap_reached` | The tracker is at its keyword cap; the answer carries it as `keyword_cap`. |
| 409 | `duplicate` | Corroboration refused: this page is already in the registry of this project. The call was right, the state was ahead of it, and there is nothing to retry. |
| 409 | `corroboration_archived` / `page_not_readable` | Watching refused: the line is out of the registry, or nothing reads its page yet, so a scheduled pass would report nothing; the on-demand check must read the page first. `off` is always accepted. |
| 409 | `email_unverified` / `no_billing_contact` | Top-up refused: the account owner must verify their email in the app first, or the account has no owner to bill. |
| 409 | `no_canon` | Verification refused: the project has no canon yet, so a canon cell has nothing to restate. Set the canon, then tick again. |
| 409 | `no_member` | Account settings refused: the account has no active owner whose language and time zone could be set; its members are fixed in the app. |
| 422 | `validation_failed` | Invalid tracker configuration; `violations[]` lists field and message. |
| 422 | `invalid_name` | Project creation refused: the name is empty or too long. |
| 422 | `invalid_canon` | A canon field is empty where it must not be, or past its cap. |
| 422 | `invalid_type` / `invalid_surface` | Surface refused: unknown type (the message lists the catalogue), or an unusable url. |
| 422 | `invalid_checklist` | Unknown checklist item, unknown language, or a language on a site-wide item; the message says what to send, and not a single tick is written. |
| 422 | `unplaceable_url` / `own_domain` / `invalid_corroboration` / `invalid_monitoring` | Corroboration refused: the address has no registrable domain, or it is the site of the brand (a surface, not a corroboration), or a field is unusable, a date outside `YYYY-MM-DD` or a cadence outside its catalogue included. |
| 422 | `invalid_category` / `invalid_source` / `invalid_date` / `invalid_entry` | Logbook refused: unknown category, unknown `?source=`, unparseable `occurred_at`, or an empty label. |
| 422 | `invalid_quest` / `invalid_status` / `invalid_project` | Quest refused: an empty or overlong title, an unknown `?status=` (the message lists the catalogue), or a `project_id` that is not a UUID. |
| 422 | `invalid_engine` / `invalid_sort` / `invalid_direction` | Atlas refused: unknown engine, sort or direction; the message lists the accepted values. |
| 422 | `invalid_account` | An account setting is unusable: an empty or overlong legal name, an unknown country code, too many address lines, a VAT number outside the intra-EU shape or without an EU billing country, a language outside the catalogue, or a time zone that is not an IANA identifier. The message names the field. |
| 422 | `below_min_topup` | Top-up refused: the amount is under the floor, given back as `min_topup` and `min_topup_minor`. |
| 429 | `rate_limited` | Budget exhausted; retry after `Retry-After` seconds. |
| 500 | `server_error` | Something broke on our side. Retry; if it persists, tell us through `POST /support/messages`. |
| 503 | `service_unavailable` | A backend is unreachable, while starting a tracker, reading or topping up credits, or reaching the support channel: retry later. |

## REST endpoints

| Method | Path | Scope | Purpose |
|---|---|---|---|
| GET | `/trackers` | read | The trackers of the account, current versions. Filter: `?project=` (a project UUID, or `default` for trackers without a project). |
| POST | `/trackers` | write | Create a tracker, in draft. Manager or above. |
| GET | `/projects` | read | The projects of the account: the folders trackers are filed under. |
| POST | `/projects` | write | Create a project; `409 project_exists` returns the existing one to reuse. |
| PATCH | `/projects/{id}` | write | Rename a project; `409 project_exists` returns the other project already going by that name. Manager or above. |
| GET | `/projects/{id}/canon` | read | The brand canon of a project and the history of its revisions, each version with the fields it touched, before and after. |
| PATCH | `/projects/{id}/canon` | write | Revise the brand canon of a project: any actual change records the next canon version. |
| GET | `/projects/{id}/link-targets` | read | The addresses of the brand a third-party link can point to, reported by `POST /corroborations/{id}/verify`: `derived` from the canon website and the surface registry, plus the `free` ones set on the project. |
| PUT | `/projects/{id}/link-targets` | write | Set the free addresses of the brand, the ones that are neither the canon website nor a surface. The list replaces the previous one and the answer returns what the project now holds. Manager or above. |
| POST | `/projects/{id}/archive` | write | File a project away, or bring it back with `{"archived": false}`. The trackers filed under it keep measuring. Manager or above. |
| GET | `/projects/{id}/surfaces` | read | The surface registry of the project: the pages about the brand where the customer has the final say, with the derived canon status of each surface and the measured authority of the domain it sits on. |
| POST | `/projects/{id}/surfaces` | write | Register a surface; it is born never aligned. |
| PATCH | `/surfaces/{id}` | write | Update the registry sheet of a surface; only the fields you send change, and no journal line is ever written here. |
| PATCH | `/surfaces/{id}/checklist` | write | Verify, set aside (reason due) or clear checklist cells; the aligned status derives from the canon cells. Only the cells you send change. Manager or above. |
| POST | `/surfaces/{id}/checks` | write | Add a check of your own to the checklist of a surface; it is required for that page to count as aligned. Manager or above. |
| PATCH | `/surfaces/{id}/checks/{key}` | write | Correct a check of your own: its label, and whether it restates the canon. The key never moves, so renaming keeps every tick. Manager or above. |
| DELETE | `/surfaces/{id}/checks/{key}` | write | Take a check of your own out of the checklist; it and its cells are kept, and `restore` brings both back. Manager or above. |
| POST | `/surfaces/{id}/checks/{key}/restore` | write | Bring a check of your own back to the checklist, with the cells it had. Manager or above. |
| DELETE | `/surfaces/{id}` | write | Take a surface out of the registry; its sheet and its alignment journal are kept, and `restore` brings it back. Manager or above. |
| POST | `/surfaces/{id}/restore` | write | Bring a surface back to the registry, with its sheet and its journal. Manager or above. |
| POST | `/surfaces/{id}/convert-to-corroboration` | write | Move the page to the corroboration registry: someone else has the final say on it. Manager or above. |
| GET | `/projects/{id}/corroborations` | read | The corroboration registry of the project: the pages about the brand where someone else has the final say, each with its exact address. |
| POST | `/projects/{id}/corroborations` | write | Record a corroboration from the exact address of the page. `422 unplaceable_url`, `422 own_domain`, `409 duplicate`. Manager or above. |
| PATCH | `/corroborations/{id}` | write | Update the sheet of a corroboration; only the fields you send change, and the url always carries its source along. Also carries `monitoring`, the cadence of the automatic watching. Manager or above. |
| POST | `/corroborations/{id}/verify` | write | Read the page now, on demand and at no cost: one pass refreshes the two findings of the line, on the mention and on the link to the brand. Manager or above. |
| POST | `/projects/{id}/corroborations/monitoring` | write | Set the watching cadence of the whole registry in one move; `off` stops it. Arms the live readable lines and answers with the count covered. Manager or above. |
| POST | `/corroborations/{id}/archive` | write | Record that the page is gone, or put it back live with `{"archived": false}`. Idempotent, and nothing is deleted. Manager or above. |
| POST | `/corroborations/{id}/convert-to-surface` | write | Move the page to the surface registry: the customer has, or takes, the final say on it. Manager or above. |
| GET | `/projects/{id}/corroboration-candidates` | read | Pages an engine showed the brand on, read from the excerpts it returned. Nothing is fetched, nothing is stored. |
| POST | `/projects/{id}/corroboration-candidates/dismiss` | write | Refuse a candidate by its `url`: never proposed again for this project. Accepting has no route, it is the ordinary recording. Manager or above. |
| GET | `/projects/{id}/logbook` | read | The logbook of the project: the dated moves (suite events and manual entries) that annotate the citation curves. Filters: `?source=`, `?category=`, `?quest_id=` (the dated trail of one quest), `?deleted=only`. |
| POST | `/projects/{id}/logbook` | write | Record an action in the logbook; `occurred_at` is the date the action happened, past dates welcome. Send `quest_id` to record it against a quest. Idempotent on `(project, quest_id, label, occurred_at)`: a replay returns the entry already recorded, with `200`. |
| PATCH | `/logbook/{id}` | write | Correct a manual entry; only the fields you send change. `quest_id` files it under a quest, `null` files it back under the project alone. |
| DELETE | `/logbook/{id}` | write | Take a manual entry out of the logbook; `restore` brings it back. |
| POST | `/logbook/{id}/restore` | write | Bring an entry back to the logbook, with its annotation. |
| GET | `/quests` | read | The quest file: what there is to do, every active project by default. Each quest carries `journal_entries` and `last_entry_at`, the actions recorded against it. Filters: `?project_id=`, `?status=` (`open` by default). The open file also carries `pending`, the files the measurement holds for review. |
| POST | `/quests` | write | Add a quest; `project_id` travels in the body. Idempotent on `(project, title)` while the quest is open: a replay returns the open quest, with `200`. |
| PATCH | `/quests/{id}` | write | Correct a quest; only the fields you send change. The status has its own moves below. |
| POST | `/quests/{id}/complete` | write | Set the quest done: the move happened. Replayable; the last move is what the file remembers. |
| POST | `/quests/{id}/dismiss` | write | Set the quest aside. Replayable, and nothing is deleted: the quest stays in the closed history. |
| POST | `/quests/{id}/reopen` | write | Put a closed quest back in the file, sheet and dates intact, `closed_at` cleared. Replayable. |
| GET | `/trackers/{id}` | read | One tracker, by id. A retired version still answers here with `status: "retired"`, so after an edit that made a new version, follow the new id rather than the one you had cached. |
| PATCH | `/trackers/{id}` | write | Update the configuration; only the fields you send change. Manager or above. |
| GET | `/trackers/{id}/discoveries` | read | The discovered keyword suggestions of the tracker, plus `dismissed`, the domains it has set aside. |
| POST | `/trackers/{id}/discoveries/accept` | write | Accept a suggestion, or a batch: the domain becomes a tracked keyword. Body carries `domain` (a string) or `domains` (a list). |
| POST | `/trackers/{id}/discoveries/dismiss` | write | Dismiss a suggestion, or a batch: never proposed again on this tracker. Body carries `domain` or `domains`; answers the same on a domain already dismissed. |
| POST | `/trackers/{id}/discoveries/restore` | write | Restore a dismissed domain, or a batch: eligible for discovery again. Body carries `domain` or `domains`. |
| POST | `/trackers/{id}/start` | write | Start or restart the measurement against the credit balance. Owner only. |
| POST | `/trackers/{id}/survey` | write | Run one survey NOW, on top of the schedule: the anchor, the frequency and the date of the next scheduled survey are untouched. On an `on_demand` tracker, this is where every survey after the first one comes from. Answers `201` with the survey, which carries `on_demand: true` and `requested_at`; its point joins the series under the same period, told apart by `on_demand`. Debits the balance like any survey, auto-reload included, and no cap on how many. While one is still running, answers `200` with THAT survey instead of starting a second one, since a second would measure the same instant twice. Refusals name the move that unblocks: `409 tracker_not_active` (start it first), `409 tracker_archived` (bring it back first), `409 already_starting` (another call is starting one right now, nothing spent), `402 insufficient_credits` with `cost_per_survey_minor`, `available_minor` and `top_up_url`. A refusal never pauses the tracker: the schedule asked for nothing and keeps running. Owner only. |
| POST | `/trackers/{id}/pause` | write | Pause the measurement; the series is kept. Recorded as a `manual` pause, which no top-up ever resumes. Owner only. |
| POST | `/trackers/{id}/archive` | write | File a finished tracker away, or bring it back with `{"archived": false}`. A tracker still measuring pauses in the same call. The sheet and the score series are kept. Owner only. |
| GET | `/trackers/{id}/surveys` | read | The surveys of the tracker chain, newest first. Each one is `{id, period_start, status, panel_version, started_at, completed_at, billed_analysts}`: `panel_version` is the tracker version that ran it (the series spans versions), and `billed_analysts` lists the AI analysts reserved on that survey (snapshotted at creation, what the credit hold covered; empty when none), the one place the API tells which analysts a survey engaged. |
| GET | `/trackers/{id}/scores` | read | The score series, one row per analyst, keyword, engine, version and period. Filters: `?analyst=` (`keyword_presence`, `share_of_voice`, `sentiment`, the analysts that carry a score; `custom_prompt` yields a text note per response instead, so `?analyst=custom_prompt` answers `400` rather than an empty list) and `?engine=`. |
| GET | `/trackers/{id}/responses` | read | The raw answers of one engine, `?engine=` required, paginated with `page` and `per_page` (25 by default, up to 100). Only answers that succeeded, in surveys that completed: a survey still running shows nothing here. Filters: `q`, `question`, `survey` (a survey id of this chain; an unknown one answers `400`), `tone` (`positive`, `neutral` or `negative`: the answers where the sentiment analyst judged a keyword mention with that tone, so a week's negative answers come back in one call; an unknown tone answers `400`); `include=raw` adds the full engine payload. Each answer also carries `uncited_sources`, the pages the engine read to write it without citing them, and `searched`, whether the engine went to the web to write it (`null` when undetermined, for answers measured before the field existed). |
| GET | `/competitor-scans` | read | The competitor scans of the account, newest first, without their list of places. Filter: `?project_id=`. |
| POST | `/competitor-scans` | write | Prepare a competitor scan on a basket of up to 5 companies, as a draft: nothing is charged, and the derived questions come back in the answer. Three companies or more and the places rank by recurrence, by how many of the basket each one covers. |
| GET | `/competitor-scans/{id}` | read | One scan and its accumulated list of places, each with its way in, its recurrence across the basket, and whether the account is already there. |
| POST | `/competitor-scans/{id}/start` | write | Arm the scan: the engine is asked and the balance is debited by `estimated_cost_minor`. Replaying answers with the scan already armed. |
| PATCH | `/competitor-scans/{id}` | write | Send `questions` to keep and reword the ones a draft will ask, one left out being removed. Set `rescan_cadence` to `weekly` or `monthly` so the scan runs again on that cadence, or to `null` to stop it. States a state, replays safely both ways. |
| GET | `/sources` | read | The Atlas: the sources the AIs cite when they answer your trackers. Filters: `?domain=`, `?engine=`, `?sort=`, `?dir=`, `?project_id=`; paginated with `page` and `per_page` up to 100. |
| GET | `/sources/{id}` | read | One source, read by its id: the same entry the list carries. |
| GET | `/sources/{id}/channels` | read | The channels behind a source: who published the videos the AIs cited on your questions. |
| GET | `/account` | read | The settings of the account: legal name, billing country, postal address, intra-EU VAT number, plus the language we write to it in and the time zone its hours are shown in. |
| PATCH | `/account` | write | Change those settings; only the fields you send change. Owner only for the billing identity. |
| GET | `/credits` | read | The prepaid credit balance: available, reserved by surveys in progress, plus the monthly pace of the account and the top-up amounts derived from it. |
| GET | `/usage` | read | What the account has spent: the ledger entries, plus the same amounts by project and by tracker. Filters `month`, `type`, paginated. |
| POST | `/credits/topup` | write | Prepare a top-up and return the hosted payment link; nothing is charged by the call. Owner only. |
| POST | `/support/messages` | read | Write to the support team: open a thread, or reply to one with `thread_id`. |
| GET | `/support/threads` | read | The support threads of the account, most recent first. |
| GET | `/support/threads/{id}` | read | One thread with its messages, answers of the support team included. |

### Creating a tracker

`POST /trackers` takes a JSON body; validation and messages are those of the app configurator.

| Field | Type | Notes |
|---|---|---|
| `title` | string, required | Display name. |
| `prompts[]` | strings, required | The questions asked to the engines at every survey, phrased as a customer would ask them. |
| `engines[]` | strings, required | `chatgpt`, `claude`, `gemini`, `perplexity`, `mistral`, `grok`. A check is priced per engine, in USD: `chatgpt` 0.10, `claude` 0.20, `gemini` 0.10, `perplexity` 0.10, `mistral` 0.10, `grok` 0.20. Claude and Grok read more sources per answer, and their check carries that. The amount for the whole tracker comes back as `cost_per_survey_minor`. |
| `frequency` | string, required | `monthly`, `weekly`, `daily`, or `on_demand`. On `on_demand` the tracker runs on no schedule: starting it runs one survey, and every survey after that comes from `POST /trackers/{id}/survey`. That is the value for a one-off reading, and for anyone who would rather decide each time. |
| `resolution` | string, required | Repetitions of every question per engine and survey: `hd`=1, `full_hd`=3, `4k`=6, `8k`=9. |
| `keywords[]` | strings or objects | Names to detect in the answers, as `"Name"` or `{"keyword": "Name", "favorite": true}`. |
| `analysts[]` | strings | `keyword_presence` and `share_of_voice` are deterministic; `sentiment` and `custom_prompt` are AI analysts billed per analyzed response. |
| `custom_prompt` | string | The instruction of the `custom_prompt` analyst; required with it. |
| `discovery` | boolean | Suggest new keywords spotted in the answers. It needs keywords to work from: sent as `true` on a tracker with none, it is quietly recorded as `false`, with no error. Send `keywords[]` alongside, and check `discovery` in the answer rather than assuming it took. |
| `notify_on_survey` | boolean | Email the account owner and managers each time a survey closes with fresh data, so the results reach them on their own. On by default; send false to keep this tracker silent. |
| `next_survey_at` | string | When the first survey runs, ISO 8601, strictly in the future; read as UTC without an offset. Omitted: the first survey runs at start, and starting the tracker launches it there and then. Later surveys keep that day and time at the pace of the frequency. Sent on a PAUSED tracker, it is kept and read back like anywhere else, and it is what makes the next start wait instead of surveying right away. It goes with a frequency that has a pace: sent alongside `on_demand`, it answers `422`, and `POST /trackers/{id}/survey` is the move it was reaching for. |
| `project_id` | string | The project the tracker is filed under: the UUID of a project of the account (`GET /projects`), or `default` for none. Omitted: the tracker files under Default. |

A created tracker is a draft: it measures nothing until started. Starting it launches its first survey immediately, unless it carries a chosen `next_survey_at` still in the future, in which case the grid starts on that date. That first survey runs on every frequency, `on_demand` included. A tracker in the responses carries `next_survey_at`: when its next survey runs (a past instant means within the hour; null when there is no date to give: a tracker whose measurement is stopped and which carries no chosen date, one whose first survey runs at start, a retired version, or an `on_demand` tracker, whose next survey is a call rather than a date). A paused tracker with a date set for later carries that date: it says when the measurement comes back once it is started. On `on_demand`, `monthly_estimate_minor` is `0`, which is the true figure: nothing runs on a pace, so `cost_per_survey_minor` is what one reading costs.

A paused tracker also carries `pause_reason`, null on any other status:

| Value | Meaning |
|---|---|
| `manual` | The measurement was stopped on purpose, through the app or `POST /trackers/{id}/pause`. It stays paused until someone starts it again: a top-up never resumes it. |
| `credit_exhausted` | The balance no longer covered a survey when it was due. A top-up resumes the tracker on its own, so `POST /credits/topup` is the fix here, not `POST /trackers/{id}/start`. |

### Updating a tracker

`PATCH /trackers/{id}` takes the same fields as the creation, all optional: only the fields you send change, and a sent list replaces the previous one entirely. Keywords, analysts and the title apply in place. Changing the prompts, engines, frequency or resolution of a version that has measured creates a new version: the score series continues across versions, and the response carries the new tracker with its new id, next to `version_created`. A version that has not measured yet is amended in place instead and keeps its id, so a second edit before the first survey leaves one version rather than a trail of empty ones, and `version_created` is `false`.

A paused tracker takes all of it, the collection settings included. The call writes the configuration and nothing else: no survey runs, no credit is reserved, the tracker stays paused with its `pause_reason`, and what you set takes effect at the next `POST /trackers/{id}/start`. That is how a tracker moves to fewer engines or a slower frequency before it costs anything more: edit it while it is paused, read `cost_per_survey_minor` back to confirm the new figure, then start it. A version created this way is born paused as well, so nothing measures until the person says so.

The one refusal left is a retired version, which is revision history and takes nothing: `409 not_editable`.

### Filing a tracker away

A tracker has an end: a campaign that closes, a brand that is sold, a trial that ran its course. `POST /trackers/{id}/archive` files it away, and `{"archived": false}` brings it back; the body is the STATE you want, omitted it files away, and both moves answer the same when replayed. `archived` and `archived_at` ride along every tracker in the reads, so an agent tells at a glance what is still followed and what is filed: `GET /trackers` lists the ones still followed first, then the ones filed away.

Filing away is a move of the FOLDER, next to the measurement rather than inside it: a filed tracker keeps its status. One that was still measuring pauses in the same call, so the spending stops there and the answer carries `status: "paused"` to say it. Bringing it back returns the tracker as it was, and `POST /trackers/{id}/start` is what restarts the measurement, so nothing spends again without being asked. Start on a filed tracker answers `409 tracker_archived` and names that move.

The sheet and the score series stay whole: `GET /trackers/{id}/scores` and `GET /trackers/{id}/responses` answer as they do after a pause, so months of measurement stay readable long after the tracker is filed.

### Projects

Projects are the folders an account files its trackers under: one per client for an agency, one per website or brand for an organization. Pure organization, editable at any time: no effect on measurement, series or billing. A tracker belongs to at most one project; without one it lives under the virtual `Default` project (no row exists for it: `project` is simply `null` on the tracker). Every tracker carries its project as `{"id": ..., "name": ...}` or `null`.

`GET /projects` lists them as `{id, name, canon, canon_version, archived, tracker_count, created_at}`. `canon` carries the brand identity sheet, as `{one_liner, short, long, category, language, perks, website, legal_name, address, phone, whatsapp, email}`: the text every publication reuses as is, so all mentions tell the same entity. `perks` is the ordered list of distinctive claims, written in the canonical language, meant to be hammered so the engines bring them up on their own: facts that hold and can be corroborated, never superlatives. The facts (`website`, `legal_name`, `address`, `phone`, `whatsapp`, `email`) are language-NEUTRAL and identical everywhere: the AIs cross-read them, and contact details that contradict each other make a blurry entity. `website` is the canonical address that identifies the entity, ONE URL only (a bare domain is accepted and completes to https, a trailing slash is dropped so the same site never records two versions). The other addresses of the brand, documentation, application, blog and social accounts included, are NOT in the canon: they are the surface registry, and derive from it. `legal_name` is the registered name of the company that operates the brand, with its jurisdiction when it is stated: the name the AIs cross-read to anchor the entity, and the one a directory listing and an `Organization.legalName` block copy. The canon lives in ONE language, its canonical language (`language`, a short code like `"en"` or `"pt-br"`; null when never declared): it is never translated, localized wordings on the pages are outputs, and the canonical language settles every language call (the llms.txt of a multilingual site is written in it). One project per BRAND, never per language. The canon is versioned: `canon_version` is the number of its current version (null when never set). The folders still in use come first in the list, then the ones filed away (`archived` true). An archived project keeps its trackers and stays filterable; it just no longer accepts new filings from the app configurator. `POST /projects` with `{"name": ...}` creates one (optionally with `canon_one_liner`, `canon_short`, `canon_long`, `canon_category`, `canon_language`, `canon_perks`, `canon_website`, `canon_legal_name`, `canon_address`, `canon_phone`, `canon_whatsapp`, `canon_email`; carried at creation, the canon is recorded as version 1); when the name is already taken (case, accents and spacing do not make two different names), `409 project_exists` carries the existing project, so reuse its id instead of duplicating. `PATCH /projects/{id}` with `{"name": ...}` renames one: the folder keeps its id, its canon, its trackers, its surfaces and its logbook, and the name shown is the only thing that changes, which is why it does not go through the canon, where every change records a new version. The name stays unique per account, so one another project already goes by answers `409 project_exists` with that project; since case, accents and spacing do not make two names, a project can always take back its own capitalisation, and sending the name it already carries answers the same. A project filed away renames like any other. File a tracker with `project_id` on `POST /trackers` or `PATCH /trackers/{id}`; moving a tracker between projects never touches its series.

`GET /projects/{id}/canon` reads the canon of one project as `{project_id, project, canon, canon_version, history}`. `canon` is the current wording, under the keys listed above, and `canon_version` its number. `history` carries every version, newest first, each as `{version, author, created_at, initial, changes}`, and `changes` is what that version touched: one entry per field, `{field, before, after}`, with the raw values, so `perks` comes back as its ordered list and `language` as its short code. `before` is the wording the version replaced, which is the string to find on a page that still carries it; on version 1 (`initial` true) there is nothing before, so each `before` is the empty value of its field. Read it to RE-PROPAGATE a revision: `GET /projects/{id}/logbook` says which keys moved and when, this says what they moved from and to. On a project whose canon is not posted yet, `history` is empty and `canon_version` is `null`.

`PATCH /projects/{id}/canon` revises the canon, field by field: a sent field replaces the current wording (an empty string or `null` clears it), an omitted field is kept as is, and a field sent as anything other than text answers `422 invalid_canon` rather than clearing it. Clearing has exactly two deliberate forms, so a number or an object where a string belongs is read as a mistaken call and changes nothing. `canon_language` follows the same PATCH semantics: declaring or changing the canonical language is a revision like any other. On a project whose canon is not posted yet, send it with at least one wording: the canon IS the wording, so the language on its own answers `422 invalid_canon` instead of recording a version that says nothing. Any actual change records the next canon version, immutable (`version_created` true in the answer); sending identical wording records nothing. The canon is meant to stay stable: revising it is a rare and deliberate move, so confirm with the person before calling. Fields and caps are those of the creation (`422 invalid_canon` beyond).

`POST /projects/{id}/archive` files a project away once the folder has served its purpose: a client that left, a brand that was sold. `{"archived": false}` brings it back; the body is the STATE you want, omitted it files away, and both moves answer the same when replayed. The project moves to the end of `GET /projects` with `archived` true.

Nothing cascades: the trackers filed under it keep their status, keep measuring and keep showing up in `GET /trackers`, because filing a folder away is not a reason to stop measurement someone paid for. The answer carries `tracker_count`, how many are still filed under it, so you can go on with `POST /trackers/{id}/archive` on each one when that is what the person meant. `Default` is where trackers without a folder live: it has no id, so this route always takes the UUID of a project of the account.

### Surfaces

Surfaces are the official pages of a brand: the pages about it where the customer has the FINAL SAY on the content: the website, the GitHub organization, the LinkedIn page, the X account, the YouTube channel, the Wikidata entity, directory listings, app store pages. The split with corroborations is control, never who typed the page or who paid for it: a page the customer can change is a surface, wherever it is hosted. The registry tracks, per project, those pages and whether each one is in phase with the versioned canon.

`GET /projects/{id}/surfaces` lists them as `{id, project_id, type, url, label, languages, notes, status, alignment, checklist, domain_authority, deleted, deleted_at, created_at}`. `status` is derived on read, never stored, and it derives from the CHECKLIST CELLS that hold it, the ones listed in `checklist.required`: `aligned` (every required cell not set aside is verified at the current wording), `needs_update` (a verification is missing or stale), or `never_aligned` (none exists yet). There are three statuses and never a fourth: setting aside the LAST canon cell is refused, because a page that carries none of the canon is not a surface: turn it into a corroboration if someone else has the final say on it, or take it out of the registry. A verification of a canon item is stamped with the canon version whose wording it restated, and it stays fresh until that WORDING moves. The comparison covers everything a page reuses: the four texts, but also the perks, the website, the address, the phone, the whatsapp and the email. Changing the brand domain or a phone number therefore puts the whole registry back in the queue, which is the point: those are exactly the details the pages repeat. It is on the wording, not on version identity, so a revision that touches none of them, such as declaring the canonical language, perishes nothing. `alignment` carries the last journal line as `{canon_version, aligned_at, author}`, or `null`. `checklist` carries `{items, site_items, kinds, required, custom, state, cells, progress}`: the template of the type (stable keys such as `title_tag` or `llms_txt`) followed by the checks the customer added of their own, the items that are site-wide, the kind of each item (what PERISHES its tick: `canon` when it restates the canon wording and the tick perishes with it; `presence` when the tick holds, such as `site_link`), `required` (what HOLDS the status: the canon items of the template plus every check of the customer, whatever its kind, so read this list rather than deducing from `kinds`), `custom` (the definitions of those checks, `{key, label, scope, restates_canon, deleted, deleted_at, created_at}`, the ones taken out included), the flat view of verified items, the detail of each cell (`{state, canon_version, fresh, at, by}` when verified, `{state, reason, at, by}` when set aside) and the counts `{done, total, dismissed}`: what holds at the current wording, over the cells that are not set aside, with the set-aside ones counted apart, never hidden. A site-wide item (`site_items`: the `llms_txt`, one file at the root of the site, written in the canonical language of the project) is one cell for the whole site, under the `site` column; every other item has one cell per language (`default` when the surface lists none).

Each surface also carries `domain_authority`, the join with the Atlas: what the AIs grant the DOMAIN the page sits on, as `{domain, source_id, engines, last_detected_on}`, always present, in the list as on the answer to a write. `domain` is the registrable domain read from the address by the same normalisation as the map, so a subdomain folds into it and a hosting platform counts as one place. `source_id` is its entry on the map, read with `GET /sources/{id}`. `engines` carries one `{engine, aa}` per AI that cited that domain over the last 30 days: the AI Authority, 0 to 100, where 100 is the source that AI cites the most. The unit is the DOMAIN and a surface is one address on it, so the figure says that PLACE is read, which is why the domain travels next to it rather than being left implicit. An AI absent from `engines` has not cited the domain lately, which is not a zero, and a `source_id` of `null` says the domain is not on the map of this account, which lists the sources your own surveys surfaced and grows as you measure more. `?sort=` orders the registry: `registry`, the default, by shown name, the order where a known line is found again; `authority` by that measured authority, the most read first, so the order of the work follows a measured fact. Any other value answers `422 invalid_sort` and names both. It orders and never filters: every surface stays in the answer, the domains off the map closing the list.

`POST /projects/{id}/surfaces` registers one from its `url` alone (absolute http(s)): the `type` (one of `website`, `github`, `linkedin`, `x`, `youtube`, `wikidata`, `directory`, `app_store`, `other`) and the `label` are DERIVED from the address, so an agent has no superfluous decision to make. The type reads the canon rather than guessing: a page whose registrable domain is the canon website is `website`, a known place carries its own kind, and any other host is `other`, which carries the generic checklist. That is why a listing on a third-party directory is never asked for an `llms.txt`. Send `type` or `label` only to correct a derivation; `languages` (short codes like `"en"` or `"pt-br"`) and `notes` stay optional. `PATCH /surfaces/{id}` updates the sheet; only the fields you send change, and a sent `languages` list replaces the previous one. `label` and `notes` clear on an empty string or `null`; the `url` never clears, so `null` leaves it as it was. Any of them sent as something other than text answers `422 invalid_surface` and changes nothing. Languages ARE the columns of the checklist, so a sheet edit can move which cells are read: declaring the language of a single-language page renames its column and carries its cells along, and when the change leaves no single destination (a language dropped from a page that keeps others, one column split into several) the columns that leave the model come back in `dropped_checklist_columns`, always present, as `[{language, verified, dismissed}]`. Their cells stay stored, so declaring that language again brings them back. The `status` of the answer is derived from the cells that read at that moment, never from the sheet: read it back rather than assuming it held.

`PATCH /surfaces/{id}/checklist` moves cells: `ticks`, a list of `{item, language, done, dismissed, reason}`. `item` is one of the `checklist.items`, the template of the type plus the checks the customer added of their own, which tick here exactly like the rest; a site-wide item (`checklist.site_items`) is one cell for the whole site, so send it without a language; `language` is one of the columns of `checklist.state`, and can be left out when the surface has a single one. Three moves per cell. VERIFY (`done` true, or nothing sent): the item is constated on the page; a canon-kind item is stamped with the current version and wording, so the canon must be set first, otherwise `409 no_canon`. SET ASIDE (`dismissed` true plus a short `reason`, due): the item does not apply on THIS surface, an author bio, a field the platform does not offer; it leaves the denominator without ever being hidden, survives canon revisions, and is undone by sending `dismissed` false. CLEAR (`done` false): the cell returns to blank. Only the cells you send change: nothing is cleared by omission. The batch is all or nothing: an unknown item, an unknown language, a set-aside without reason or a contradictory move answers `422 invalid_checklist`, says what to send, and writes not a single cell, so a verification pass is never half recorded. Most items are yours to check first hand, a title tag, JSON-LD or an llms.txt all read with a simple fetch: verify the page, then record what you saw. The ALIGNED status derives from the cells of `checklist.required`: verifying the last one aligns the surface and dates the event in the journal on its own. And setting aside the LAST canon cell is refused, because a page that carries none of the canon is not a surface: turn it into a corroboration if someone else has the final say on it, or take it out of the registry, which answers `409 not_a_surface`: a state conflict, not a malformed move, so it is not worth retrying.

`POST /surfaces/{id}/checks` adds a check of your own to the checklist of one surface: a requirement the customer holds on THAT page, in their words, REQUIRED for it to count as aligned. `label` is the only field due; `scope` is `language` by default (one cell per language of the surface) or `site` (one cell for the whole site), and `restates_canon` is the one judgement to make: true when the check restates the WORDING of the canon, so its verification perishes when the wording moves; false, the default, when it constates anything else, so the tick holds until someone clears it. Sending the same label again returns the check already there and brings it back from the trash if it slept there, so a replay never duplicates (`201` on creation, `200` otherwise). The key derives from the label at birth and then freezes, being the coordinate of the cells already recorded, which is why `PATCH /surfaces/{id}/checks/{key}` renames a check without losing a single tick. `DELETE /surfaces/{id}/checks/{key}` takes one out: it leaves the checklist and stops holding the page short of aligned, while the definition and its cells are kept, read back in `checklist.custom` with `deleted` true, and `POST /surfaces/{id}/checks/{key}/restore` brings both back. A surface holds up to twenty checks of your own; a full checklist answers `409 check_cap_reached`, and a label already taken `409 duplicate_check`.

There is no route that declares a surface aligned, and that is the design: the status derives from the cells, so it is EARNED cell by cell through the checklist PATCH, which takes as many cells as you want in one call. Verifying the last canon cell aligns the surface and freezes the journal line on its own, dated by that verification.

`DELETE /surfaces/{id}` takes a surface out of the registry, for a page that is gone (account closed, listing removed) or a line that had no place there. The surface leaves the registry and everything derived from it: the canon statuses, the Quests file, the counts, the curve annotations. Its sheet and its alignment journal are kept as they are, and `POST /surfaces/{id}/restore` brings the surface back with them, so a line taken out by mistake costs nothing. Both moves are idempotent, and `deleted_at` carries the day the surface left. The move that means working on a surface, `checklist`, answers `409 surface_deleted` while it sits out of the registry: bring it back first. Updating the sheet stays open, so an address can be fixed before the return. `GET /projects/{id}/surfaces?deleted=only` lists what is out, most recently taken out first; any other value of `deleted` answers `422 invalid_filter`. Without it, the read carries the registry alone.

`POST /surfaces/{id}/convert-to-corroboration` moves the page to the corroboration registry, for a line filed on the wrong side or a page whose final say moved to someone else. Nothing is retyped: `url`, `label` and `notes` travel, the publication date stays unknown rather than guessed. The surface goes to the trash with its alignment journal, and the reverse move exists, so nothing is lost and a replay finds the same line instead of duplicating it (`201` on creation, `200` when the page already slept in the registry and was put back live). The own site of the brand answers `422 own_domain`: it stays a surface.

### Corroborations

A corroboration is a page about the brand where SOMEONE ELSE has the final say: an article, a directory entry, a review, a comparison. The twin registry of surfaces, where the customer has the final say: the split is CONTROL, never who wrote the page or who paid for it. The test that settles it: if the customer changes the page, does the change stay? Yes is a surface, no is a corroboration. Being able to edit is not the test, since a wiki anyone can edit is a corroboration, while a listing the customer holds is a surface even though a third party runs the site. A corroboration is recorded as dated proof; asking its source for a change stays a normal move, and the outcome belongs in the logbook. The Atlas is the map both registries point to: it charts the sources the AIs listen to, the corroboration registry says where, on that map, someone already speaks about the brand.

Recording is DECLARING, never detecting. Nothing written here enters the Atlas: the map is fed by measured surveys only, so one customer can never move what it says to the others. A corroboration therefore carries its `domain` as a plain string, not the id of a source, and it can perfectly well sit on a domain no survey has ever cited, which is the normal case when starting out. To see whether that source is on the map, search it: `GET /sources?domain=<domain>`.

`GET /projects/{id}/corroborations` lists them as `{id, project_id, url, domain, label, notes, published_on, author, request_channel, monitoring, monitoring_paused, archived, page_check, created_at}`, live ones first. `request_channel` says whether someone can be asked to change the page: `available` (a contact or a process exists), `none` (nobody to ask) or `unknown` (not filled in, the default); three states, because "no channel" and "not filled in" are two different facts. It gates the `corroborations_to_refresh` suggestions of the quest file, and the contact itself belongs in the notes. Send it on `POST` or `PATCH` (`null` goes back to `unknown`; an unknown value answers `422 invalid_corroboration` listing the catalogue). `url` is the exact page. `domain` is the registrable domain it sits on, derived from the address by the same normalizer as the Atlas. `published_on` is the day the page was PUBLISHED, distinct from `created_at`, the day it was recorded: it is the publication date that means something against the citation curves, and it is `null` when unknown rather than guessed.

`POST /projects/{id}/corroborations` records one from its `url` alone (absolute http(s), the exact page and never the home page of the site): the `domain` and the `label` are DERIVED from the address, and `label`, `notes` and `published_on` (as `YYYY-MM-DD`) stay optional. Only record a page you have actually read: it is a statement of fact, so confirm with the person first. Three refusals answer with their own slug, so no message has to be parsed: `422 unplaceable_url` (the address has no registrable domain), `422 own_domain` (this is the own site of the brand, where they have the final say: a surface, use `POST /projects/{id}/surfaces`), `409 duplicate` (the page is already in the registry of this project, and there is nothing to retry).

`PATCH /corroborations/{id}` updates the sheet; only the fields you send change. `label` and `notes` clear on an empty string or `null`, and a cleared label goes back to the derived one; `published_on` clears the same way; the `url` never clears, so `null` leaves it as it was. Correcting the `url` ALWAYS recomputes the `domain`: the two never drift apart, and sending a `domain` yourself has no effect.

`POST /corroborations/{id}/verify` reads the page NOW, on demand and at no cost, and every line carries the latest result as `page_check`. One pass returns two findings, dated. On the MENTION: the passage around the brand is archived on the line, and the finding says `unchanged`, `changed` (the passage moved, worth re-reading), `figures` (the passage moved on NUMBERS only, such as a rank or a counter, so `changed_at` stays where it was) or `gone` (the page did not answer). When a change has been found, `excerpt_before` carries the passage as it stood BEFORE `changed_at`, so comparing the two texts says what moved without reading the whole page again. On the LINK: every link of the page towards an address of the brand, each with its rel tokens (`nofollow`, `sponsored`, `ugc`, or none), its anchor text and its target; the addresses that count as the brand are the link targets below, derived from the canon website and the surface registry, plus the free ones of the project. Manager or above.

`monitoring`, on the same `PATCH`, arms the automatic watching of the line: `off` (the default), `daily`, `weekly` or `monthly`. It states a STATE, so re-sending a value changes nothing, and it evaluates on the sheet as it lands: correcting the `url` in the same call puts the reading back in progress, so arm once the new address reads. Watching applies to a live line whose page reads: elsewhere the answer is `409 corroboration_archived` or `409 page_not_readable`, and `off` is always accepted. At the chosen cadence, each executed check runs the same pass as the on-demand check, debits the prepaid balance per executed check (the grid is published at https://epovest.com/en/pricing), and one email per account and per pass reports the lines where the text moved, the link went, or the page stopped answering. A drained balance stops the passes and the line says so with `monitoring_paused`, the cadence kept: a top-up resumes them as they were. A page that stops answering stops being debited, and free probes watch for its return. `POST /projects/{id}/corroborations/monitoring` sets the whole registry in one move, `{"monitoring": "weekly"}`, `off` to stop: arming covers the live readable lines, stopping never refuses, and the answer carries the count covered, so nothing has to be re-listed.

`GET /projects/{id}/link-targets` lists the addresses of the brand a third-party link can point to, the ones a reading of the page reports on. Two lists come back. `derived` is what is already covered from work the customer has done elsewhere, each row with its `source`: `canon` (the canonical website) or `surface` (a page of the surface registry, such as a profile, a directory listing or an app store page). `free` is what has been set on the project on top of those. Read it before setting anything: an address already derived needs no entry of its own. Matching follows the nature of the host: by registrable DOMAIN for an own domain, and by domain AND PATH on a shared profile host, so a link to the platform itself is never read as a link to the brand.

`PUT /projects/{id}/link-targets` sets the free ones, with `{"link_targets": [...]}` of absolute http(s) addresses: a satellite domain the customer keeps outside their registry, a tracking link, a partner page. They are set ONCE for the project and serve every line of the registry, present and future, so there is nothing to set line by line. The list REPLACES the previous one, and the answer returns what the project now holds, derived addresses included, so a read-back confirms it. An empty list keeps the derived ones. Manager or above.

`POST /corroborations/{id}/convert-to-surface` moves the page to the surface registry, when the customer has, or takes, the final say on it: their own profile filed on the wrong side, or a source that became a reliable channel. Nothing is retyped: `url`, `label` and `notes` travel, the publication date is copied into the notes, the type derives from the address. The corroboration is taken down with its sheet intact, and the reverse move exists, so nothing is lost and a replay finds the same line instead of duplicating it (`201` on creation, `200` when the page already slept in the surface registry, trash included, and was brought back).

`GET /projects/{id}/corroboration-candidates` proposes the MENTIONS an engine has shown the brand in: pages found in the text the engine itself returned next to each address. Nothing is fetched: the excerpts are read from raw payloads already in store, over the latest surveys (`surveys_scanned` says how many). A mention is not filed yet, and pages already filed on either side (the corroboration registry or the surface registry of the project) are left out. Each row is `{url, domain, excerpt, matched_by, engines, seen_on, published_on, suggested}` and carries no id, because nothing is stored: the list is recomputed on every read. `matched_by` is the strength of the proof: `website` means the full domain of the brand appears in the excerpt, which is near proof; `name` means only the name did, which is weaker, since homonyms exist, so read the page before recording it. `suggested` names the registry the filing is proposed in, and it has three values: `surface` when the host is a known profile place (a page the customer usually has the final say on: register it with `POST /projects/{id}/surfaces`), `ask` when the address looks like a listing on a place outside that catalogue, so it can be either side, and `corroboration` otherwise. It is a suggestion, never a filing: both moves stay open, the customer decides. On an `ask`, the test that settles it is the criterion itself, put in their own terms: if they change that page, does the change stay? Yes files it as a surface, no as a corroboration. The host cannot answer it, because two pages of one host differ: a product listing on a software directory is held by the vendor, the comparison page beside it is not.

That list follows the measurement: it is recomputed from the latest surveys, so it widens as the account measures more, and a broader tracker surfaces more of it.

To accept a candidate, record it: `POST /projects/{id}/corroborations` with its `url` (and its `published_on` when the candidate carries one). There is no accept route, on purpose: accepting is the ordinary recording move, and the candidate then leaves the list because the page is in the registry. To refuse one, `POST /projects/{id}/corroboration-candidates/dismiss` with `{"url": "..."}`: that page is never proposed again for this project, nothing is created or deleted, and the call is idempotent.

`POST /corroborations/{id}/archive` records that the page is no longer there (unpublished, dead link), and `{"archived": false}` puts it back live. Both are idempotent: the call states an intended state, it does not toggle one, so replaying it never undoes what it just did. Nothing is ever deleted, and there is no DELETE at all: the line and its history stay, because "they talked about us from March to July" is information, while a page taken down simply stops counting as a presence on the map. A recording mistake is fixed with a PATCH.

### Logbook

Every project keeps a logbook: the dated moves of the GEO work, readable against the measures. It composes two sources. Suite moves are derived from the product itself (the canon moved to a version, a surface was marked aligned, a corroboration was recorded): they appear on their own and cannot be edited. Manual entries are actions the customer records, such as "Site translated into Spanish" or "Guest article published": short, dated with the day the action HAPPENED (recording after the fact is the normal case), and freely editable, because it is the customer's own logbook; an entry taken out of it waits in the trash, so the move can be undone. Every move also lands as an annotation on the citation curves of the trackers of the project, so what was done can be read against what the curves show.

`GET /projects/{id}/logbook` returns the composed timeline, newest first, as `{"logbook": [...]}`. Every item carries `source` (`tool` or `manual`), `kind` (`canon_version`, `surface_aligned`, `corroboration`, `corroboration_archived` or `entry`), `occurred_at` (the date of the action) and `author`. Canon and surface items add `canon_version`, plus `surface_id` and `surface` when a surface was aligned. A `canon_version` item also carries `changed`, the canon keys that revision touched, among `one_liner`, `short`, `long`, `category`, `language`, `perks`, `website`, `legal_name`, `address`, `phone`, `whatsapp` and `email`: the number says the registry has work waiting, `changed` says what the work is about, so the re-propagation of a revision is driven from the API, page by page, on the fields that actually moved. Version 1 lists everything it posted, having no previous version. The wording to propagate is the current one, served with the project on `GET /projects`. Corroboration items add `corroboration_id`, `corroboration`, `url`, `domain`, `published_on` and `archived`. Manual items add `id`, `category`, `label`, `notes` and `recorded_at`. Manual items also carry `deleted_at`, the day the entry left the logbook, `null` while it is in it. Filters: `?source=` (`tool` or `manual`), `?category=` and `?quest_id=`; the last two only ever match manual entries, since a suite move carries neither a category nor a quest. Manual items also carry `quest_id` and `quest`, the quest the action moves forward, `null` when it serves none.

On a `corroboration` item, `occurred_at` is the PUBLICATION date when it is known, and the recording date otherwise. `published_on` sits right next to it so you can tell which one you are reading: `null` there means the move is dated by the day it was learned. An article from two years ago recorded today belongs two years ago on the timeline, so pass `published_on` whenever you know it. A corroboration that was taken down carries TWO moves: its publication (`corroboration`) and its removal (`corroboration_archived`), the latter dated at the day it was archived, `archived` true on both. Both stay in the logbook: the page did appear, the curve could well have moved from it, and a presence that ends is itself part of the record.

`POST /projects/{id}/logbook` records an entry: `category` (one of `content`, `technical`, `translation`, `canon`, `press`, `other`), `label` (a short wording: it is what the annotation shows), plus optional `occurred_at` (ISO 8601 date or datetime, read as UTC without an offset; omitted, now), `notes` and `quest_id`, the quest of the same project the action moves forward. An entry recorded against a quest stays an entry of the logbook of the project: it is stored once, it annotates the curves once, and it reads from both places. When the person says "yesterday" or "last week", compute that date and pass it: the value of the logbook is the date of the action itself. The call is idempotent on `(project, quest_id, label, occurred_at)`: send it again and you get the entry already recorded, with `200` instead of `201`, never a second copy. The quest is part of that key, so the same wording recorded on two quests on the same day stays two entries, on two files. So a retry after a timeout is safe, and the same move recorded in two languages, having two labels, stays two entries. When the twin is one you had taken out, it comes back rather than being written a second time, and `restored` is `true` in the answer so the move is visible.

`PATCH /logbook/{id}` corrects an entry, field by field: only the fields you send change (empty `notes` clear them; the `label` never clears, so `null` leaves it as it was). `quest_id` files an entry under a quest after the fact, and `null` files it back under the project alone: it states a state, so it replays safely both ways. A field sent as something other than text answers `422 invalid_entry` and changes nothing. `DELETE /logbook/{id}` takes an entry out of the logbook, along with the annotation it placed on the curves; do it only when the person asked for it, it is their logbook. The entry then waits in the trash: `GET /projects/{id}/logbook?deleted=only` lists what is out, most recently taken out first, and `POST /logbook/{id}/restore` brings one back with its annotation. Both moves are idempotent. The trash carries recorded entries only, so `deleted=only` goes without `source` and `category`, and any other value of `deleted` answers `422 invalid_filter`. Suite moves carry no id and cannot be edited or taken out: they are derived from the canon history and the surface registry.

### Quests

The quest file answers "what is there to do for the GEO work". It holds the quests of the customer: next moves they (or you, on their behalf) decided and recorded, such as "Get our MCP server listed on the AI tool directories". A quest belongs to a project, opens as `open`, and closes as `done` (the move happened) or `dismissed` (the customer set it aside); both stay readable as history, and both reopen. The logbook is the record of what WAS done; the quest file is what comes next. The two meet on one quest: record an action with its `quest_id` and that quest carries its own dated trail, read with `GET /projects/{id}/logbook?quest_id=`, which is how you tell what was already tried on a move and when.

`GET /quests` returns `{"quests": [...], "pending": {...}}`. `quests` lists the file, newest first, every active project by default: each quest carries `id`, `project_id`, `project` (its name), `title`, `notes`, `status`, `author`, `created_at`, `closed_at`, plus `journal_entries` and `last_entry_at`, how many actions were recorded against it and when the last one happened. The two figures come with the file so a trail is never read blind; the entries themselves are read with `GET /projects/{id}/logbook?quest_id=`. `?status=` reads one state (`open` by default, `done` or `dismissed` for the closed history) and `?project_id=` scopes the file to one project. Reading the closed history answers `{"quests": [...]}`: it is the question of what was done or set aside, and it is answered with the quests that answer it.

`pending` comes with the open file: the work the measurement itself is holding for review, composed on every read from the product's own registries, so it empties on its own as the moves are made. `surfaces_to_align` lists the surfaces whose canon moved since they were last marked aligned, as `{project_id, project, surface_id, surface, url}`: realign the page, then verify the perished cells with the checklist PATCH. `corroborations_to_refresh` lists the corroborations recorded or published BEFORE the current canon version whose request channel is not `none`, oldest first, as `{project_id, project, corroboration_id, corroboration, url, published_on, request_channel}`: a suggested move, never a status; asking the source for an update is decided in the corroboration registry, and what you obtain belongs in the logbook. `keyword_discoveries` lists the trackers with suggestions waiting, as `{tracker_id, tracker, project_id, project, count}` (`project` is `null` on a tracker filed under Default): read them with `GET /trackers/{id}/discoveries` and accept or dismiss there. On the project-scoped read, `pending` also carries `corroboration_candidates`, the same entries as `GET /projects/{id}/corroboration-candidates`: they are computed per project, from the excerpts of the last surveys, so the account-wide read does not carry the key at all rather than showing a zero it did not examine. An empty file with the measurement running means there is nothing to correct today.

`POST /quests` adds one: `project_id` (in the body: the file is account-wide, the project is data), `title` (a short wording of the move: it is what the file shows), plus optional `notes`. The call is idempotent on `(project, title)` while the quest is open: send it again and you get the quest already in the file, with `200` instead of `201`, never a second copy. A closed quest with the same title does not block: doing the move again later is a new quest with its own history.

`PATCH /quests/{id}` corrects one, field by field: only the fields you send change (empty `notes` clear them; the `title` never clears, so `null` leaves it as it was). A field sent as something other than text answers `422 invalid_quest` and changes nothing. The status moves through its own calls: `POST /quests/{id}/complete` sets the quest done, `POST /quests/{id}/dismiss` sets it aside, `POST /quests/{id}/reopen` puts it back in the file. All three SET a state rather than toggling one, so a replay is safe and the last move is what the file remembers, a dismissed quest done after all included. Reopening clears `closed_at` and keeps the sheet as it was, the date it was added, its author and its notes: the same quest resumes, with its own history. Nothing is deleted: closed quests stay in the history.

### Keyword discovery

When a tracker has `discovery` on, Epovest keeps an eye on the domains that keep coming back in the surveyed engines' sources while no tracked keyword covers them: those are names getting the attention your market gives out. `GET /trackers/{id}/discoveries` lists the current suggestions as `{domain, sources, keyword}`, where `sources` counts how many times that domain appeared, recomputed on every read; it also carries `dismissed`, the domains this tracker has set aside, so what you refused stays readable and a change of mind is one call away.

The three moves each take one domain or a batch: send `{"domain": "<domain>"}` for one, or `{"domains": ["<a>", "<b>"]}` for several, never both. A batch is all-or-nothing: one entry that is not eligible refuses the whole batch, so you never have to guess which half took. The answer echoes the shape you sent, a string for `domain`, a list for `domains`.

`POST /trackers/{id}/discoveries/accept` turns the domains into tracked keywords, in place, within the keyword cap (`409 keyword_cap_reached`, which carries the cap as `keyword_cap`); the answer carries `keyword` (single) or `keywords` (batch) plus the updated `tracker`. `POST /trackers/{id}/discoveries/dismiss` retires them and answers `{"dismissed": ...}`; dismissing an already dismissed domain is fine. Both revalidate each domain against the current suggestions: one that is not among them answers `404` (with `domains` listing the offenders). `POST /trackers/{id}/discoveries/restore` takes a domain back out of `dismissed` so discovery may surface it again; it is idempotent and never `404`s, since restoring a domain that was not dismissed already leaves it eligible. The suggestions list comes back empty when discovery is off, and when no analyst reads keywords at all. Nothing is ever added silently: relay the suggestions to the person, the decision is theirs.

### Competitor scans

A competitor scan asks one engine about a basket of up to 5 companies of the same market, three or more being what the ranking by recurrence needs, and returns THE PLACES that corroborate them: the domains the engine cited while answering about them, ranked by how many of the basket each place covers. The competitor is not the subject, it is the instrument. That list cannot be read anywhere, it is provoked: once the question is about the subject, the citation is the fact.

`POST /competitor-scans` prepares one, as a DRAFT: nothing is charged, and the answer carries the questions it derived for you to read. Each subject needs a `website`, since a whole domain found in a page is what tells two companies with the same name apart; `category` qualifies each company in the questions and `usage` says what buyers use it for. `templates` picks which of the four questions each company gets (`about_reputation`, `about_price`, `about_customers`, `compared_alternatives`), and `language` the language they are asked in, written in 32 of them: en, fr, es, de, it, pt, ar, bg, cs, da, el, fi, he, hi, hu, id, ja, ko, ms, nl, no, pl, ro, ru, sk, sv, th, tr, uk, vi, zh, zh-hant.

The wording is where the measurement is won, so it stays in your hands until the scan is armed: `PATCH /competitor-scans/{id}` takes `questions`, the list of the ones to KEEP with the text to ask (`[{"id": "...", "text": "..."}]`, an omitted `text` keeping the current one). A question of the scan left out of the list is removed, and `estimated_cost_minor` follows it. `POST /competitor-scans/{id}/start` arms the scan, and that is what spends: the amount is `estimated_cost_minor`, so name it to the person first. Replaying it answers with the scan already armed instead of paying for a second one. The same `PATCH` sets `rescan_cadence` to `weekly` or `monthly`, or to `null` to stop it: weekly widens the list faster and spends as often, monthly follows the pace a corpus of third-party pages moves at.

`GET /competitor-scans/{id}` carries the list. Every place holds `subjects`, the number of the basket corroborated there, its `reach` (the way in: `self_serve` to open your own page, `participate` for a forum or a community, `ask` for a third-party editorial site, `registry` when the page follows an official filing), the `ai_authority` of the domain, its exact `pages`, and `client_present`, which says whether the account already recorded a corroboration there. The list ACCUMULATES over every check, deduplicated by URL, so `seen_in_checks` counts checks and never citations: a place every pass brings back is a steady one, and each pass widens the list rather than replacing it. `controlled` is what each company publishes on its own site, and `rivals` the competitive set the engine names.

To act on a place, add a quest with its URL (`POST /quests`): that is where the work resumes, alongside everything else the account decided.

Checks of a competitor scan are priced on the same grid as tracker checks, and read on `/usage` under their own cost line, `competitor_scan`.

### The Atlas of sources

The Atlas is the map of the sources the AIs listen to. Every entry is a domain, with the engines that cited it and the dates.

What it records is deliberately narrow: this domain was cited as a source by this engine, on this date. No opinion, no editorial judgement, nothing to argue with. That is what lets the Atlas be BUILT from every measurement Epovest runs, all accounts together: a citation is a fact rather than a rating, so the corpus gains from its coverage without any account being able to tilt it. That corpus is what the AI Authority scale is computed on, which is why the figure holds still while your own set of sources grows. The same reason bounds it in the other direction: the Atlas never says who measures what. No tracker, no survey and no account appears in it.

The unit is the registrable domain, so `fr.wikipedia.org` and `en.wikipedia.org` are one and the same source, and a hosting platform counts as one source rather than one per author.

`GET /sources` lists them as `{id, domain, engines, last_detected_on}`. `engines` carries one entry per engine, as `{engine, aa}`: which engines cite this source, and what it weighs on each. Filters: `?domain=` keeps the domains containing that text, `?engine=` keeps the sources that engine has cited at least once, `?sort=` orders the page. `aa_chatgpt`, `aa_claude`, `aa_gemini`, `aa_perplexity`, `aa_mistral` and `aa_grok` each give the ranking of one AI and keep only the sources that AI cites: the rankings are almost disjoint, so each one ranks the sources on its own AI. The others order across all AIs (`detections`), by number of distinct engines (`engines`), by most recent detection (`freshness`) or alphabetically (`domain`). `?dir=` takes `desc` or `asc` (422 `invalid_direction`); left out, each sort takes its natural way, strongest first for a ranking and A to Z for `domain`. Ascending is what answers "where am I weakest", which is usually where the work is.

`?project_id=` reads the map of one brand: the uuid of a project of the account, or `default` for the trackers filed under none. It takes the sources surfaced by the trackers of that project (a malformed uuid answers 422 `invalid_project`, a project of another account 404). Left out, the map covers every tracker of the account, which is the reading that shows a source weighing on two of its markets at once. Either way the AI Authority scale holds still: it stays computed across the whole map, so a project of three trackers and a project of thirty are read on the same 0 to 100.

`GET /sources/{id}` returns the same entry, read by its id. A source outside your own set answers `404`, exactly as an unknown one.

`GET /sources/{id}/channels` reads the level below the domain: the channels that published the videos the AIs cited when answering your questions. It answers `{source, channels, videos, named, unnamed, pending, citations}`. Each channel carries `{name, url, questions, videos, citations, surveys, first_seen_on, last_seen_on, you_are_there, video_list}`, and each entry of `video_list` carries `{url, questions, citations, surveys, first_seen_on, last_seen_on}`, where `questions` on a video is the text of your own questions that surfaced it. The order is the measure: how many of your questions the channel came back on, then videos, then citations, so a channel that answers two of your questions with one well-titled video sits above a busy channel cited twice on the same one. `you_are_there` is read from the corroborations this account has recorded on that channel or on one of its videos. `videos` counts the distinct videos cited on your questions, `named` those whose channel is established, `unnamed` those the world does not name, and `pending` those still to be established: the four are what the list is drawn from, so a short list reads for what it is. `?project_id=` takes the same values as on the list above.

**The Atlas you read is yours.** It lists the sources surfaced by your own surveys, so it grows as you measure more. The scale stays platform-wide: AI Authority is computed across every measurement Epovest runs, which is what keeps it stable and beyond the reach of any single account.

**AI Authority** is the only figure the Atlas serves. It runs from 0 to 100 on each AI, over the last 30 days: 100 is the source that AI cites the most, and the rest is its share of that leader. An engine carrying no value has not cited the domain lately, which is not a zero.

Each AI has its own leader, so its own scale. An AA always places a source against the leader of ITS AI, and the rankings are almost disjoint: each one ranks the sources on its own AI. That is the useful part, since aiming at one AI and aiming at another are opposite strategies.

The figure is a share, and a share is portable: a 61 means the same thing on your Atlas as on anyone else's, and it goes on meaning it as your coverage widens.

### Account settings

`GET /account` returns the settings of the account, and `PATCH /account` changes them. Read before you write: only the fields you send change, so the answer of the read is what tells you the value of everything you are leaving alone.

| Field | Meaning |
|---|---|
| `name` | The legal name of the account, the one printed on its invoices. A non-empty string of at most 160 characters. |
| `billing_country` | The billing country, as an ISO 3166-1 alpha-2 code: `FR`, `US`, `BR`. It decides the tax treatment of the next invoices, so it is the country of the entity being billed. |
| `billing_address` | The postal address, one string per line: `["12 Example Street", "75001 Paris"]`. A sent list replaces the previous address; three lines of 150 characters at most, so the address block of an invoice stays readable. |
| `intra_eu_vat_number` | The intra-EU VAT number of an account billed in an EU member state: the two-letter country code and the national number, `FR12345678901`. It goes on the invoices. |
| `language` | The language we write to this account in, for emails and for the hosted payment page of a top-up: `en`, `fr`, `es`, `de`, `it`, `pt`. |
| `timezone` | The time zone the hours of the account are shown in, as an IANA identifier: `Europe/Paris`, `America/New_York`, `UTC`. |
| `member` | Who the language and the time zone belong to: `{email, name, role}`. They are preferences of a person, and this names which one: the person a one-click connection acts for, or the oldest active owner of the account. |
| `created_at` | When the account was created. |

`language` and `timezone` always come back with the value in force, `en` and `UTC` by default, so you can report what applies without working it out.

On `PATCH`, `billing_country`, `billing_address` and `intra_eu_vat_number` clear when you send `null`; `name`, `language` and `timezone` keep their value on `null`, so name the language or the time zone you want. A postal address and a VAT number travel with `billing_country`: send the country in the same call, and the VAT number goes with an EU country. What you write applies to the invoices issued from then on; the ones already issued keep the details they carry. Writing the billing identity is owner only, `422 invalid_account` names the field it refuses, and `409 no_member` says the account has no active owner whose language could be set.

Signing in is set up in the app: the email address, the password and the second factor are confirmed there by the person themselves.

### Reading and topping up credits

`GET /credits` returns the prepaid balance of the account:

| Field | Meaning |
|---|---|
| `available_minor` | What the account can spend now: net of reservations. |
| `balance_minor` | The gross balance. |
| `held_minor`, `holds[]` | Credits reserved by surveys in progress; each hold carries `amount_minor`, `reason` and `created_at`, the instant it was placed, written like the ledger entries of `GET /usage`. A reservation is captured when its survey completes, or released. |
| `expires_at` | Always null: Epovest credits never expire. Kept so an integration that reads it does not break. |
| `currency` | The wallet currency of the account. |
| `min_topup_minor` | The floor a top-up has to clear. |
| `monthly_estimate_minor` | What the account has set up to consume in a month: its trackers in draft, running or out of credit, plus the corroborations it monitors. `0` when nothing is set up yet. |
| `suggested_topups[]` | Three amounts derived from that pace, worth one, three and six months of it, rounded and never under the floor. Each carries `amount_minor`, `amount` (major units) and `covers_months`, the runway it buys. Take one of these when the person leaves the amount to you. |

When nothing consumes yet, `monthly_estimate_minor` is `0` and `suggested_topups` is `[]`, since no amount follows from anything; ask what they want to measure, and the figures appear as soon as a tracker is configured.

`POST /credits/topup` prepares a top-up and returns `{"payment_url": ..., "amount": ..., "amount_minor": ..., "currency": ...}`. It emits the top-up invoice and returns the hosted payment page: nothing is charged by the call itself. Hand `payment_url` to the person; they approve the amount there, and once they pay, the balance updates on its own and the trackers that ran out of credit resume. Only the trackers stopped for lack of credit come back this way: one the person paused on purpose keeps its `manual` reason and waits for `POST /trackers/{id}/start`.

The amount goes in one of two units, one per speaker. Send exactly one of them; a key set to `null` counts as absent.

| Field | Unit | Use it when |
|---|---|---|
| `amount` | Major units of the wallet currency: `50` means 50.00, `12.5` means 12.50. | The person names the amount. Pass exactly the number they said. |
| `amount_minor` | Minor units: `5000` means 50.00. | The person leaves the amount to you. Take one of the `suggested_topups[]` of `GET /credits` and pass its `amount_minor`. The whole cost grid is in minor units too (`cost_per_survey_minor`, `monthly_estimate_minor` on every tracker), so nothing has to be converted. |

Amounts are always in the wallet currency of the account, which `GET /credits` gives: if the person names another currency, tell them rather than converting one currency into another yourself. The minimum top-up is 10.00 (1000 minor units); below it, `422 below_min_topup` carries `min_topup` and `min_topup_minor`. The response echoes the amount in both units, so you can confirm to the person what they are about to pay in the unit they think in.

### Reading what the account has spent

`GET /credits` says what is left to spend; `GET /usage` says what has been spent, and on what.

| Field | Meaning |
|---|---|
| `by_tracker[]` | The spend of each tracker over the period, biggest spender first: `tracker_id`, `name`, `project_id`, `listed`, `spent_minor`, `survey_count`. `tracker_id` is the current version of the chain, so it goes straight into `GET /trackers/{id}/scores` or `/responses`. `listed` is false for a tracker that is no longer in the account list; its spend still counts in the total. |
| `by_project[]` | The same amounts folded by project: `project_id`, `name`, `archived`, `spent_minor`, `survey_count`. `project_id` is null for the trackers filed under no project. |
| `this_month_forecast` | The month in progress: `total_minor` = `spent_minor` (already debited) + `remaining_minor` (what the active trackers and monitored corroborations will still run before month end, counted as real occurrences and recomputed from their configuration). A forecast: report it as one, never as spend. |
| `by_month[]` | Per month, newest first, up to twelve: `month` (YYYY-MM), `spent_minor`, and the same amount by cost line, by project and by tracker. `months_total` says how many months have spend, so you can tell whether twelve covered everything. |
| `lines[]`, `by_category[]` | The spend by COST LINE: `survey`, `ai_analyst` (the supplement of the AI analysts grafted onto a check, derived from what that check reserved), `competitor_scan` (a one-off scan of a basket of competitors, run from the app, priced on the same check grid), `corroboration_check`, and `other` for a line not named yet, which stays visible rather than dropping out of a total. A competitor scan is its own line and never a row of `by_tracker`: it measures a market, not one of the account trackers. |
| `period` | `{from, to}`: the first and last check debit counted. The amounts outside `by_month` are a cumulative total over that period, never a monthly figure, so quote the period alongside the amount. |
| `total_spent_minor` | The check spend of the period, matching the sum of both breakdowns. |
| `entries[]` | The ledger itself, newest first: `kind`, `amount_minor` (signed), `balance_after_minor`, `reason`, `tracker_name`, `period_start`, `on_demand`, `invoice_id`, `created_at`. `created_at` is the instant the entry was posted, in RFC 3339 with the UTC offset, to the second and carrying the fraction the ledger recorded: an active account posts dozens of entries a day, so it is the time that tells them apart and orders them. `on_demand` says where a check debit came from: `true` when the check was asked for on the tracker, `false` when the cadence ran it, `null` on an entry that is not a check debit. The two are worth separating in a spend report: one is a decision, the other is the schedule. |
| `currency`, `month`, `pagination` | The wallet currency, the month asked for, and `{page, per_page, total, pages}`. |

`month` (`YYYY-MM`) narrows everything to one month; `type` (`in`, `out`) narrows `entries[]` alone, since both breakdowns are spend by nature. `page` is clamped like everywhere else in this API: stop on `page >= pages`.

### Example

```bash
curl -H "Authorization: Bearer epo_..." \
  "https://api.epovest.com/v1/trackers"

curl -X POST \
  -H "Authorization: Bearer epo_..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Market watch",
    "prompts": ["Which GEO tracking solution should I choose?"],
    "engines": ["chatgpt", "claude"],
    "frequency": "weekly",
    "resolution": "hd",
    "keywords": [{"keyword": "Epovest", "favorite": true}],
    "analysts": ["keyword_presence"]
  }' \
  "https://api.epovest.com/v1/trackers"
```

## MCP server

The MCP server exposes the same surface as tools, for AI agents that speak the Model Context Protocol.

- Endpoint: `https://mcp.epovest.com/mcp`
- Transport: streamable HTTP, stateless. Send ONE JSON-RPC message per POST with `Content-Type: application/json`; the reply is a single JSON body. No session id is issued nor required. GET serves no stream and answers 405. Notifications are acknowledged with 202 and an empty body.
- Authentication: the same `Authorization: Bearer` header on every request.
- Protocol: revisions `2026-07-28`, `2025-06-18`, `2025-03-26` and `2024-11-05` are served on the same endpoint. `server/discover` returns that list with the capabilities and the server identity, and answers without a key.
- From `2026-07-28` on, each request declares its own version: send the `MCP-Protocol-Version` header and the same value in `params._meta` under `io.modelcontextprotocol/protocolVersion`, plus `Mcp-Method` (and `Mcp-Name` on `tools/call`). Results carry `resultType: "complete"`; `tools/list` and `server/discover` carry `ttlMs` and `cacheScope`. A version outside that list comes back as a `400` with error `-32022`, which names the ones to pick from.
- Before that revision, `initialize` negotiates the version, `2025-06-18` preferred; then `tools/list` and `tools/call`.

Tools and the REST routes they wrap:

| Tool | Scope | Wraps |
|---|---|---|
| `list_trackers` | read | GET `/trackers` |
| `create_tracker` | write | POST `/trackers` |
| `update_tracker` | write | PATCH `/trackers/{id}` |
| `start_tracker` | write | POST `/trackers/{id}/start` |
| `survey_now` | write | POST `/trackers/{id}/survey` |
| `pause_tracker` | write | POST `/trackers/{id}/pause` |
| `archive_tracker` | write | POST `/trackers/{id}/archive` |
| `list_projects` | read | GET `/projects` |
| `create_project` | write | POST `/projects` |
| `rename_project` | write | PATCH `/projects/{id}` |
| `get_canon` | read | GET `/projects/{id}/canon` |
| `update_project_canon` | write | PATCH `/projects/{id}/canon` |
| `get_link_targets` | read | GET `/projects/{id}/link-targets` |
| `set_link_targets` | write | PUT `/projects/{id}/link-targets` |
| `archive_project` | write | POST `/projects/{id}/archive` |
| `list_surfaces` | read | GET `/projects/{id}/surfaces` |
| `create_surface` | write | POST `/projects/{id}/surfaces` |
| `update_surface` | write | PATCH `/surfaces/{id}` |
| `tick_surface_checklist` | write | PATCH `/surfaces/{id}/checklist` |
| `add_surface_check` | write | POST `/surfaces/{id}/checks` |
| `update_surface_check` | write | PATCH `/surfaces/{id}/checks/{key}` |
| `delete_surface_check` | write | DELETE `/surfaces/{id}/checks/{key}` |
| `restore_surface_check` | write | POST `/surfaces/{id}/checks/{key}/restore` |
| `delete_surface` | write | DELETE `/surfaces/{id}` |
| `restore_surface` | write | POST `/surfaces/{id}/restore` |
| `convert_surface_to_corroboration` | write | POST `/surfaces/{id}/convert-to-corroboration` |
| `list_corroborations` | read | GET `/projects/{id}/corroborations` |
| `create_corroboration` | write | POST `/projects/{id}/corroborations` |
| `update_corroboration` | write | PATCH `/corroborations/{id}` |
| `archive_corroboration` | write | POST `/corroborations/{id}/archive` |
| `verify_corroboration` | write | POST `/corroborations/{id}/verify` |
| `convert_corroboration_to_surface` | write | POST `/corroborations/{id}/convert-to-surface` |
| `list_corroboration_candidates` | read | GET `/projects/{id}/corroboration-candidates` |
| `dismiss_corroboration_candidate` | write | POST `/projects/{id}/corroboration-candidates/dismiss` |
| `get_logbook` | read | GET `/projects/{id}/logbook` |
| `create_logbook_entry` | write | POST `/projects/{id}/logbook` |
| `update_logbook_entry` | write | PATCH `/logbook/{id}` |
| `delete_logbook_entry` | write | DELETE `/logbook/{id}` |
| `restore_logbook_entry` | write | POST `/logbook/{id}/restore` |
| `list_quests` | read | GET `/quests` |
| `create_quest` | write | POST `/quests` |
| `update_quest` | write | PATCH `/quests/{id}` |
| `complete_quest` | write | POST `/quests/{id}/complete` |
| `dismiss_quest` | write | POST `/quests/{id}/dismiss` |
| `reopen_quest` | write | POST `/quests/{id}/reopen` |
| `get_results` | read | GET `/trackers/{id}/scores` |
| `get_responses` | read | GET `/trackers/{id}/responses` |
| `list_keyword_discoveries` | read | GET `/trackers/{id}/discoveries` |
| `accept_keyword_discovery` | write | POST `/trackers/{id}/discoveries/accept` |
| `dismiss_keyword_discovery` | write | POST `/trackers/{id}/discoveries/dismiss` |
| `restore_keyword_discovery` | write | POST `/trackers/{id}/discoveries/restore` |
| `list_competitor_scans` | read | GET `/competitor-scans` |
| `get_competitor_scan` | read | GET `/competitor-scans/{id}` |
| `create_competitor_scan` | write | POST `/competitor-scans` |
| `start_competitor_scan` | write | POST `/competitor-scans/{id}/start` |
| `update_competitor_scan` | write | PATCH `/competitor-scans/{id}` |
| `list_sources` | read | GET `/sources` |
| `get_source` | read | GET `/sources/{id}` |
| `list_source_channels` | read | GET `/sources/{id}/channels` |
| `get_account_settings` | read | GET `/account` |
| `update_account_settings` | write | PATCH `/account` |
| `get_credits` | read | GET `/credits` |
| `get_usage` | read | GET `/usage` |
| `topup_credits` | write | POST `/credits/topup` |
| `contact_support` | read | POST `/support/messages` |
| `list_support_threads` | read | GET `/support/threads` |
| `get_support_thread` | read | GET `/support/threads/{id}` |

A tool result carries the API JSON twice: as text in `content` and decoded in `structuredContent`. A failed call comes back with `isError: true` and the same `{"error": "...", "message": "..."}` envelope as the REST API. An argument a tool does not declare is refused with a `bad_request` that names it and lists the ones it knows, so a typo like `note` for `notes` surfaces at once instead of being dropped in silence. The rate limit and the scopes apply to MCP exactly as to REST.

### Example

```bash
curl -X POST \
  -H "Authorization: Bearer epo_..." \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: server/discover" \
  -d '{"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": {"name": "my-agent", "version": "1.0"}}}}' \
  "https://mcp.epovest.com/mcp"

curl -X POST \
  -H "Authorization: Bearer epo_..." \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/call" \
  -H "Mcp-Name: get_results" \
  -d '{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "get_results", "arguments": {"tracker_id": "01980000-0000-7000-8000-000000000000", "analyst": "keyword_presence"}, "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28"}}}' \
  "https://mcp.epovest.com/mcp"
```

## Talking to us

`POST /support/messages`, or the `contact_support` tool, reaches the humans behind Epovest: report a problem, suggest an improvement, ask a question. Use it when a call refuses what should work, when something the user needs is missing, or when the user asks you to tell us something. We read every message, and early feedback shapes what gets built.

| Field | Type | Notes |
|---|---|---|
| `message` | string, required | What you want to tell us. Include what you tried and what happened. |
| `subject` | string | Title of the thread. Derived from the message when omitted; ignored when replying. |
| `kind` | string | `problem`, `suggestion` or `question`. Sorts the message on arrival. |
| `thread_id` | string | Reply to this thread instead of opening a new one. |

The answer comes back in the same thread: read it with `GET /support/threads/{id}` (`get_support_thread`) and relay it to the user. Threads are shared with the members of the account, who see them in the app, so a message written by an integration is signed as such: we never attribute to a person words they did not type. A token obtained by the one-click connection names the member it acts for; a key names no one.

Sending is capped at ten messages per day per credential, and `429 rate_limited` answers beyond that. An API key carries its own budget, so an account holding several keys has several; the tokens of a one-click connection share one budget per account.

## Pricing

Measurement is prepaid: the account buys credits, in the app or through `POST /credits/topup`, and every survey debits its recalculated cost. Prices and the cost grid are published at https://epovest.com/en/pricing.
