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).

Overview

  • Base URL: https://anchorify.io
  • Local dev: http://localhost:3737 (defaults — see CLAUDE.md 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:

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:

{"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:

{
  "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).

Success response:

{
  "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 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:

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:

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.

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.

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.

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:

{"slug": "new-slug"}

The new slug is validated against the same regex as POST / (see /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.

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:

{"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:

{"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:

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:

{"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:

{"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.

# 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:

{"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 for the full matrix.

Body:

{"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.

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:

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

Response — no change:

{"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:

{"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).

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:

{"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.

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

Response:

{"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:

{
  "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..."
  ]
}
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 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.

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:

{"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:

{"user_email": "[email protected]"}

Response:

{
  "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:

{"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:

{"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.

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:

{
  "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:

{
  "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.
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 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:

{
  "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:

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

Legacy response:

{"kind": "legacy"}
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 for cursor mechanics.

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

Response:

{
  "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).

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

Response:

{"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 for cursor mechanics.

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

Response:

{
  "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). Body must be 1..2000 characters after trimming whitespace.

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):

{
  "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:

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

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

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:

{"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_typeshare or comment
  • target_id — share id or comment uuid
curl -s 'https://anchorify.io/api/v1/reactions?target_type=share&target_id=k3p9x2af'

Response:

{"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_only1 or true to return only rows with read_at IS NULL
curl -s 'https://anchorify.io/api/v1/notifications?limit=50&unread_only=1' \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"

Response:

{
  "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.

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

Response:

{"count": 3}

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

Auth: per-user token or cookie session.

Body — one of:

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

…or:

{"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:

{"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:

{
  "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:

{"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:

{"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
  • statussuggested | approved | rejected filter
curl -s 'https://anchorify.io/api/v1/me/suggestions?status=suggested' \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"

Response:

{
  "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
  • statusapproved | suggested | rejected filter
curl -s 'https://anchorify.io/api/v1/shares/k3p9x2af/versions' \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"

Response:

{
  "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.

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

Response:

{
  "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).

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

Response (normal case):

{
  "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:

{
  "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:

{"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.)
curl -s 'https://anchorify.io/api/v1/orgs/alice/audit?limit=50' \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"

Response:

{
  "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) — same per-IP bucket as comments and reactions.

Body:

{
  "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:

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

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

{"status": "already_has_access"}

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

{"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
  • statuspending | approved | denied | cancelled filter
curl -s 'https://anchorify.io/api/v1/orgs/alice/access-requests?status=pending' \
  -H "Authorization: Bearer $REPO_SHARE_TOKEN"

Response:

{
  "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:

{"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 for the full data model.

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

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

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

Response:

{
  "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:

{"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 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:

{"user_email": "[email protected]", "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:

{
  "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:

{"user_email": "[email protected]"}

Response:

{
  "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:

{"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.

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:

{"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:

{
  "email":  "[email protected]",
  "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:

{"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 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.

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:

{"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 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:

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.
  • 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