# Epovest API for agents

Epovest measures how AI assistants such as ChatGPT, Claude, Gemini and Perplexity answer the questions a market asks: which names they cite, with which sources, and how that changes over time. 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.

## 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, starting and pausing trackers.

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

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 key, 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.

## Data-access window

Epovest is prepaid: measurement debits a credit balance. Reading through the API is open during the validity window of the latest top-up, twelve months from purchase, even once the balance is spent. When the window expires, every API and MCP call answers `402 data_access_expired` until the account tops up again.

The credits and support routes and tools are the exception: they answer even once the window has expired. Reading the balance and topping up is precisely how an expired account reopens its access, and a blocked account must be able to reach us.

Starting a tracker also requires the balance to cover at least one survey; otherwise `402 insufficient_credits`. 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"}`.

## Errors

| HTTP | error | Meaning |
|---|---|---|
| 400 | `bad_request` | Malformed body or parameter; the message says what. |
| 401 | `unauthenticated` | Missing, unknown or revoked key. |
| 402 | `data_access_expired` | Reading window expired: top up to reopen it. |
| 402 | `insufficient_credits` | Start refused: the balance does not cover one survey. |
| 403 | `insufficient_scope` | The key does not carry the required scope. |
| 404 | `not_found` | Unknown tracker or support thread for this account. |
| 404 | `unknown_project` | The `?project=` filter names no project of the account. |
| 409 | `project_exists` | Creation refused: a project with this name already exists; the answer carries it. |
| 422 | `invalid_name` | Project creation refused: the name is empty or too long. |
| 409 | `not_active` / `not_startable` / `not_editable` | The tracker state does not allow the action. |
| 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. |
| 422 | `validation_failed` | Invalid tracker configuration; `violations[]` lists field and message. |
| 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. |
| 503 | `service_unavailable` | A backend is unreachable, while starting a tracker or while 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. |
| 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. |
| GET | `/trackers/{id}` | read | One tracker. |
| PATCH | `/trackers/{id}` | write | Update the configuration; only the fields you send change. |
| GET | `/trackers/{id}/discoveries` | read | The discovered keyword suggestions of the tracker. |
| POST | `/trackers/{id}/discoveries/accept` | write | Accept a suggestion: the domain becomes a tracked keyword. |
| POST | `/trackers/{id}/discoveries/dismiss` | write | Dismiss a suggestion: never proposed again on this tracker. |
| POST | `/trackers/{id}/start` | write | Start or restart the measurement against the credit balance. |
| POST | `/trackers/{id}/pause` | write | Pause the measurement; the series is kept. |
| GET | `/trackers/{id}/surveys` | read | The surveys of the tracker chain, newest first. |
| GET | `/trackers/{id}/scores` | read | The score series, one row per analyst, keyword, engine, version and period. Filters: `?analyst=`, `?engine=`. |
| GET | `/trackers/{id}/responses` | read | The raw answers of one engine, `?engine=` required, paginated with `page` and `per_page` up to 100. Filters: `q`, `question`, `survey`; `include=raw` adds the full engine payload. |
| GET | `/credits` | read | The prepaid credit balance: available, reserved by surveys in progress, expiries. |
| POST | `/credits/topup` | write | Prepare a top-up and return the hosted payment link; nothing is charged by the call. |
| 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`. |
| `frequency` | string, required | `monthly`, `weekly`, `daily`. |
| `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. |
| `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. Later surveys keep that day and time at the pace of the frequency. |
| `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. A tracker in the responses carries `next_survey_at`: when its next survey runs (a past instant means within the hour; null when nothing is scheduled: paused, or a draft without a chosen date whose first survey runs at 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 tracker 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`. Only a draft, abandoned or active tracker can be edited; otherwise `409 not_editable`.

### 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, archived, tracker_count, created_at}`. 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; 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. File a tracker with `project_id` on `POST /trackers` or `PATCH /trackers/{id}`; moving a tracker between projects never touches its series.

### Keyword discovery

When a tracker has `discovery` on, Epovest keeps an eye on the domains the surveyed engines cite as sources again and again 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, citations, keyword}`, recomputed on every read. `POST /trackers/{id}/discoveries/accept` with `{"domain": ...}` turns one into a tracked keyword, in place, within the keyword cap (`409 keyword_cap_reached`); `POST /trackers/{id}/discoveries/dismiss` retires one for good. Nothing is ever added silently: relay the suggestions to the person, the decision is theirs.

### 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, zero once the credits expired. |
| `balance_minor` | The gross balance. |
| `held_minor`, `holds[]` | Credits reserved by surveys in progress; each hold carries `amount_minor`, `reason` and `created_at`. A reservation is captured when its survey completes, or released. |
| `expires_at` | When the credits expire, twelve months from the latest top-up. |
| `data_access_until` | Until when the data-access window is open. |
| `currency` | The wallet currency of the account. |

`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, paused trackers resume and the data-access window reopens.

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. | You work the amount out yourself. The 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.

### 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: `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` |
| `pause_tracker` | write | POST `/trackers/{id}/pause` |
| `list_projects` | read | GET `/projects` |
| `create_project` | write | POST `/projects` |
| `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` |
| `get_credits` | read | GET `/credits` |
| `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. The rate limit and the scopes apply to MCP exactly as to REST. So does the data-access window, tool by tool: once it has expired, a tool that reads or writes measurement answers `isError` with `data_access_expired`, while the support tools keep working.

### Example

```bash
curl -X POST \
  -H "Authorization: Bearer epo_..." \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "my-agent", "version": "1.0"}}}' \
  "https://mcp.epovest.com/mcp"

curl -X POST \
  -H "Authorization: Bearer epo_..." \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "get_results", "arguments": {"tracker_id": "01980000-0000-7000-8000-000000000000", "analyst": "keyword_presence"}}}' \
  "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 account per day, and `429 rate_limited` answers beyond that.

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