# SolSEO for agents

This file is for software: a language model, an MCP client, a script. It describes the read API a SolSEO customer reaches with a key they minted themselves.

<!-- your-account -->

## What SolSEO is

A rank tracker and site auditor. It checks where a site ranks on Google for keywords the customer chose, crawls the site and lists what is wrong with it, and turns both into a monthly report. Australian, priced in AUD.

## What this file is

Everything needed to call the API correctly, including what the refusals mean. The route list and the limits below are generated from the same tables the server enforces, so they cannot describe a route that does not answer.

The machine readable version of the same thing is `https://solseo.com.au/openapi.json`.

## Getting a key

A person does this, once, and no API call can do it. Sign in, open `/app/settings/api`, name the key, tick the scopes, and copy the value: it is shown once and stored only as a digest. A key belongs to the account that minted it and cannot be pointed at another one.

API keys are part of the paid plans.

## Authenticating

A bearer header on every request:

```
Authorization: Bearer <your key>
```

**Make `GET https://solseo.com.au/api/v1/whoami` your first call.** It needs no scope, answers what the key is and which account it speaks for, and it is how you find out whether the header survived the trip before you debug anything else.

## The shape of every answer

Flat JSON. Never wrapped in `data` and never carrying an `ok` boolean.

```json
{ "error": "not_found", "message": "No site with id 41 on this account." }
```

**Branch on `error`, never on `message`.** The code is the contract and the sentence is for a person reading a transcript. The wording changes; the code does not.

## Scopes

Eight, and they have no hierarchy and no implication. `keywords:write` does not imply `keywords:read`. A key lists exactly what it holds.

| Scope | What it reaches |
|---|---|
| `sites:read` | Read sites and their settings. |
| `keywords:read` | Read keywords and their positions. |
| `keywords:write` | Add, edit, pause and delete keywords. |
| `audits:read` | Read a site's health score and its open findings. |
| `audits:write` | Queue an on-demand audit of a site. |
| `reports:read` | Read generated reports and their metadata. |
| `reports:write` | Queue an on-demand report for a site and a period. |
| `alerts:read` | Read the account's alert feed. |

Three of them name no address yet. They exist so a key minted today keeps working when the write routes ship, rather than everybody having to mint again.

## The routes

All GET. All under `https://solseo.com.au/api/v1/my/`. Nothing on this surface writes.

### `GET /api/v1/my/sites`

Scope: `sites:read`. List the sites on your account with keyword and competitor counts, last check and last crawl.

| Parameter | In | Type | Default | Range | What it does |
|---|---|---|---|---|---|
| `status` | query | string | `active` | `active`, `archived`, `all` | Which sites to include. |

### `GET /api/v1/my/sites/{id}`

Scope: `sites:read`. One site by id: settings, counts, last check and last crawl. Another account's id answers 404.

| Parameter | In | Type | Default | Range | What it does |
|---|---|---|---|---|---|
| `id` | path | integer | none | any | The site id, from the sites list. |

### `GET /api/v1/my/sites/{id}/keywords`

Scope: `keywords:read`. Keywords on a site with the latest position, the change against yesterday and 30 days ago, tags and the working-on flag. Paged, 200 at most.

| Parameter | In | Type | Default | Range | What it does |
|---|---|---|---|---|---|
| `id` | path | integer | none | any | The site id. |
| `limit` | query | integer | `50` | 1 to 200 | How many rows to return. |
| `offset` | query | integer | `0` | 0 to no maximum | How many rows to skip. Page three is offset 100 at limit 50. |
| `q` | query | string | none | any | Match the keyword text. |
| `tag` | query | string | none | any | One tag. |
| `status` | query | string | none | `active`, `paused`, `not_found` | Narrow to paused, active, or the ones not found on the page. |
| `working_on` | query | boolean | none | any | Only the keywords marked as being worked on. |
| `sort` | query | string | `position` | `position`, `change`, `best`, `keyword` | Priority keywords sort first whatever this says. |

### `GET /api/v1/my/keywords/{id}/history`

Scope: `keywords:read`. Daily positions for one keyword, 30 days by default and 365 at most. A day with no check is absent from the array rather than null.

| Parameter | In | Type | Default | Range | What it does |
|---|---|---|---|---|---|
| `id` | path | integer | none | any | The keyword id, from the keyword list. |
| `days` | query | integer | `30` | 1 to 365 | How far back to read, counting today. |

Held for up to 60 seconds server side.

### `GET /api/v1/my/sites/{id}/movers`

Scope: `keywords:read`. The biggest risers and fallers on a site over 7 or 30 days.

| Parameter | In | Type | Default | Range | What it does |
|---|---|---|---|---|---|
| `id` | path | integer | none | any | The site id. |
| `days` | query | integer | `7` | `7`, `30` | The window, 7 days or 30. |

Held for up to 60 seconds server side.

### `GET /api/v1/my/sites/{id}/audit`

Scope: `audits:read`. The site's health score, the previous one, and when each audit ran.

| Parameter | In | Type | Default | Range | What it does |
|---|---|---|---|---|---|
| `id` | path | integer | none | any | The site id. |

### `GET /api/v1/my/sites/{id}/findings`

Scope: `audits:read`. Open findings on a site from the latest audit: code, severity, page count, and the reason and fix text. Muted findings only when asked for.

| Parameter | In | Type | Default | Range | What it does |
|---|---|---|---|---|---|
| `id` | path | integer | none | any | The site id. |
| `severity` | query | string | none | `critical`, `high`, `medium`, `low`, `info` | One severity. design/07 named three values that have never been stored; these five are what is in the table. |
| `include_muted` | query | boolean | `false` | any | Include findings somebody muted. |

Held for up to 60 seconds server side.

