# The Convoro API

A token-authenticated REST API at `/api/v1`. Design decisions and why:
[`docs/design/2026-08-04-rest-api.md`](../design/2026-08-04-rest-api.md).

## Authenticating

Every request carries a bearer token:

```bash
curl -H "Authorization: Bearer cvo_…" https://example.com/api/v1/me
```

If your server strips `Authorization`, `X-Api-Token: cvo_…` works too. **Do
not put the token in the query string** — it ends up in access logs, browser
history and referrer headers.

**The session cookie does not authenticate the API.** Being signed in to the
website in the same browser makes no difference: `/api/v1` reads the bearer
token and nothing else. That is deliberate — it is what makes CSRF impossible
here rather than merely defended against, and it is why these endpoints do not
need a CSRF token.

### Making a token

From **your account → API tokens** (`/settings/api`), or from the command
line:

```bash
php tools/convoro api:token you@example.com --name="Deploy bot" --abilities=read,write:content
```

**The token is shown once.** Only its SHA-256 is stored, so it cannot be
recovered — make another one if you lose it.

### Abilities

| Ability | Allows |
|---|---|
| `read` | Reading anything that member can already see |
| `write:content` | Creating topics and posts, and editing their own |
| `write:reactions` | Reacting to posts |
| `admin` | Administrator endpoints — **and** the member must actually be one |

Any `write:` ability implies `read`. Abilities only ever *narrow* what a
member can do: an `admin` token belonging to somebody who is not an
administrator simply cannot reach admin endpoints.

**A token can never do more than its owner.** Every request goes through the
same permission checks the website uses — a token belonging to a non-moderator
cannot post in a locked topic, and a suspended account's tokens stop working.

## Responses

```json
{ "data": { … } }
{ "data": [ … ], "meta": { "page": 1, "per_page": 25, "total": 97, "pages": 4 } }
{ "error": { "code": "not_found", "message": "No topic with that id." } }
```

Switch on `error.code`, never on `error.message` — the code is the contract,
the message is for a human reading a log and may be reworded.

| Code | Status | Means |
|---|---|---|
| `unauthenticated` | 401 | No token was sent |
| `invalid_token` | 401 | The token is wrong, revoked or expired |
| `account_suspended` | 403 | The token's account is banned |
| `forbidden` | 403 | Credentials are fine, the answer is still no |
| `not_found` | 404 | Not there — **or** there but hidden from you |
| `invalid_request` | 422 | The body was wrong; the message says how |

A hidden or deleted thing answers 404 rather than 403, matching the website: a
403 would confirm it exists.

Timestamps are ISO 8601 in UTC (`2026-08-04T18:15:01Z`).

## Paging

`?page=1&per_page=25`. `per_page` maxes out at 100 and is clamped rather than
refused — a client asking for 5,000 wants as many as it can have.

## Rate limiting

300 requests per minute, keyed by token, answering `429` past that. Two scripts
behind one office IP do not consume each other's budget.

## Endpoints

### `GET /api/v1/me`

The token's own account, whether it is a moderator, and what the token may do.
The only endpoint that returns an email address, and only ever your own.

### Forums

| | |
|---|---|
| `GET /forums` | Every forum and category |
| `GET /forums/{id}` | One forum |

### Topics

| | |
|---|---|
| `GET /topics` | Pinned first, then most recently active. Filter with `?forum_id=` |
| `GET /topics/{id}` | One topic |
| `GET /topics/{id}/posts` | Its posts, oldest first |
| `POST /topics` | Create one. Needs `write:content` |

```bash
curl -X POST https://example.com/api/v1/topics \
  -H "Authorization: Bearer cvo_…" \
  -H "Content-Type: application/json" \
  -d '{"forum_id": 1, "title": "Hello", "text": "First paragraph.\n\nSecond."}'
```

### Posts

| | |
|---|---|
| `GET /posts/{id}` | One post |
| `POST /topics/{id}/posts` | Reply. Needs `write:content` |
| `PATCH /posts/{id}` | Edit. Needs `write:content` |

Editing goes through the same rules as the website: your own post, not hidden,
topic not locked, inside the editing window if one is set. Moderators are not
limited. An edit within two minutes of posting does not bump the edit count.

### Members

| | |
|---|---|
| `GET /members/{id}` | A member's public profile. Never their email |

## Writing content

Both writing endpoints take either form:

- **`text`** — plain text. Blank lines become paragraphs. This is what a script
  has, and it exists so nobody has to build an editor document by hand.
- **`content`** — a TipTap document, which is what the website posts.

Both render through the same sanitiser the website uses, so anything posted
through the API is exactly as safe as anything posted through a browser. HTML
in `text` is escaped, not interpreted.

Posts come back as rendered `html` and plain `text`. The editor source is not
exposed — it is an implementation detail of the editor, not a contract.

## What is not here yet

- **Moderation endpoints.** Reading is safe to open first; letting a script
  lock and delete wants more thought about auditing than an ability flag gives.
- **Private messages.** Somebody's inbox is the last thing that should be
  reachable by a token pasted into a script.
- **Webhooks.** The natural next thing, and listed separately in the roadmap.
- **OAuth.** These tokens cover scripts and integrations you control. OAuth is
  for granting *other people's* software access to your account, which is a
  different feature and deserves its own design.
