# API

The HTTP surface: authentication, the workspace header, idempotency, errors and OpenAPI.

## Base URL and discovery

```text
https://api.fastpad.ai
```

| Path | Serves |
| --- | --- |
| `GET /openapi.json` | the OpenAPI 3.1 document, strong-ETagged — a polling client costs a 304 |
| `GET /.well-known/oauth-protected-resource` | RFC 9728 protected-resource metadata |
| `GET /health` | liveness plus a database round trip |
| `POST /mcp` | the agent tool surface |

## Authenticating

```bash
curl https://api.fastpad.ai/v1/notes \
  -H 'Authorization: Bearer fpk_live_…' \
  -H 'x-fastpad-workspace: 01JB2K…'
```

Everything is authenticated by default. The only routes reachable without a credential are health, the OpenAPI document, the well-known metadata, the two device-login routes, and published notes under `/v1/public/`.

Requests are rate limited per credential. Each agent has its own per-minute limit, 120 unless it was set otherwise. Each signed-in person may make 1,200 requests a minute, and ask 20 questions a minute on `POST /v1/ask`, shared across their session and every CLI token they hold. The two device-login routes have no credential to count against, so they are limited per address: 20 starts and 60 polls a minute. A refusal is a 429 with `retry_after_ms`; wait that long and try again.

`x-fastpad-workspace` names which of your workspaces to act in. It is not an authorization decision: naming a workspace you are not a member of returns nothing rather than someone else’s notes.

## Request shape

- Identifiers are ULIDs — 26 characters, sortable by creation time.
- Timestamps are ISO 8601 with an offset.
- Lists are keyset-paginated; follow `next_cursor` rather than counting pages.
- Bodies are capped at 64 KiB on public routes, 8 MiB on authenticated routes and `/mcp`. A body over 64 KiB must finish arriving within 60 seconds and, after the first 5 seconds, keep up a minimum average rate that scales with its size: 64 KiB a second for a maximal 8 MiB body, proportionally less for a smaller one. A body that falls short gets a retryable 408, whose `min_bytes_per_sec` says which floor applied. Large imports are presigned to object storage instead.
- No array in a request may hold more than 10,000 elements, and no request more than 50,000 values in all. A 422 lists at most 50 `field_errors`, and `field_errors_truncated` counts the rest.
- Every mutation takes `idempotencyKey`. Send one.

An idempotency key replays the first response verbatim. A body that differs under the same key is a 409, never a quiet replay of the wrong answer.

## Errors

Every failure is `application/problem+json` per RFC 9457, with a stable `type` URI, `retryable`, `retry_after_ms` where it applies, `field_errors[]` and a `docs_url` that resolves.

```json
{
  "type": "https://fastpad.ai/errors/stale-frontier",
  "title": "Stale frontier",
  "status": 409,
  "retryable": true,
  "docs_url": "https://fastpad.ai/errors/stale-frontier"
}
```

Every problem names the next call to make. The full catalog is at [fastpad.ai/errors](/errors).

## Writing a note

Note content is a collaborative document, so a write is a batch of CRDT operations plus the state you computed them against.

```text
POST /v1/notes/{id}/ops
{ "ops": "<base64 Loro update>", "peerId": "…", "expectedFrontier": "fp1:…", "idempotencyKey": "…" }
```

If the note moved since you read it, you get `stale-frontier` (409) naming both states — re-read and recompute rather than overwriting. The operation log and the queryable projection are written in one transaction, so a write is visible to a search the instant it is committed.

One save is checked before anything imports it. `ops` must be a Loro update export, never a snapshot, of at most 1 MiB once decoded. It may add at most 100,000 atoms of change the note does not already hold (an atom is one character inserted or deleted), or 200,000 when it is one peer’s edit built on the note’s current version; ops the note already has are not counted. A save past any of these gets a 422 `validation-failed` that is not retryable and names `max_bytes`, `max_atoms` or `mode`. The same bytes are refused every time, so keep the change, re-read the note, and send it again as smaller updates, each exported from the version the server last acknowledged.

> **Note:** If you are an agent, do not use this route. Use `fastpad_propose`, which produces a reviewable diff. Direct writes are for a client acting on behalf of the person at the keyboard.
