---
name: api
description: |
  HTTP API reference for Anchorify. Covers authentication, all
  stable `/api/v1/*` endpoints (shares, comments, reactions, listing),
  error shapes, rate limits, pagination, and the visibility model.
  Every endpoint includes a copy-pasteable `curl` example. Use this
  page when integrating against the service from a script, another
  CLI, or a third-party app.
---

# Anchorify API

This is the HTTP API reference for **anchorify.io**. The same
service powers the website, the `anchorify` CLI, and any
third-party integration. The stable surface is everything under
`/api/v1/`; older paths are kept as aliases (see
[Versioning](#versioning)).

## Overview

- **Base URL**: `https://anchorify.io`
- **Local dev**: `http://localhost:3737` (defaults — see
  [`CLAUDE.md`](https://github.com/) in the repo)
- **Request bodies**: `application/json` unless noted (form-encoded
  endpoints are explicitly called out)
- **Response bodies**: `application/json` for `/api/*`; `text/plain;
  charset=utf-8` for `…/source` raw endpoints; `text/html` for the
  human-facing render path (`GET /:user/:slug`)
- **Timestamps**: integer milliseconds since the Unix epoch (UTC)
- **IDs**: opaque 8-character base36 strings for shares
  (e.g. `k3p9x2af`); UUIDs for comments

If your shell does not have `$REPO_SHARE_TOKEN` exported, every example
below assumes you run something like:

```bash
export REPO_SHARE_TOKEN=repo_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

You can mint a per-user token at
<https://anchorify.io/dashboard>.

## Authentication

The service supports three auth modes; each endpoint accepts a
specific subset.

### 1. Per-user token (recommended)

A bearer token tied to a single signed-up user. Generate one from the
dashboard. Format: `repo_` followed by 32 hex characters.

```
Authorization: Bearer repo_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

This is the only auth mode for ownership-scoped operations
(visibility, password, rename, analytics, delete via `DELETE`,
comments, reactions). It is also the recommended mode for `POST /`
and listing endpoints.

### 2. Cookie session (browser only)

A signed `rs_session` cookie set after Google OAuth at
`/auth/google`. 30-day TTL. Used by the `/dashboard` UI and by any
form-encoded endpoint under `/api/shares/:id/...` (the dashboard's
form posts). Not generally usable by scripts; if you are integrating
programmatically, use a per-user token instead.

### 3. Legacy admin bearer (`REPO_SHARE_TOKEN`)

A single environment-injected bearer for the original single-tenant
era. Accepts:

```
Authorization: Bearer <REPO_SHARE_TOKEN>
```

…or HTTP Basic with any username and the token as the password.

This token still owns:

- `POST /` (writes — back-door without per-user identity; resulting
  shares have `user_id = NULL`)
- `GET /_list`, `GET /_dashboard` (cross-user admin view)
- `GET /api/me`, `GET /api/v1/me` (returns `{kind: "legacy"}`)
- `POST /api/cli/delete`, `POST /api/v1/cli/delete` (id-based delete
  only — slug-based delete requires a per-user token)

It is **rejected** by every other `/api/v1/*` endpoint. Anywhere a
docstring below says "per-user token", the legacy bearer will get a
`401`.

## Rate limits

Write endpoints share an in-memory per-IP token bucket of
`WRITE_RATE_PER_MIN` (default **30 requests/minute**). When the
bucket is empty the server returns:

```
HTTP/1.1 429 Too Many Requests
Retry-After: <seconds>
Content-Type: application/json

{"error":"rate limit exceeded","retry_after":<seconds>}
```

`Retry-After` is in integer seconds and matches `retry_after` in the
JSON body. Wait that long, then retry.

Endpoints behind the limiter:

- `POST /` (publish/update)
- `POST /unlock` (password-gate unlock)
- `POST /api/v1/shares/:id/comments`
- `POST /api/v1/reactions`

Reads are not rate-limited beyond what your network and Postgres can
sustain.

## Errors

All `/api/*` errors are JSON with at least an `error` field. Some
also include `reason` (human-readable explanation), `slug`, `id`, or
`limit`.

| Status | Meaning                                                                 |
| ------ | ----------------------------------------------------------------------- |
| `400`  | Bad request — invalid JSON, missing required field, invalid slug, etc.  |
| `401`  | Auth required, missing, or invalid                                      |
| `403`  | Authenticated but not allowed (e.g. fetching another user's private share) |
| `404`  | Not found, soft-deleted, or owned by someone else                       |
| `409`  | Slug taken                                                              |
| `410`  | Share was deleted (`GET /:id` only — render path)                       |
| `413`  | Payload too large (content exceeds `MAX_SHARE_BYTES`)                   |
| `429`  | Rate limit hit OR per-user share cap reached                            |

Common shapes:

```json
{"error": "unauthorized"}
{"error": "not found"}
{"error": "not found or not owned", "id": "k3p9x2af"}
{"error": "invalid slug", "reason": "must be lowercase alphanumeric + hyphens, 1-60 chars, no leading/trailing dash"}
{"error": "slug taken", "slug": "q1-report"}
{"error": "file too large", "limit": 1048576}
{"error": "rate limit exceeded", "retry_after": 7}
```

For `404`, `not found` and `not found or not owned` are both used
deliberately: the second variant is returned by ownership-scoped
endpoints to avoid leaking whether a share exists under another
user's account.

## Endpoints — Shares

### `POST /` — publish or update a share

Auth: per-user token, **or** legacy admin bearer (back-door).

This is the main publish endpoint, used by the `anchorify` CLI.
Body branches on which key is set:

| Key       | Behavior                                                                  |
| --------- | ------------------------------------------------------------------------- |
| `id`      | UPDATE the existing share at this id (ownership-scoped for per-user token) |
| `slug`    | INSERT a new share at this slug (`409` if taken, `400` if slug invalid)   |
| neither   | INSERT a new share at a random 8-char id (`id == slug`)                   |

Body fields:

```json
{
  "filename": "string (optional) — display name + extension hint for content_type detection",
  "content":  "string (required) — file contents; UTF-8; <= MAX_SHARE_BYTES (1 MiB default)",
  "id":       "string (optional) — update target",
  "slug":     "string (optional) — chosen slug for INSERT",
  "password": "string (optional) — '' clears, non-empty sets a render-time password",
  "visibility": "string (optional) — 'public'|'unlisted'|'members'; legacy 'secret' = 'unlisted'; default 'unlisted' on INSERT",
  "type":     "string (optional) — 'markdown'|'code'|'json'|'yaml'|'csv'|'tsv'|'html'|'slides_marp'|'slides_reveal' override"
}
```

Public + password is rejected (`400` — see [Visibility model](#visibility-model)).

Success response:

```json
{
  "id": "k3p9x2af",
  "url": "https://anchorify.io/alice/untitled/q1-report",
  "warnings": []
}
```

`warnings` is always present (V3.3) — an array of authoring issues the analyzer flagged in the payload. Empty array means the file will preview cleanly. See [Lint API](#post-apiv1lint--check-content-without-publishing) for the warning shape and the full kind catalog. The publish itself succeeds regardless; the caller decides whether to surface warnings or retry with a fixed file.

Example — publish a new file at a chosen slug:

```bash
curl -sX POST https://anchorify.io/ \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"filename":"q1.md","content":"# Q1 report\n\nLooks great.\n","slug":"q1-report"}'
```

Example — update an existing share:

```bash
curl -sX POST https://anchorify.io/ \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"id":"k3p9x2af","content":"# Q1 report v2\n"}'
```

### `GET /:id` — public render (HTML)

Auth: none.

Renders the share as HTML with GitHub-flavored typography. Returns
`410` if the share has been soft-deleted, `404` if it never existed.
If the share has a per-user owner, redirects (`301`) to its canonical
`/:username/:slug` URL. If the share has a password, returns the
unlock page until the visitor posts the right password to
`POST /unlock`.

```bash
curl -sI https://anchorify.io/k3p9x2af
```

For the **raw markdown / source text**, use the `/source` endpoint
below — that's the API surface and the recommended path for scripts.

### `GET /api/v1/shares/:id/source` — raw source

Auth: none for public shares; ownership required (cookie session OR
per-user token) for non-public shares.

Returns the stored content as `text/plain; charset=utf-8`. Does not
increment the view counter (this is the API path, not the render
path). For a non-public share, a non-owner gets `403`; a missing or
soft-deleted share gets `404`.

```bash
curl -s https://anchorify.io/api/v1/shares/k3p9x2af/source \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"
```

### `GET /api/v1/users/:username/shares/:slug/source` — cross-user public source

Auth: none. Public shares only — unlisted and members shares get `403`, missing
shares get `404`. Useful for fetching another user's published
markdown without knowing the internal share id.

```bash
curl -s https://anchorify.io/api/v1/users/alice/shares/q1-report/source
```

### `PATCH /api/v1/shares/:id` — rename a share's slug

Auth: per-user token or cookie session (owner).

Per-user shares only — legacy `/:id` shares cannot be renamed because
they have no `:username/:slug` URL.

Body:

```json
{"slug": "new-slug"}
```

The new slug is validated against the same regex as `POST /` (see
[`/docs/slug-rules`](/docs/slug-rules)). Same-slug rename is a no-op
(`200` with `unchanged: true`). On success the old slug is registered
as a `301` redirect under your username, so existing links keep
working. Errors: `400` invalid slug, `404` not owned, `409` slug
already taken by another of your shares.

```bash
curl -sX PATCH https://anchorify.io/api/v1/shares/k3p9x2af \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"slug":"q1-report-v2"}'
```

Success:

```json
{"slug": "q1-report-v2", "url": "https://anchorify.io/alice/q1-report-v2"}
```

### `POST /api/v1/shares/:id/visibility` — set the visibility tier

Auth: per-user token or cookie session (owner).

Body:

```json
{"visibility": "public"}
```

Accepted values: `"public"`, `"unlisted"`, `"members"`. The legacy
value `"secret"` is accepted and treated as `"unlisted"`. Same-state
is a no-op (`200` with `unchanged: true`).

The `unlisted → public` transition is rejected (`400`) if the share
currently has a password — public shares cannot have a password.
Add `?force=1` to clear the password and complete the flip in one
call:

```bash
curl -sX POST 'https://anchorify.io/api/v1/shares/k3p9x2af/visibility?force=1' \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"visibility":"public"}'
```

Response when force-clearing the password:

```json
{"visibility": "public", "password_cleared": true}
```

A flip into `members` automatically clears any password (members
visibility uses org/project membership as the gate). The response
includes `password_cleared: true` if there was one.

### `POST /api/v1/shares/:id/password` — set or clear a password

Auth: per-user token or cookie session (owner).

Body:

```json
{"password": "hunter2"}
```

Empty string clears the password. The hash is stored via
`Bun.password.hash`; the cleartext is never persisted. Setting a
password on a `public` or `members` share is rejected (`400`) — flip
the share to `unlisted` first.

```bash
# Set:
curl -sX POST https://anchorify.io/api/v1/shares/k3p9x2af/password \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"password":"hunter2"}'

# Clear:
curl -sX POST https://anchorify.io/api/v1/shares/k3p9x2af/password \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"password":""}'
```

Response:

```json
{"ok": true, "has_password": true}
```

### `POST /api/v1/shares/:id/link-permission` — set the link-permission tier

Auth: per-user token or cookie session (owner).

Link-permission controls what an anonymous URL-holder can do on a
share — independently of the share's visibility tier. Adding the
`can_comment` or `can_suggest` tier on an `unlisted` share is the
"Google Docs anyone-with-the-link can comment" affordance; it does
NOT widen who can read the share (visibility owns that). See
[Link permissions](/docs/link-permissions) for the full matrix.

Body:

```json
{"link_permission": "can_comment"}
```

Accepted values: `"none"` (default), `"can_view"`, `"can_comment"`,
`"can_suggest"`. Each tier additively unlocks the next:

| tier | What URL-holders can do |
| ---- | ----------------------- |
| `none` | Read only (subject to visibility). |
| `can_view` | Same as `none` today (reserved — surfaces a "shared with you" pill in the renderer). |
| `can_comment` | Post comments + reactions without signing in (anon comments still require completing a magic-link sign-in). |
| `can_suggest` | Submit suggested changes (rate-limited; goes to the owner's suggestions inbox). |

Invalid value: `400 {"error":"link_permission must be one of: none, can_view, can_comment, can_suggest"}`.

Some `(visibility, link_permission)` combinations are rejected as
contradictory at the service layer (e.g. a public share with
`link_permission='none'` is allowed; a members share with
`can_comment` is allowed; raw-conflicting combinations return
`400 {"error":"<reason>"}` from the service). Same-state flips
return `200` with `unchanged: true`.

```bash
curl -sX POST https://anchorify.io/api/v1/shares/k3p9x2af/link-permission \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"link_permission":"can_comment"}'
```

Response — change applied:

```json
{"link_permission": "can_comment", "from": "none"}
```

Response — no change:

```json
{"link_permission": "can_comment", "unchanged": true}
```

### `POST /api/v1/shares/:id/content-type` — change how a share renders

Auth: per-user token or cookie session (owner).

Body:

```json
{"content_type": "code"}
```

Allowed values: `markdown`, `code`, `json`, `yaml`, `csv`, `tsv`,
`html`. Anything else is `400`. Non-owner requests get `404` to avoid
leaking existence.

Useful for fixing shares that were published via the web "Paste" flow
without a filename and got bucketed as markdown by default — Python /
JSON / CSV pastes would render through `marked.parse()` and look
wrong (e.g. `__name__` in Python becoming bold).

```bash
curl -sX POST https://anchorify.io/api/v1/shares/k3p9x2af/content-type \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"content_type":"code"}'
```

Response:

```json
{"ok": true, "content_type": "code"}
```

### `DELETE /api/v1/shares/:id` — soft-delete a share

Auth: per-user token or cookie session (owner).

Marks the share as deleted (`deleted_at = now()`). The URL starts
returning `410 Gone` on the render path; the row is retained for
audit. Idempotent — deleting an already-deleted share returns `404`.

```bash
curl -sX DELETE https://anchorify.io/api/v1/shares/k3p9x2af \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"
```

Response:

```json
{"deleted": true, "id": "k3p9x2af", "slug": "q1-report",
 "url": "https://anchorify.io/alice/q1-report"}
```

### `GET /api/v1/shares/:id/analytics` — per-share view metrics

Auth: per-user token or cookie session (owner).

Returns aggregate view metrics plus a 30-day daily breakdown
(zero-filled, ascending, UTC dates). Non-owner requests get `404`
to avoid leaking existence.

Response:

```json
{
  "total": 42,
  "unique_ips": 17,
  "last_viewed_at": 1717449600000,
  "daily": [
    {"date": "2026-04-10", "count": 0},
    {"date": "2026-04-11", "count": 3},
    "...28 more entries..."
  ]
}
```

```bash
curl -s https://anchorify.io/api/v1/shares/k3p9x2af/analytics \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"
```

### `PUT /api/v1/shares/:id/theme` — set per-share theme overrides

Auth: per-user token or cookie session (owner). See
[Themes](/docs/themes) for the cascade (`share > project > org`).

Body — a `theme` JSON object validated against the shared schema in
`src/services/theme.ts`. Recognized fields include `primary_color`,
`background_color`, `text_color`, `font_family`, etc. Raw CSS is
rejected. All fields are optional; omit a field to leave it
unchanged at the org/project level.

```bash
curl -sX PUT https://anchorify.io/api/v1/shares/k3p9x2af/theme \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"primary_color":"#3b82f6","background_color":"#fdfdfc"}'
```

Response:

```json
{"ok": true, "theme": {"primary_color": "#3b82f6", "background_color": "#fdfdfc"}}
```

Errors: `400 {"error":"<reason>"}` on validation failure (invalid
hex, unknown field, raw CSS). `404` when the share isn't owned by
the caller — same existence-leak prevention as elsewhere.

### `DELETE /api/v1/shares/:id/theme` — clear per-share overrides

Auth: per-user token or cookie session (owner).

Clears any per-share theme row; the renderer falls back to the
project- and org-level themes. Returns `204` with no body.

### `POST /api/v1/shares/:id/members` — grant share-editor access

Auth: per-user token or cookie session (owner — admin of the share's
owner org).

Adds a user as an editor of a single share (a row in `share_members`).
The grant is per-share, narrower than a project-member editor —
useful when you want to hand someone edit access to one deliverable
without exposing the whole project.

Body:

```json
{"user_email": "bob@example.com"}
```

Response:

```json
{
  "added": true,
  "already_member": false,
  "user": {"id": "<uuid>", "username": "bob"}
}
```

`already_member: true` with `added: false` is the idempotent re-add
case. The new editor receives a `member.added_to_share` notification.

Errors:

- `400 {"error":"user_email required"}` if the email is missing/empty.
- `404 {"error":"no account","reason":"send an invite instead"}` when
  the target email has no Anchorify account — fall back to the
  invite flow (`/api/v1/orgs/:slug/invites`).
- `404` for share-not-found or caller-not-owner (existence-leak guard).

### `DELETE /api/v1/shares/:id/members/:userId` — revoke share-editor

Auth: per-user token or cookie session (owner). The path parameter
is the **user id**, not the username — pull it from a prior
`POST …/members` response or from the share-settings page.

Response:

```json
{"removed": true, "user": {"id": "<uuid>", "username": "bob"}}
```

`404 {"error":"not a member"}` if the user wasn't a share editor.

### `POST /api/v1/cli/delete` — delete by id or slug (CLI alias)

Auth: per-user token, OR legacy admin bearer (id-based only).

Body:

```json
{"id": "k3p9x2af"}
```

…or `{"slug": "q1-report"}` (per-user token only — legacy bearer
gets `400` for slug-based delete because it has no user scope).

This duplicates `DELETE /api/v1/shares/:id` for the CLI's use case
(slug-based delete + legacy-bearer compatibility). Prefer
`DELETE /api/v1/shares/:id` from new code.

```bash
curl -sX POST https://anchorify.io/api/v1/cli/delete \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"slug":"q1-report"}'
```

## Endpoints — Lint

### `POST /api/v1/lint` — check content without publishing

Auth: per-user token or legacy bearer (same gate as `POST /`).

Run the V3.3 content analyzer against a payload. Use this before
publishing so an agent can surface authoring issues to the user, or
gate `POST /` on a clean lint. Pure compute — no persistence, no row
created.

Body fields:

```json
{
  "content":      "string (required) — file contents; UTF-8; <= MAX_SHARE_BYTES",
  "filename":     "string (optional) — drives filename-extension detection",
  "content_type": "string (optional) — override; one of markdown/code/json/yaml/csv/tsv/html/slides_marp/slides_reveal"
}
```

Success response:

```json
{
  "effective_content_type": "slides_marp",
  "warnings": [
    {
      "kind": "slide_deck_no_separators",
      "severity": "warn",
      "message": "This deck has no `---` slide separators after the frontmatter — everything will render as a single slide.",
      "doc_url": "/docs/slides#slide-separators",
      "location": { "line": 4 }
    }
  ]
}
```

`effective_content_type` reflects the V3.2 sniffer (filename extension +
frontmatter opt-in) so the caller knows what type the publish would land
on. `warnings` is always present — an empty array means the file is
clean.

Warning kinds (V3.3) and severities:

| kind                              | severity | Cause |
| --------------------------------- | -------- | ----- |
| `empty`                           | warn     | Content is empty / whitespace only. |
| `slide_frontmatter_unrecognized`  | error    | File has `marp: true` / `reveal: true` but the frontmatter is malformed. |
| `slide_deck_no_separators`        | warn     | Marp/reveal deck with no `---` slide separators. |
| `unclosed_frontmatter`            | warn     | Opens with `---` but the block never closes. |
| `json_parse_failed`               | error    | JSON is unparseable (after trailing-comma recovery). |
| `csv_likely_wrong_delimiter`      | info     | `.csv` file with no commas in the header row. |
| `markdown_looks_like_html`        | warn     | Markdown starting with `<!DOCTYPE>` / `<html>`. |
| `code_no_language`                | info     | Code content with no filename / extension. |
| `binary_under_text_type`          | error    | NUL bytes detected in content stored as a text type. |

```bash
curl -sX POST https://anchorify.io/api/v1/lint \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"content":"---\nmarp: true\n# slide 1","filename":"deck.md"}'
```

Errors: `400` on missing `content` or invalid `content_type`; `413`
when the payload exceeds `MAX_SHARE_BYTES`; `401` without auth.

See [Lint API guide](/docs/lint) for the CLI counterpart and example
flows.

## Endpoints — Agent edit (BYOK)

The agent-edit surface lets a user run an LLM rewrite of a document body
using **their own** Anthropic API key. Anchorify never bills for inference — the
key is supplied per-user and stored encrypted at rest. Used by the
in-browser "Ask the agent" editor pane.

### `GET /api/v1/me/anthropic-key` — does the caller have a key set?

Auth: per-user token or cookie session.

Response: `{ "hasKey": true }` or `{ "hasKey": false }`. The raw key is
never returned over the wire after it's set.

### `POST /api/v1/me/anthropic-key` — set the caller's key

Auth: per-user token or cookie session.

Body: `{ "key": "sk-ant-..." }`.

Response: `204 No Content` on success. `400` if the key is empty or the
provider rejects it during the round-trip key probe.

### `DELETE /api/v1/me/anthropic-key` — clear the caller's key

Auth: per-user token or cookie session.

Response: `204 No Content`. Idempotent — clearing an already-cleared key
is a no-op.

### `POST /api/v1/agent-edit` — run an LLM rewrite of a document

Auth: per-user token or cookie session. Legacy operator bearer is
**rejected** — agent-edit is tied to per-user key storage.

Body:

```json
{
  "content": "<full document body>",
  "instruction": "tighten the intro; add a TL;DR section at the top",
  "content_type": "text/markdown"   // optional, defaults to "text/markdown"
}
```

Response: `200` with `{ "content": "<revised document body>" }`. The
returned content fully replaces the original — the caller is responsible
for handing the result to a follow-up `POST /` (with
`version_source: "agent_edit"`) if they want to persist the change as a
new version.

Errors:
- `400` — invalid JSON, missing `content` or `instruction`, or
  provider rejected the key during the round-trip probe (on
  `POST /api/v1/me/anthropic-key`).
- `401` — unauthenticated, legacy operator bearer used, or Anthropic
  rejected the stored key (`error: "anthropic_auth_failed"`); the
  caller should prompt the user to re-enter their key.
- `413` — payload exceeds `MAX_SHARE_BYTES`.
- `428` — caller has no Anthropic key set
  (`error: "no_anthropic_key"`); surfaces a hint to set one in
  Settings → Anchorify.
- `429` — per-user rate limit (keyed on `userId`, not IP).
- `502` — upstream Anthropic error (network failure, non-2xx
  non-401 response, or empty completion). Error body carries the
  upstream message for caller-side debugging.

Audit: every successful invocation emits a `share.agent_invoked` event
(no shareId — the call is share-less; the save side that follows emits
`share.agent_edit` against the share).

## Endpoints — Listing & identity

### `GET /api/v1/me` — token introspection

Auth: per-user token or legacy bearer.

Returns the identity of the bearer. Used by `anchorify login` to
verify a freshly-pasted token.

Per-user response:

```json
{"kind": "user", "username": "alice", "userId": "<uuid>"}
```

Legacy response:

```json
{"kind": "legacy"}
```

```bash
curl -s https://anchorify.io/api/v1/me \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"
```

### `GET /api/v1/me/shares` — list your shares

Auth: per-user token (returns shares owned by that user) or legacy
bearer (returns the global admin list).

Sorted by `updated_at` descending, tie-broken by `id` descending.

Query parameters:

- `limit` — page size, 1..100, default 50 (clamped to 100)
- `cursor` — opaque pagination cursor from a prior `next_cursor`

See [Pagination](#pagination) for cursor mechanics.

```bash
curl -s 'https://anchorify.io/api/v1/me/shares?limit=50' \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"
```

Response:

```json
{
  "items": [
    {
      "id": "k3p9x2af",
      "slug": "q1-report",
      "filename": "q1.md",
      "url": "https://anchorify.io/alice/q1-report",
      "updated_at": 1717449600000
    }
  ],
  "next_cursor": "MTcxNzQ0OTYwMDAwMF9rM3A5eDJhZg=="
}
```

`next_cursor` is `null` when there are no more pages.

The legacy `GET /_list` endpoint (still used by the `anchorify`
CLI) returns the same row shape but without pagination — every share
in a single `{items: [...]}` response, no `next_cursor` field. New
integrations should use `/api/v1/me/shares` and follow the cursor.

### `GET /api/v1/me/shares/count` — number of shares you own

Auth: cookie session (this endpoint is used by the dashboard).

```bash
curl -s https://anchorify.io/api/v1/me/shares/count \
  -b "rs_session=<session-cookie>"
```

Response:

```json
{"count": 17}
```

## Endpoints — Comments

### `GET /api/v1/shares/:id/comments` — list comments

Auth: none for `public` and `unlisted` shares. `members` shares
require a session for the org admin or one of the project's viewers.
Within those access rules, anyone who can read the share can read its
comments.

Query parameters:

- `limit` — page size, 1..100, default 50
- `cursor` — opaque pagination cursor from a prior `next_cursor`

Sort order: newest first (`created_at DESC, id DESC`). See
[Pagination](#pagination) for cursor mechanics.

```bash
curl -s 'https://anchorify.io/api/v1/shares/k3p9x2af/comments?limit=20'
```

Response:

```json
{
  "items": [
    {
      "id": "<uuid>",
      "user": {"username": "bob"},
      "body": "Looks great!",
      "created_at": 1717449600000,
      "reactions": {"thumbs_up": 2}
    }
  ],
  "next_cursor": "MTcxNzQ0OTYwMDAwMF88dXVpZD4="
}
```

`next_cursor` is `null` when there are no more pages.

### `POST /api/v1/shares/:id/comments` — post a comment

Auth: per-user token or cookie session (any signed-in user).

Rate-limited (see [Rate limits](#rate-limits)). Body must be 1..2000
characters after trimming whitespace.

```bash
curl -sX POST https://anchorify.io/api/v1/shares/k3p9x2af/comments \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body":"Nice writeup."}'
```

Response (only the new comment, not the full list):

```json
{
  "id": "<uuid>",
  "body": "Nice writeup.",
  "created_at": 1717449600000,
  "user": {"username": "alice"}
}
```

## Endpoints — Reactions

### `POST /api/v1/reactions` — toggle a reaction

Auth: per-user token or cookie session.

Toggles a reaction on a share or comment. If the same `(target,
user, emoji)` row already exists, it is removed (`toggled:
"removed"`); otherwise it is added (`toggled: "added"`).

Body:

```json
{
  "target_type": "share",
  "target_id":   "k3p9x2af",
  "emoji":       "thumbs_up"
}
```

Allowed `target_type`: `share`, `comment`.
Allowed `emoji`: `thumbs_up`, `thumbs_down`, `laugh`, `celebrate`,
`confused`, `heart`.

```bash
curl -sX POST https://anchorify.io/api/v1/reactions \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"target_type":"share","target_id":"k3p9x2af","emoji":"heart"}'
```

Response:

```json
{"toggled": "added", "counts": {"heart": 1, "thumbs_up": 4}}
```

`counts` covers every emoji currently on the target with count > 0;
zero-count emojis are omitted so clients can render straight from
the map.

### `GET /api/v1/reactions` — read reaction counts

Auth: none.

Query parameters:

- `target_type` — `share` or `comment`
- `target_id` — share id or comment uuid

```bash
curl -s 'https://anchorify.io/api/v1/reactions?target_type=share&target_id=k3p9x2af'
```

Response:

```json
{"counts": {"heart": 1, "thumbs_up": 4}}
```

## Endpoints — Notifications

In-app notifications surface state changes that touched the signed-in
user — a comment posted on one of their shares, an access request
arriving in their inbox, a suggestion being submitted against a share
they own, a new editor/viewer grant. The bell icon in the dashboard
top bar polls `unread-count`; the dropdown calls `GET …/notifications`
and the dropdown's "Mark all read" action calls `POST …/read` with
`{all: true}`.

All three endpoints require an authenticated user (cookie session OR
per-user bearer). The legacy operator bearer is rejected — there is
no notification stream for the operator persona.

### `GET /api/v1/notifications` — list notifications

Auth: per-user token or cookie session.

Sorted newest first (`created_at DESC, id DESC`). Cursor pagination
is keyset on `created_at` (milliseconds).

Query parameters:

- `limit` — page size, 1..200, default 50
- `before` — ms-epoch cursor from a prior `next_cursor`
- `unread_only` — `1` or `true` to return only rows with `read_at IS NULL`

```bash
curl -s 'https://anchorify.io/api/v1/notifications?limit=50&unread_only=1' \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"
```

Response:

```json
{
  "items": [
    {
      "id": "k3p9x2af",
      "kind": "comment.created",
      "actor": {"username": "bob"},
      "subject_type": "share",
      "subject_id": "k3p9x2af",
      "payload": {
        "title": "New comment on q1-report",
        "body": "Bob commented: \"Nice writeup.\"",
        "link": "/alice/untitled/q1-report#comments"
      },
      "read_at": null,
      "created_at": 1717449600000
    }
  ],
  "next_cursor": 1717449590000
}
```

Notification `kind` values:

| kind | When it fires |
| ---- | ------------- |
| `comment.created` | Someone posted a comment on a share you own (skips self). |
| `access_request.created` | A visitor requested access to one of your shares. |
| `access_request.approved` | Your access request was approved. |
| `access_request.denied` | Your access request was denied. |
| `suggestion.submitted` | A suggested-change version was submitted on a share you own. |
| `suggestion.approved` | Your suggested change was approved and merged. |
| `suggestion.rejected` | Your suggested change was rejected. |
| `member.added_to_org` | You were added as an org admin. |
| `member.added_to_project` | You were added as a project viewer or editor. |
| `member.added_to_share` | You were granted editor access on a single share. |

`payload` carries the title/body/link rendered in the bell dropdown.
`actor` is `null` for system-triggered notifications (e.g. an
access-request decision is attributed to the deciding admin, but the
viewer/editor grant materializing on approve is attributed to the
admin too).

### `GET /api/v1/notifications/unread-count` — bell-icon badge

Auth: per-user token or cookie session.

```bash
curl -s https://anchorify.io/api/v1/notifications/unread-count \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"
```

Response:

```json
{"count": 3}
```

### `POST /api/v1/notifications/read` — mark read

Auth: per-user token or cookie session.

Body — one of:

```json
{"ids": ["k3p9x2af", "a8m2qz0v"]}
```

…or:

```json
{"all": true}
```

`ids` marks just those rows read. `all: true` marks every unread
notification for the caller read. Non-existent ids are silently
ignored — the response reports how many rows actually flipped.

Response:

```json
{"ok": true, "marked": 2}
```

Errors: `400 {"error":"must supply { ids: string[] } or { all: true }"}`
when the body is neither shape.

## Endpoints — Suggestions

Suggestions are the recipient-facing edit-proposal flow. A
non-admin caller (project viewer, share editor, org viewer, or
anyone holding a `link_permission='can_suggest'` link) submits a
proposed new version of a share; the share's owner approves or
rejects from the dashboard inbox. Approved suggestions become the
new content; rejected ones stay on the version timeline marked
`rejected`.

The suggestion endpoints live under the share-versions tree — they
share the underlying `share_versions` table, with `status` ∈
{`approved`, `suggested`, `rejected`} distinguishing approved
versions from open proposals.

### `POST /api/v1/shares/:id/suggestions` — submit a suggestion

Auth: per-user token or cookie session.

Caller must satisfy `can(actor, "suggestion.create", share)` —
that means one of:

- Org admin of the share's owner org (always allowed; producing
  an approved version directly would be simpler, but this is the
  same code path the suggest UI uses)
- Project member (viewer or editor) on the share's project
- Share editor (via `share_members`)
- Org viewer (via `org_viewers`)
- Anyone holding a link with `link_permission='can_suggest'`

Anonymous suggestions are explicitly deferred (V4 #20 — rate-limited
+ captcha required first). An anon caller gets `404`.

Body:

```json
{
  "content":      "string (required) — proposed new content",
  "content_type": "string (optional) — defaults to the share's current content_type",
  "filename":     "string (optional) — defaults to the share's current filename"
}
```

Response:

```json
{"id": "<version-uuid>", "status": "suggested", "share_id": "k3p9x2af"}
```

Errors: `400` on invalid JSON / missing `content`; `404` when the
share is missing, soft-deleted, or the caller isn't allowed to
suggest.

### `POST /api/v1/shares/:id/suggestions/:vid/approve` — approve

Auth: cookie session (org admin of the share's owner org).

Approving copies the suggestion's content into `shares.content`,
flips the version row to `status='approved'`, and emits a
`suggestion.approved` notification to the suggester.

Response:

```json
{"ok": true, "version_id": "<version-uuid>"}
```

Errors: `404` if the share or version isn't found or the caller
isn't an admin of the owner org; `409 {"error":"wrong_status"}` if
the version was already approved or rejected.

### `POST /api/v1/shares/:id/suggestions/:vid/reject` — reject

Auth: cookie session (org admin of the share's owner org).

Flips the version row to `status='rejected'` (no content copy).
Emits a `suggestion.rejected` notification to the suggester.

Response shape and error cases mirror `…/approve`.

### `GET /api/v1/me/suggestions` — list your inbox of suggestions

Auth: per-user token or cookie session.

Returns the suggestions submitted **by the caller**, newest first.
Use this to give a suggester a "Where did my proposed changes go?"
view across every share they've touched. For the owner-side inbox
(suggestions submitted **against your** shares), call
`GET /api/v1/shares/:id/versions?status=suggested` on each share you
admin, or visit `/dashboard/inbox` in the browser.

Query parameters:

- `limit` — page size, 1..200, default 50
- `cursor` (or `before`) — opaque cursor from a prior `next_cursor`
- `status` — `suggested` | `approved` | `rejected` filter

```bash
curl -s 'https://anchorify.io/api/v1/me/suggestions?status=suggested' \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"
```

Response:

```json
{
  "items": [
    {
      "id": "<version-uuid>",
      "share_id": "k3p9x2af",
      "status": "suggested",
      "created_at": 1717449600000,
      "approved_at": null,
      "share": {
        "slug": "q1-report",
        "filename": "q1.md",
        "org_slug": "alice",
        "project_slug": "untitled",
        "url": "https://anchorify.io/alice/untitled/q1-report"
      }
    }
  ],
  "next_cursor": "..."
}
```

## Endpoints — Versions and audit

The version history records every approved write to a share's content
plus every suggested change. The audit feed records every privileged
action against the org (member grants, visibility flips, deletes,
domain config, theme changes, etc.). Both are admin-only, owner-org
scoped.

### `GET /api/v1/shares/:id/versions` — list a share's versions

Auth: cookie session (org admin of the share's owner org).

Sorted newest first. Cursor pagination is keyset on
`(created_at, id)`. Bodies are omitted from the list response (50
full versions would balloon the payload) — fetch a single version
to read its content.

Query parameters:

- `limit` — page size, 1..200, default 50
- `cursor` — opaque cursor from a prior `next_cursor`
- `status` — `approved` | `suggested` | `rejected` filter

```bash
curl -s 'https://anchorify.io/api/v1/shares/k3p9x2af/versions' \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"
```

Response:

```json
{
  "items": [
    {
      "id": "<version-uuid>",
      "source": "publish",
      "status": "approved",
      "author": {"username": "alice"},
      "content_type": "markdown",
      "filename": "q1.md",
      "created_at": 1717449600000,
      "approved_at": 1717449600000,
      "bytes": 1843
    }
  ],
  "next_cursor": "..."
}
```

`source` is one of `publish`, `rollback`, `suggestion`, or
`agent_edit`. `approved_at` is the moment the row became the
shares.content. For `status='suggested'` rows it's `null` until
approval.

`400 {"error":"invalid cursor"}` if the cursor is tampered or
truncated.

### `GET /api/v1/shares/:id/versions/:vid` — fetch a single version

Auth: cookie session (org admin of the share's owner org).

Returns the version metadata **and** the full body.

```bash
curl -s https://anchorify.io/api/v1/shares/k3p9x2af/versions/<vid> \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"
```

Response:

```json
{
  "id": "<version-uuid>",
  "source": "publish",
  "author": {"username": "alice"},
  "content_type": "markdown",
  "filename": "q1.md",
  "created_at": 1717449600000,
  "content": "# Q1 report\n\n…"
}
```

### `GET /api/v1/shares/:id/versions/:a/diff/:b` — line diff

Auth: cookie session (org admin of the share's owner org).

Returns a line-by-line diff between version `a` and version `b`.
Both versions must belong to the share. Diff direction is from `a`
to `b` (the typical call shape is `older/diff/newer`).

```bash
curl -s https://anchorify.io/api/v1/shares/k3p9x2af/versions/<old>/diff/<new> \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"
```

Response (normal case):

```json
{
  "from": {"id": "<old>", "created_at": 1717400000000},
  "to":   {"id": "<new>", "created_at": 1717449600000},
  "lines": [
    {"kind": "equal",  "text": "# Q1 report"},
    {"kind": "remove", "text": "Looks great."},
    {"kind": "add",    "text": "Looks great. Updated 5/15."}
  ]
}
```

Response when either version is too large to diff safely:

```json
{
  "from": {"id": "<old>", "created_at": 1717400000000},
  "to":   {"id": "<new>", "created_at": 1717449600000},
  "truncated": true,
  "reason": "diff inputs exceed safe-LCS limits",
  "lines": []
}
```

`truncated: true` means the diff was skipped — typically because one
side is binary or both sides are very large. The render UI falls back
to a "view full version" link in that case.

### `POST /api/v1/shares/:id/versions/:vid/restore` — restore a version

Auth: cookie session (org admin of the share's owner org).

Copies the target version's content into `shares.content`, appends a
new `source='rollback'` row to the history (so the rollback itself is
versioned), and emits a `share.restore` audit event.

Response:

```json
{"ok": true, "restored_from": "<version-uuid>", "at": 1717449600000}
```

### `GET /api/v1/orgs/:slug/audit` — org activity feed

Auth: cookie session (org admin of `:slug`).

Cursor-paginated, newest first. Non-admin callers see `404` to avoid
existence leaks.

Query parameters:

- `limit` — page size, 1..200, default 50
- `cursor` — ms-epoch cursor from a prior `next_cursor`
- `action` — narrow to one event type (e.g. `share.delete`,
  `share.visibility`, `org.member.add`, `share.restore`,
  `share.link_permission`, `org.theme.set`, `org.theme.clear`,
  `domain.add`, `domain.verify`, etc.)

```bash
curl -s 'https://anchorify.io/api/v1/orgs/alice/audit?limit=50' \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"
```

Response:

```json
{
  "items": [
    {
      "id": "<uuid>",
      "action": "share.visibility",
      "actor": {"username": "alice"},
      "target_type": "share",
      "target_id": "k3p9x2af",
      "metadata": {"from": "unlisted", "to": "members"},
      "created_at": 1717449600000
    }
  ],
  "next_cursor": 1717449590000
}
```

`actor` is `null` for system-emitted events (rare — most actions
attribute to the calling admin).

## Endpoints — Access requests

A `members`-tier share returns `404` to outsiders, but the share-render
path optionally surfaces a "request access" form. The form POSTs to
`/api/v1/shares/:id/access-requests`; the request lands in the owner
org's inbox at `/dashboard/inbox` and emits an
`access_request.created` notification to every org admin.

### `POST /api/v1/shares/:id/access-requests` — submit a request

Auth: none required. Anonymous + signed-in requesters both work; the
signed-in path uses the session, the anon path requires
`requester_email` in the body.

Rate-limited (see [Rate limits](#rate-limits)) — same per-IP bucket
as comments and reactions.

Body:

```json
{
  "target_role":     "viewer",
  "message":         "optional, <=1000 chars",
  "requester_email": "required when not signed in, <=254 chars"
}
```

`target_role` is `"viewer"` or `"editor"`. Anything else is `400`.

Response — newly created:

```json
{"id": "<uuid>", "status": "pending"}
```

Response — caller already has access (no row written):

```json
{"status": "already_has_access"}
```

Response — open request from the same caller already exists (no
duplicate row written):

```json
{"id": "<uuid>", "status": "duplicate_pending"}
```

### `GET /api/v1/orgs/:slug/access-requests` — admin inbox

Auth: cookie session (org admin of `:slug`).

Query parameters:

- `limit` — 1..200, default 50
- `cursor` — ms-epoch cursor from a prior `next_cursor`
- `status` — `pending` | `approved` | `denied` | `cancelled` filter

```bash
curl -s 'https://anchorify.io/api/v1/orgs/alice/access-requests?status=pending' \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"
```

Response:

```json
{
  "items": [
    {
      "id": "<uuid>",
      "share_id": "k3p9x2af",
      "requester": {"username": "bob"},
      "target_role": "viewer",
      "message": "Sharing this with the auditing team — please grant view.",
      "status": "pending",
      "decided_at": null,
      "created_at": 1717449600000
    }
  ],
  "next_cursor": null
}
```

Anonymous submissions surface as `{"email": "..."}` in `requester`
instead of `{"username": "..."}`.

### `POST /api/v1/access-requests/:id/approve` — approve

Auth: cookie session (org admin of the share's owner org).

Materializes the grant: a `viewer` request inserts a `project_members`
row (viewer role) for the requester on the share's project; an
`editor` request inserts a `share_members` row (editor) on the share
itself.

Response:

```json
{"ok": true, "id": "<uuid>", "grant": "project_member"}
```

`grant` is `project_member` or `share_member` so the caller knows
which surface the row landed in. `409 {"error":"wrong_status"}` if
the request was already decided.

### `POST /api/v1/access-requests/:id/deny` — deny

Auth: cookie session (org admin of the share's owner org). Flips the
status to `denied`; emits `access_request.denied` to the requester.
Response shape and errors mirror approve.

## Endpoints — Orgs

Every user has exactly one home org (auto-created on first sign-in,
slug = chosen username). Org admins are admins of every project in
the org. See [`/docs/orgs`](/docs/orgs) for the full data model.

### `GET /api/v1/orgs/:slug` — org details

Auth: per-user token or cookie session (org admin or project viewer).

```bash
curl -s https://anchorify.io/api/v1/orgs/alice \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"
```

Response:

```json
{
  "org": {"slug": "alice", "name": "Alice", "plan": "free"},
  "members":  [{"username": "alice"}],
  "projects": [{"slug": "untitled", "name": "Untitled"}, {"slug": "q1-acme", "name": "Q1 — Acme"}]
}
```

### `PATCH /api/v1/orgs/:slug` — rename / update name

Auth: per-user token or cookie session (org admin).

Body:

```json
{"slug": "new-slug", "name": "New Display Name"}
```

Both fields optional; at least one required. The old slug is
permanently tombstoned and cannot be reclaimed; URLs at the old
slug 301 to the new slug via `org_redirects`.

### `DELETE /api/v1/orgs/:slug` — intentionally blocked

Returns `400 {"error":"org delete not supported"}`. Org delete is
out of scope for V3 — admins are encouraged to rename or use the
org-merge flow on invite-accept instead.

### `POST /api/v1/orgs/:slug/members` — add an org admin

Auth: per-user token or cookie session (org admin).

Body: `{"username": "bob"}`. Adds `bob` as an admin of the org.
Cross-org admin invites go through the invite flow instead — this
endpoint is for the case where `bob` is already a known user in
the same org context (rare).

### `DELETE /api/v1/orgs/:slug/members/:username` — remove an admin

Auth: per-user token or cookie session (org admin). Admins cannot
remove themselves; ask another admin or use the org-merge flow.

## Endpoints — Projects

Projects group shares inside an org. Every share lives in a project.
The default project on org create is `untitled`. See
[`/docs/projects`](/docs/projects) for slug rules and rename semantics.

### `GET /api/v1/orgs/:slug/projects` — list projects

Auth: per-user token or cookie session (any org member).

Response: `{"projects": [{"slug": "untitled", "name": "Untitled", "share_count": 3}, …]}`

### `POST /api/v1/orgs/:slug/projects` — create a project

Auth: per-user token or cookie session (org admin).

Body: `{"slug": "q1-acme", "name": "Q1 — Acme"}`.
`name` defaults to `slug` if omitted.

### `PATCH /api/v1/orgs/:slug/projects/:pslug` — rename

Auth: per-user token or cookie session (org admin). Body:
`{"slug": "new-slug"}` and/or `{"name": "New name"}`. Old URLs at
the previous slug 301 via `project_redirects` (history preserved).

### `DELETE /api/v1/orgs/:slug/projects/:pslug` — delete

Auth: per-user token or cookie session (org admin). Blocked if the
project still contains shares (move them first) or if it is the
last project in the org (org must have at least one project).

### `POST /api/v1/orgs/:slug/projects/:pslug/members` — add a project member

Auth: per-user token or cookie session (org admin).

Body:

```json
{"user_email": "client-bob@example.com", "role": "viewer"}
```

`role` is optional (default `"viewer"`) and must be `"viewer"` or
`"editor"`:

- `viewer` — read-only access to the project's shares (the historical
  "project viewer" role).
- `editor` — read + write (publish, edit, delete shares in this
  project). The doc-level-roles UI (V4 #8) added this tier; pre-#8
  callers omit `role` and continue to get `viewer`.

Response:

```json
{
  "added": true,
  "already_member": false,
  "role": "viewer",
  "user": {"id": "<uuid>", "username": "client-bob"}
}
```

Errors:

- `400 {"error":"role must be 'viewer' or 'editor'"}` on invalid role.
- `400 {"error":"no account","reason":"send an invite instead"}` if
  the email has no Anchorify account — fall back to the invite
  flow.
- `404` for org/project not found OR caller not an admin
  (existence-leak guard).

### `DELETE /api/v1/orgs/:slug/projects/:pslug/members/:username`

Auth: per-user token or cookie session (org admin).

### `POST /api/v1/orgs/:slug/viewers` — grant org-wide viewer access

Auth: per-user token or cookie session (org admin).

Adds a user as a cross-project viewer of the org (a row in
`org_viewers`). The grant is org-scoped read access — the user can
read every share in every project of the org, but cannot edit or
publish.

Use this for an internal reviewer or stakeholder who needs to see all
the org's work but doesn't need write access on any of it. For
narrower scope, use a project-level viewer instead.

Body:

```json
{"user_email": "boss@example.com"}
```

Response:

```json
{
  "added": true,
  "already_member": false,
  "user": {"id": "<uuid>", "username": "boss"}
}
```

Errors:

- `404 {"error":"no account","reason":"send an invite instead"}` if
  the email has no Anchorify account.
- `400 {"error":"already admin","reason":"user is already an admin of this org"}`
  when the user is already an org admin (admin > viewer; granting
  viewer would be a downgrade — explicitly rejected).
- `404` for org-not-found or caller-not-admin.

### `DELETE /api/v1/orgs/:slug/viewers/:userId` — revoke org-viewer

Auth: per-user token or cookie session (org admin). Path parameter
is the **user id**, not the username.

Response:

```json
{"removed": true, "user": {"id": "<uuid>", "username": "boss"}}
```

`404 {"error":"not a viewer"}` if the user wasn't an org viewer.

### `PUT /api/v1/orgs/:slug/projects/:pslug/theme` — set project theme

Auth: per-user token or cookie session (org admin).

Body shape identical to `PUT /api/v1/shares/:id/theme` — a `theme`
JSON object validated against the shared schema. Sets the
project-level theme overrides; the cascade is `share > project >
org`.

```bash
curl -sX PUT https://anchorify.io/api/v1/orgs/alice/projects/q1-acme/theme \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"primary_color":"#1e40af"}'
```

Response:

```json
{"ok": true, "theme": {"primary_color": "#1e40af"}}
```

### `DELETE /api/v1/orgs/:slug/projects/:pslug/theme` — clear project theme

Auth: per-user token or cookie session (org admin). Returns `204`.

## Endpoints — Invites

HMAC-signed invite tokens, 7-day TTL. No `invites` table — verification
is purely stateless. Cold-recipient (no Anchorify account) invites bounce
through `/auth/pick-username` and resume after username pick.

### `POST /api/v1/orgs/:slug/invites` — mint an invite

Auth: per-user token or cookie session (org admin).

Body:

```json
{
  "email":  "bob@example.com",
  "target": "org",
  "project_slug": "q1-acme"
}
```

- `target` = `"org"` adds Bob as an org admin on accept.
- `target` = `"project"` requires `project_slug`; adds Bob as a
  project viewer on accept.

Response:

```json
{"token": "...", "accept_url": "https://anchorify.io/auth/invite/..."}
```

The invite email is sent automatically via the `send_email` job.

### `GET /auth/invite/:token` — accept (browser flow)

Verifies the token, branches on the recipient's auth state:

- Signed-in matching email → inserts membership, redirects to the
  target (`/dashboard` for org invites; `/<org>/<project>` for
  project invites).
- Signed-in but sole-admin-elsewhere with conflicting home org →
  renders the merge UI (`200`).
- Signed-in but multi-admin-elsewhere → `400` block (must promote
  another admin first).
- Not signed in, email has an existing Anchorify account → 302 to
  `/auth/sign-in?email=…&next=…` (magic-link).
- Not signed in, email is cold (no Anchorify account) → 302 to
  `/auth/pick-username` with a pending-signup cookie carrying the
  resume `next`.
- Token expired or target deleted → `410`.

## Endpoints — Auth

### `POST /auth/magic-link` — request a magic-link sign-in

Auth: none. Form-encoded body: `email=<email>&next=<path>`. Mints a
single-use HMAC-signed token, mails it via Resend. No-op (with a
stdout log) when `RESEND_API_KEY` is unset. Always responds `200`
regardless of whether the email exists (no account-enumeration leak).

### `GET /auth/magic/:token` — redeem a magic link

Auth: none. Verifies the token, single-use enforced via
`consumed_magic_tokens` (PK on SHA-256). On success: sets a 30-day
session cookie, redirects to `next` (or `/dashboard`). On expired /
already-used / invalid: renders an error page. The `next` path is
same-origin-guarded.

### `GET /auth/sign-in` — sign-in form

Renders a form that POSTs to `/auth/magic-link`. With `?email=`
query param, the email is prefilled and read-only (used by the
invite-accept warm-recipient flow).

## Endpoints — Share move

### `POST /api/v1/shares/:id/move` — move into a different project

Auth: per-user token or cookie session (share owner — org admin in
the share's owner org).

Body: `{"project_slug": "new-project"}`. The target project must
exist in the share's owner org. Slug collisions inside the new
project return `409 {"error":"slug taken in target project"}`.

Old URLs at `/<org>/<old-project>/<slug>` 301 to the new canonical
URL; the per-project `slug_redirects` table tracks history.

## Endpoints — Branding

Per-org branding (logo, primary color, footer text) renders on
every share page in the org. See [`/docs/branding`](/docs/branding)
for image sniffing + SVG sanitization rules.

### `POST /api/v1/orgs/:slug/branding/logo` — upload a logo

Auth: per-user token or cookie session (org admin).

`multipart/form-data` with a `logo` field — PNG, JPG, or SVG. The
server sniffs the content type from magic bytes (the
`Content-Type` header is advisory) and sanitizes SVGs before
storage. Max size 1 MiB. Returns `{"url": "...", "content_type": "..."}`.

### `POST /api/v1/orgs/:slug/branding` — set color / footer / etc.

Auth: per-user token or cookie session (org admin).

Body: `{"primary_color": "#3b82f6", "footer_text": "© Alice Studio"}`.
Color must be a 6-digit hex. Either field may be omitted to leave it
unchanged. Send `null` to clear a field.

### `DELETE /api/v1/orgs/:slug/branding/logo` — remove the logo

Auth: per-user token or cookie session (org admin).

### `PUT /api/v1/orgs/:slug/theme` — set org-level theme

Auth: per-user token or cookie session (org admin).

Body shape identical to the project- and share-level theme endpoints
— a `theme` JSON object validated against the shared schema in
`src/services/theme.ts`. The org-level theme is the base of the
cascade (`share > project > org`); project and share overrides
specialize on top. See [Themes](/docs/themes).

```bash
curl -sX PUT https://anchorify.io/api/v1/orgs/alice/theme \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"primary_color":"#0f766e","font_family":"system"}'
```

Response:

```json
{"ok": true, "theme": {"primary_color": "#0f766e", "font_family": "system"}}
```

### `DELETE /api/v1/orgs/:slug/theme` — clear the org theme

Auth: per-user token or cookie session (org admin). Returns `204`.
Shares in the org fall back to the default theme.

## Endpoints — Custom domains (pro-only)

Pro-tier orgs can serve their shares from a custom hostname (e.g.
`docs.acme.com`). Gated by `CUSTOM_DOMAINS_ENABLED=1` and the
org's plan tier. See [`/docs/custom-domains`](/docs/custom-domains)
for the verification + provisioning timeline.

### `POST /api/v1/orgs/:slug/domain` — add a custom domain

Auth: per-user token or cookie session (org admin).

Body: `{"hostname": "docs.acme.com"}`.

Response includes a `verification_token` that the admin must add as
a TXT record at `_jf-verify.<hostname>` before calling
`/verify`.

### `POST /api/v1/orgs/:slug/domain/verify` — kick off verification

Auth: per-user token or cookie session (org admin). Enqueues a
`verify_domain` job; the job resolves the TXT record and flips the
row to `provisioning`, which the `provision_cert` job picks up.

### `DELETE /api/v1/orgs/:slug/domain` — remove the custom domain

Auth: per-user token or cookie session (org admin). Frees the
hostname slot on the Anchorify side immediately. Cloudflare for SaaS
`custom_hostname` cleanup is operator-burden for the validation
cohort (we don't yet persist the CF id).

## Endpoints — Admin (operator-only)

### `POST /_admin/orgs/:slug/plan` — flip an org's plan tier

Auth: legacy operator bearer (`REPO_SHARE_TOKEN`), not a per-user
token. Body: `{"plan": "free"}` or `{"plan": "pro"}`. Used to
upgrade an org for custom-domain access during the validation
cohort. There is no self-serve upgrade path.

## Pagination

Cursor-based, opaque. Used today by
`GET /api/v1/shares/:id/comments` and `GET /api/v1/me/shares`.

- Pass `?limit=<1..100>` to set page size; default 50, max 100
- The response includes `next_cursor: <opaque-string> | null`
- To fetch the next page, pass `?cursor=<the-opaque-string>` along
  with the same `limit`
- A `null` `next_cursor` means you have reached the end
- Cursors are tied to sort order and tie-broken on row id; concurrent
  inserts at the head of the list will not cause the cursor to skip
  or duplicate older rows
- Invalid or truncated cursors return `400 {"error":"invalid cursor"}`

Example — paginate through every comment on a share:

```bash
url='https://anchorify.io/api/v1/shares/k3p9x2af/comments?limit=50'
while [ -n "$url" ]; do
  resp=$(curl -s "$url")
  echo "$resp" | jq -r '.items[] | "\(.created_at)\t\(.user.username)\t\(.body)"'
  next=$(echo "$resp" | jq -r '.next_cursor // empty')
  if [ -n "$next" ]; then
    url="https://anchorify.io/api/v1/shares/k3p9x2af/comments?limit=50&cursor=$next"
  else
    url=""
  fi
done
```

## Limits

- **Per-share content size**: `MAX_SHARE_BYTES` (default
  **1 MiB** = 1,048,576 bytes). Counted as UTF-8 byte length of the
  decoded `content` string. Oversize uploads return `413
  {"error":"file too large","limit":1048576}` before any DB roundtrip.
- **Per-user share cap**: `MAX_SHARES_PER_USER` (default **500**).
  Enforced on INSERT only. Hitting the cap returns `429
  {"error":"share limit reached"}`. Updates do not count.
- **Comment body**: 1..2000 characters after trimming. Backed by a
  DB `CHECK` constraint.
- **Slug**: 1..60 chars, `^[a-z0-9](?:[a-z0-9-]{0,58}[a-z0-9])?$`.
  See [`/docs/slug-rules`](/docs/slug-rules).
- **Token**: `repo_` + 32 hex chars (160 bits of entropy).

## Visibility model

Every share has one of three visibility tiers:

| Aspect              | Public                      | Unlisted (default)               | Members                        |
| ------------------- | --------------------------- | -------------------------------- | ------------------------------ |
| Reachable via URL   | Yes — open                  | Yes — open (URL is unguessable)  | Only signed-in org/project viewers |
| Search-engine index | Allowed; sets `<canonical>` | Blocked: `noindex, nofollow`     | Blocked: `noindex, nofollow`   |
| Anon hit            | Renders                     | Renders                          | `404` (no info-leak)           |
| Password allowed    | No (mutually exclusive)     | Yes (optional)                   | No (membership IS the gate)    |
| Default for new     | No                          | Yes                              | No                             |
| Source endpoint     | Unauth read                 | Owner-only read                  | Org-admin + project-viewer read |

The legacy value `"secret"` is accepted on INSERT/UPDATE and stored as
`"unlisted"` — old CLI clients and integrations keep working unchanged.

Rules the API enforces:

- `visibility` defaults to `"unlisted"` on INSERT when the field is
  omitted from `POST /`. Updates without a `visibility` field do not
  change visibility.
- A `public` share cannot simultaneously have a password. A `members`
  share cannot have a password (membership is the gate). Any request
  that would produce either state is rejected with
  `400 {"error":"public shares cannot have a password"}` /
  `400 {"error":"members shares cannot have a password"}`.
- The `unlisted → public` flip with a password set requires `?force=1`
  on `POST /api/v1/shares/:id/visibility`; force clears the password
  and reports `password_cleared: true`.
- The `unlisted → members` flip clears any password automatically;
  the response reports `password_cleared: true` if there was one.

## Versioning

`/api/v1/*` is the stable surface. Existing pre-v1 paths are kept as
aliases for backwards compatibility:

| Legacy path                     | Stable equivalent                    |
| ------------------------------- | ------------------------------------ |
| `GET /_list`                    | `GET /api/v1/me/shares`              |
| `GET /api/me`                   | `GET /api/v1/me`                     |
| `GET /api/me/shares-count`      | `GET /api/v1/me/shares/count`        |
| `POST /api/cli/delete`          | `POST /api/v1/cli/delete`            |
| `POST /api/shares/:id/delete`   | `DELETE /api/v1/shares/:id`          |
| `POST /api/shares/:id/password` | `POST /api/v1/shares/:id/password`   |
| `POST /api/shares/:id/visibility` | `POST /api/v1/shares/:id/visibility` |

The dashboard form-encoded variants (`POST /api/shares/:id/...`)
remain cookie-only and are intended for the JS-less dashboard form
posts; new integrations should use the JSON `/api/v1/*` endpoints.

Backwards-incompatible changes will appear under `/api/v2/*`. There
is no plan to remove `/api/v1/*` routes once published.

## See also

- [`/docs/slug-rules`](/docs/slug-rules) — exact slug regex and
  validation rules
- [`/docs/anchorify-skill`](/docs/anchorify-skill) — the
  `anchorify` skill (CLI wrapper) that drives most of these endpoints