### `GET /api/v1/my/sites/{id}/search-console`

Scope: `sites:read`. Clicks, impressions, CTR and average position over 7, 28 or 90 days against the period before it. 404 when the site has no property connected.

| Parameter | In | Type | Default | Range | What it does |
|---|---|---|---|---|---|
| `id` | path | integer | none | any | The site id. |
| `days` | query | integer | `28` | `7`, `28`, `90` | The window. |

Held for up to 60 seconds server side.

### `GET /api/v1/my/alerts`

Scope: `alerts:read`. The last alert events on the account: rank drops, downtime, indexation loss, audit regressions and finished reports.

| Parameter | In | Type | Default | Range | What it does |
|---|---|---|---|---|---|
| `limit` | query | integer | `25` | 1 to 100 | How many events. |
| `site_id` | query | integer | none | any | Narrow to one site. |
| `kind` | query | string | none | any | One alert kind. |

### `GET /api/v1/my/reports`

Scope: `reports:read`. Reports on the account or one site, with period, status, whether a share link exists and its hint. The address of a share link is never returned: only its digest is stored, so nothing can reconstruct it.

| Parameter | In | Type | Default | Range | What it does |
|---|---|---|---|---|---|
| `site_id` | query | integer | none | any | Narrow to one site. |
| `limit` | query | integer | `25` | 1 to 100 | How many reports. |

### `GET /api/v1/my/agents.md`

Scope: `sites:read`. The public AGENTS.md with your own account spliced in: your account id, your sites and their ids, the limits in force on your plan, and which scopes this key holds. Never your key.

## Paging and ranges

`limit` and `offset`, both on the query string. Every paged answer carries `total` beside the array, so you know whether there is another page without asking for one. `total` counts what the filter matched, not what was returned.

**A day with no check is absent from a history array rather than present as null.** Nothing was measured that day. A null would read as "not ranking", which is a different fact, and reporting it as one is the most likely way to mislead a customer with this API.

## Caching

Every answer carries a weak `ETag` over the body, and the response says `Cache-Control: private, max-age=0, must-revalidate` with `Vary: Authorization`.

Send the value back as `If-None-Match` and an unchanged answer is a `304` with no body.

**A `304` counts against the rate limit.** Revalidating is cheaper than reading. It is not free, so it is not a reason to poll every second.

## Rate limits

| | Per key | Per account | Per rolling 24 hours |
|---|---|---|---|
| Paid | 60 a minute | 120 a minute | 5000 |
| Free | capped by the account | 30 a minute | 500 |

The day is a **rolling 24 hours**, not a calendar day, so `retry_after` is a number that can be computed rather than guessed. Two keys on one account share the account window: it is an account limit, not a per-key limit with a bigger number on it.

A `429` carries `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`, and a body naming the bucket, the limit and the window. A refusal is recorded before it is sent, so ignoring `Retry-After` spends your own allowance.

## Every refusal

```json
401 { "error": "unauthorized", "message": "Missing or invalid bearer credential." }
403 { "error": "scope_required", "message": "This token does not have the keywords:read scope.", "required": ["keywords:read"], "held": ["sites:read"] }
403 { "error": "account_required", "message": "This key is not bound to an account..." }
404 { "error": "not_found", "message": "No site with id 41 on this account." }
422 { "error": "validation_failed", "message": "days must be 7 or 30." }
429 { "error": "rate_limited", "message": "Too many requests: account:8 allows 120 per 60s. Retry in 34 seconds.", "retry_after": 34, "bucket": "account:8", "limit": 120, "window": "60s" }
```

**A `404` means "not yours, or not there". It does not mean the thing was deleted.** Ids on this product are sequential, so answering `403` for a row belonging to somebody else would confirm that the row exists and let anyone count the product's sites and keywords. The two cases therefore answer identically, on purpose. If you tell a customer their site has been deleted because you saw a `404`, you will be wrong and they will believe you: say the id was not found on this key's account.

On this surface `403` is only ever about the credential: `scope_required` means add a scope or mint a new key, and `account_required` means the key is not bound to an account.

## Worked examples

**Which keywords slipped on site 12 this week?**

```
curl -s -H "Authorization: Bearer $SOLSEO_KEY" \
  "https://solseo.com.au/api/v1/my/sites/12/movers?days=7"
```

**What is wrong with the site?**

```
curl -s -H "Authorization: Bearer $SOLSEO_KEY" \
  "https://solseo.com.au/api/v1/my/sites/12/findings?severity=critical"
```

**One keyword, day by day, without re-downloading what you already have:**

```
curl -s -H "Authorization: Bearer $SOLSEO_KEY" \
  -H 'If-None-Match: W/"abc..."' \
  "https://solseo.com.au/api/v1/my/keywords/481/history?days=90"
```

## What this API will never do

- **Billing.** Money is changed by a person on a checkout page, not by a key.
- **Users and client logins.** Granting somebody access to an account is not a thing an automation should be able to do quietly.
- **Minting or revoking keys.** A key that could mint a key is a key that cannot be revoked.
- **Another account.** `account_id` is never read from a request here, by any credential. There is no admin override on this surface.
- **Adding a site.** It spends a plan allowance and it is done once, by somebody looking at the price of doing it.
- **Bulk result pages.** The stored pages behind a ranking are ours to keep, not a dataset to export.

## What is coming

Writes: adding and tagging keywords, asking for an audit, asking for a report, alert rules and competitors. Then a remote MCP server at `POST /mcp`, with a bearer key and a config block to paste into a client. `https://solseo.com.au/mcp` is the page that says which of those answers today.

## Where to ask

https://solseo.com.au/docs, or the address on that page. A bug in this document is a bug: it is generated from the server's own tables, so a disagreement between the two is worth reporting.
