# Design note — the REST API

**Dated 2026-08-04. Written before the code, per the provenance practice in
[ROADMAP.md](../../ROADMAP.md#proving-independence).**

The first of phase 6, and the one the rest of it leans on — webhooks, imports
and any mobile or integration work all want a way in that is not a browser.

---

## The job to be done

Something that is not a person with a browser needs to read the forum and post
to it: a bot, a script, a status page, another system. It needs to authenticate
as somebody, be limited to what that somebody may do, and be stoppable without
changing their password.

## Decision 1 — bearer tokens only, and never the session cookie

**An API request authenticates with a bearer token or not at all.** The session
cookie is ignored entirely on `/api/v1`.

This is the load-bearing security decision and it is worth being explicit about
why. If an API endpoint accepted the session cookie, a page on any other site
could make the browser call it with the member's credentials attached — that is
what CSRF is, and it is why the rest of Convoro checks a token on every form.
Refusing cookies removes the whole class: there is nothing for another site's
page to ride on, because a browser will not attach an `Authorization` header it
was never given.

That is also what makes it safe to exempt `/api/v1` from CSRF checking, and the
exemption is scoped to exactly that prefix — **not** to `/api`, because
`/api/users/search` already exists, is session-authenticated, and must keep its
protection.

## Decision 2 — a token is a credential, so it is stored like one

Generated once, shown once, and **only its SHA-256 is stored**. Losing the
database should not hand somebody a working key to every account, which is the
same reason password reset tokens are already hashed.

A token carries a **prefix** in clear — the first characters of the plaintext —
so a lookup is one indexed query rather than hashing every row, and so the
owner can tell two tokens apart in a list without either being readable.

Comparison is `hash_equals`. A token check that returns early on the first
wrong byte is a timing oracle, and this one is reachable by anybody.

## Decision 3 — a token can never do more than its owner

Every request resolves to a member and then goes through the same checks the
website uses. A token belonging to somebody who is not a moderator cannot
moderate; a token belonging to a banned member cannot post.

**Abilities narrow, they never widen.** Four:

| Ability | What it allows |
|---|---|
| `read` | Reading anything that member can already see |
| `write:content` | Creating topics and posts, editing their own |
| `write:reactions` | Reacting |
| `admin` | Endpoints that need an administrator — *and* the member must actually be one |

A token with `admin` held by a non-administrator is simply a token that cannot
reach the admin endpoints. The ability is a ceiling, not a grant.

## Decision 4 — one response shape, including for errors

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

The `code` is the machine-readable part and is the thing that will not change;
the `message` is for a human reading a log and may be reworded. A client that
switches on `message` is a client that breaks when somebody improves the
wording, so there is something better to switch on.

**Status codes mean what they normally mean.** 401 for no or bad credentials,
403 when the credentials are fine but the answer is still no, 404 when it is
not there — or when it is there but hidden from this member, matching the
website's behaviour, because a 403 confirms a thing exists.

## Decision 5 — versioned in the path, from the first release

`/api/v1/…`. Not because a v2 is planned, but because adding a version later
means every existing client is on an unversioned URL that now has to keep
working forever. The cheapest moment to have a version is before anybody is
using it.

## Decision 6 — what a member sees, they can revoke

Tokens are managed by the member who owns them, at `/settings/api`. Each row
shows the name, the abilities, when it was last used and its prefix — and can
be revoked.

**Last-used is written at most once a minute per token.** A write on every
request would turn every read into a read plus a write, which is a real cost
for a field nobody needs to the second.

Revoking is immediate and is not a delete: a revoked row stays, so "this token
was used at 3am and is now revoked" remains answerable. Administrators get the
same list across all members, because the person who needs to kill a leaked
token at 3am is not always its owner.

## Decision 7 — rate limited by token, not by IP

The existing limiter keys by signed-in user or IP. An API token is neither, so
it gets its own key: two scripts behind one office IP should not consume each
other's budget, and one runaway token should not lock out everybody else.

## What this does not do

- **No OAuth.** Tokens a member generates cover the actual need — scripts and
  integrations they control. OAuth is for granting *other people's* software
  access to your account, which is a different feature with a consent screen,
  client registration and refresh tokens, and it deserves its own note.
- **No write endpoints for moderation.** Reading is safe to open first; letting
  a script lock and delete things wants more thought about auditing than an
  ability flag gives.
- **No webhooks yet.** They are the natural next thing and are listed
  separately in the roadmap.
- **No API for private messages.** Somebody's inbox is the last thing that
  should be reachable by a token pasted into a script, and it should be an
  explicit opt-in ability if it is ever wanted.
