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 — seeCLAUDE.mdin the repo) - Request bodies:
application/jsonunless noted (form-encoded andmultipart/form-dataendpoints are explicitly called out —POST /api/v1/shares/uploadis the multipart one) - Response bodies:
application/jsonfor/api/*;text/plain; charset=utf-8for…/sourceraw endpoints;text/htmlfor 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
(access, 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 haveuser_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/commentsPOST /api/v1/reactions
POST /api/v1/preview has its own, looser per-IP bucket
(PREVIEW_RATE_PER_MIN, default 120 requests/minute) so
live-preview typing doesn't drain the write budget; the 429 shape is
identical.
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, publishing over an org slug you can't edit, or index_forbidden — see below) |
404 |
Not found, soft-deleted, or owned by someone else |
409 |
Slug taken (/new + POST /api/v1/shares/upload; text POST / slug now updates in place — see publish-by-slug) |
410 |
Share was deleted (GET /:id only — render path) |
413 |
Payload too large (content or uploaded file exceeds the owner org's cap — MAX_SHARE_BYTES on Free, MAX_SHARE_BYTES_PRO on Pro) |
429 |
Rate limit hit OR per-user share cap reached (MAX_SHARES_PER_USER on Free, MAX_SHARES_PER_USER_PRO on Pro) |
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}
{"error": "index_forbidden", "reason": "New accounts can't publish a public (search-indexed) share yet. …"}
index_forbidden (403) is returned by any write that would set a
share indexed: true (publish with --index, POST / with
indexed: true, or POST /api/v1/shares/:id/access) when the owner
hasn't cleared the anti-spam floor — a verified (Google) email OR an
account older than ~24h, plus a per-org hourly cap on new public shares.
The share is still created/updated and live at its link; only public
listing (/discover + the sitemap) is held back. Retry without --index
to publish it hidden, or once the account qualifies. reason carries a
recipient-safe explanation.
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 |
If a live share already occupies (your target org, project, slug): UPDATE it when you can edit it, else 403. Otherwise INSERT a new share at this slug (400 if slug invalid). |
| neither | INSERT a new share at a random 8-char id (id == slug) |
Idempotent publish-by-slug ([F-SLUG-ORG-UNIQ]). A slug-only publish is
now idempotent against the public URL key (org, project, slug): re-sending
the same slug edits the existing share in place instead of forking a second
share at the same URL (which used to happen for a different author in a
multi-member org) or returning 409 (same author). If the slug is held by a
share you lack edit access to, you get 403 {"error":"slug in use by a share you can't edit","slug":"…"} — never a silent fork. The share's stable
identifier is always its id; the slug is a resolution key at publish time.
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",
"access": "string (optional) — 'restricted'|'view'|'comment'|'suggest'; default 'comment' on INSERT, leave-alone on UPDATE",
"indexed": "boolean (optional) — public listing + search indexing; default false on INSERT, leave-alone on UPDATE; forced false when access is 'restricted'",
"type": "string (optional) — 'markdown'|'code'|'json'|'yaml'|'csv'|'tsv'|'html'|'slides_marp'|'slides_reveal' override"
}
An invalid access returns 400 {"error":"invalid access","reason":"must be one of: restricted, view, comment, suggest"}; a non-boolean indexed returns 400 {"error":"invalid indexed"}. A password combined with indexed: true or access: "restricted" is rejected (400 — see Access model). Requesting indexed: true from an unverified or brand-new account can be refused with 403 {"error":"index_forbidden"}.
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"}'
POST /api/v1/shares/upload — publish a file (multipart)#
Auth: per-user token, or cookie session. The legacy admin bearer is
rejected with 403 — it has no home org to publish into.
POST / carries content as a JSON string, so it can only publish text.
This is its multipart twin: the endpoint for publishing a file —
including binaries (.pdf, .png, .zip, .docx) that can't survive a
UTF-8 round trip. It is the endpoint behind anchorify <file> for
binaries and behind anchorify upload-folder for the binary members of a
batch.
It creates or replaces, on the same three rules as POST /:
| Given | Behavior |
|---|---|
id |
Replace that share's bytes. 404 if it isn't yours (existence is not leaked). |
slug held by a live share in the target project |
Replace it when you can edit it; 403 {"error":"slug in use by a share you can't edit"} when you can't — never a silent fork. |
| neither, or a free slug | Create a new share. |
A replacement keeps the share's id, slug and URL, so a link already
in a client's inbox keeps working. The response carries "updated": true
when it replaced and false when it created.
Multipart fields:
| Field | Required | Notes |
|---|---|---|
file |
yes | The bytes. A File part; the part's Content-Type is a display hint only. |
id |
no | Replace this share's bytes instead of creating. |
filename |
no | Overrides the part's own filename. Drives the title + content-type detection. |
slug |
no | Chosen slug. Omit for a random 8-char id. |
access |
no | restricted | view | comment | suggest. Default comment. |
indexed |
no | true/false (also 1/0, on/off). Default false; forced false when access is restricted. |
type |
no | Content-type override. Applies to text files only — ignored for binaries, whose tier comes from the bytes. |
org |
no | Target org slug. Must be your home org. |
project |
no | Target project slug. Required when your org has 2+ projects. |
Both tiers are accepted. The server probes the bytes: a NUL anywhere
routes the file to the blob tier (image / pdf / binary, bytes in
storage, content set to the "@@blob" sentinel); anything that decodes
cleanly as UTF-8 lands as an ordinary text share with the usual
extension + frontmatter detection. Callers don't have to pre-sort.
The stored MIME is sniffed from the bytes, never taken from the
request. A PNG declared as text/html is stored and served as
image/png; an HTML payload declared as anything is served
application/octet-stream with Content-Disposition: attachment.
Size cap is keyed on the target org's plan: MAX_SHARE_BYTES
(1 MiB default) on Free, MAX_SHARE_BYTES_PRO (10 MiB default) on Pro.
Success response:
{
"id": "k3p9x2af",
"url": "https://anchorify.io/alice/untitled/q1-deck",
"slug": "q1-deck",
"filename": "q1-deck.pdf",
"content_type": "pdf",
"binary": true,
"updated": false
}
Errors:
| Status | Body |
|---|---|
400 |
{"error":"file required"} — no file part, or it was empty |
400 |
{"error":"invalid slug"} / {"error":"invalid access"} / {"error":"invalid indexed"} / {"error":"invalid type"} |
400 |
{"error":"project required","org":"…","projects":[…]} — org has 2+ projects |
400 |
{"error":"org not found"} / {"error":"project not found"} |
403 |
{"error":"index_forbidden","reason":"…"} — anti-spam gate on indexed: true |
403 |
{"error":"forbidden"} — legacy admin bearer (no org to publish into) |
403 |
{"error":"slug in use by a share you can't edit","slug":"…"} |
404 |
{"error":"not found or not owned"} — id names a share you can't edit |
409 |
{"error":"slug taken","slug":"…"} — the slug is held by a soft-deleted share of yours (live ones are replaced, not rejected) |
413 |
{"error":"file too large","limit":1048576} |
429 |
{"error":"share limit reached"} |
A rejected upload never leaves bytes behind: the blob is written before the row so a rendered share can't point at a missing object, and any failure after that point deletes it again.
Example:
curl -sX POST https://anchorify.io/api/v1/shares/upload \
-H "Authorization: Bearer $ANCHORIFY_TOKEN" \
-F "file=@./q1-deck.pdf" \
-F "slug=q1-deck" \
-F "project=q1-acme"
GET /api/v1/shares/resolve — reference → share id#
Auth: optional. An anonymous caller can resolve a public share — the same thing they can already do by opening the link. A per-user token or cookie session additionally resolves anything that caller can read.
Turns a human reference into a share id. Every other share-scoped
endpoint takes the opaque id; this is how a client gets one from what
a person actually has to hand.
Query parameter ref accepts, in the order someone is likely to have
one:
| Form | Example |
|---|---|
| Full URL | https://anchorify.io/acme/q1/deck |
| Path | /acme/q1/deck |
| Share id | k3p9x2af |
| Bare slug | deck |
A URL or 3-segment path names exactly one share. A bare token is matched against ids first, then slugs across every org the caller can read — with the caller's own org preferred, so typing your own share's slug doesn't turn into a disambiguation prompt because a stranger used the same word. Query strings and fragments on a pasted URL are ignored.
The gate is the same read check the render route applies, so this
endpoint can never name a share the caller couldn't have opened in a
browser. Unreadable and non-existent both return 404, with an
identical body — a distinction would be an existence oracle.
curl -s "https://anchorify.io/api/v1/shares/resolve?ref=/acme/q1/deck" \
-H "Authorization: Bearer $ANCHORIFY_TOKEN"
{
"id": "k3p9x2af",
"slug": "deck",
"filename": "deck.pdf",
"content_type": "pdf",
"org": "acme",
"project": "q1",
"url": "https://anchorify.io/acme/q1/deck",
"owned": false
}
owned is true when the share belongs to the caller's own org, so a
client can tell "mine, I can edit it" from "shared with me" without a
second round-trip.
Errors:
| Status | Body |
|---|---|
400 |
{"error":"ref required"} |
404 |
{"error":"not found","reason":"no share you can open matches that reference — …"} |
409 |
{"error":"ambiguous ref","matches":[{"id":"…","url":"…"}, …]} — a bare slug naming more than one readable share; re-send the full URL |
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. Indexed (public) shares only — hidden and restricted 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/access — set the access rung + public listing#
Auth: per-user token or cookie session (owner). A non-owner gets 404,
not 403 — we don't leak that the share exists.
Body:
{"access": "comment", "indexed": true}
access is required and must be one of "restricted", "view",
"comment", "suggest"; anything else returns
400 {"error":"access must be one of: restricted, view, comment, suggest"}.
indexed is an optional boolean (absent or non-true reads as false)
and is forced to false when access is "restricted". Same-state is a
no-op (200 with unchanged: true). See the
Access model for what each rung grants.
The call is rejected (400) if the share has a password and the
requested state can't keep one — that is, access: "restricted" or
indexed: true. Add ?force=1 to clear the password and complete the
change in one call:
curl -sX POST 'https://anchorify.io/api/v1/shares/k3p9x2af/access?force=1' \
-H "Authorization: Bearer $REPO_SHARE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"access":"comment","indexed":true}'
Response when force-clearing the password:
{"access": "comment", "indexed": true, "password_cleared": true}
Without the password conflict the response is just
{"access": "...", "indexed": bool}.
A not-indexed → indexed transition also passes the anti-spam gate and can
return 403 {"error":"index_forbidden","reason":"<recipient-safe text>"}.
The share stays live at its URL — only the public listing is withheld.
Editing a share that is already indexed is never re-gated.
POST /api/v1/shares/:id/downloads — allow or block downloads#
Auth: per-user token or cookie session (owner). A non-owner gets 404.
Body:
{"enabled": false}
enabled is required and must be a boolean; anything else returns
400 {"error":"enabled must be a boolean"}. Same-state is a no-op (200
with unchanged: true).
With downloads off, every surface that hands the file over stops
answering: GET /<org>/<project>/<slug>/download and the ?raw=1
raw-file URL both 404, the share toolbar drops its Download control and
PDF export, blob shares lose their download CTA, and the CSV grid loses
its "Export CSV" button. The page still renders — this is a friction
control, not DRM, and it does not stop copy, print, or view-source.
The gate is independent of the password gate: a share with downloads off
404s on /download even for a visitor holding a valid unlock cookie.
curl -sX POST https://anchorify.io/api/v1/shares/k3p9x2af/downloads \
-H "Authorization: Bearer $REPO_SHARE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"enabled":false}'
{"downloads_enabled": false}
New shares default to true. Download history is retained either way —
downloads_total and last_downloaded_at keep reporting what already
happened (see
GET /api/v1/shares/:id/analytics,
which also reports the current downloads_enabled state).
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. A password is only
valid on a hidden, link-reachable share, so setting one is rejected
(400) when the share is indexed
({"error":"indexed shares cannot have a password"}) or restricted
({"error":"restricted shares cannot have a password"}). Flip the share
with POST …/access 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/content-type — change how a share renders#
Auth: per-user token or cookie session. Authorizes on share.update —
the same rule as the browser surfaces — so org admins, project editors
and share-level editors can all change how a share renders. Everyone else
gets 404 rather than 403, so existence doesn't leak.
Body:
{"content_type": "code"}
Allowed values: markdown, code, json, yaml, csv, tsv,
html, html_embed, slides_marp, slides_reveal. Anything else is
400.
Also reachable as anchorify type <slug-or-id> <type> from the
CLI and as anchorify_set_content_type from the
MCP server.
image, pdf and binary are not in that list and never will be:
they are not render choices, they describe a share whose content is an
uploaded file. Use POST /api/v1/shares/:id/file to replace those
bytes instead.
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. Authorizes on
share.flip_visibility — the same rule as the analytics page and the
share settings page — so admins of the org that owns the share and
project editors can read it, whether or not they personally published it.
Everyone else, including project viewers, org viewers and share-level
editors, gets 404 rather than 403 so existence doesn't leak.
Returns aggregate view metrics plus a 30-day daily breakdown (zero-filled, ascending, UTC dates).
Also reachable as anchorify analytics <slug-or-id> [--json] from the
CLI and as anchorify_share_analytics
from the MCP server. Both omit visitors[] unless
asked — those rows are peppered IP hashes.
downloads_total / last_downloaded_at count file downloads separately
from views, and downloads_enabled reports the current state of the
share's downloads toggle.
Turning downloads off does not erase the history above it.
Response:
{
"total": 42,
"unique_ips": 17,
"last_viewed_at": 1717449600000,
"downloads_total": 9,
"last_downloaded_at": 1717449100000,
"downloads_enabled": true,
"daily": [
{"date": "2026-04-10", "count": 0},
{"date": "2026-04-11", "count": 3},
"...28 more entries..."
],
"visitors": ["...per-visitor breakdown..."]
}
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).404for 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, missingcontentorinstruction, or provider rejected the key during the round-trip probe (onPOST /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 exceedsMAX_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 onuserId, 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 priornext_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: comments follow the share's own read gate exactly. Anyone who can
read the share can read its comments; a restricted share returns 404
to anyone without an explicit grant (org admin / project viewer / share
editor / org viewer), and a password-protected share returns 404 until
the caller has the unlock cookie. Non-readers get 404 (not 403) so
the endpoint never confirms a share exists.
Query parameters:
limit— page size, 1..100, default 50cursor— opaque pagination cursor from a priornext_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,
"updated_at": null,
"reactions": {"thumbs_up": 2},
"anchor": null,
"resolved": false,
"resolved_at": null,
"resolved_by": null,
"parent_id": null
}
],
"next_cursor": "MTcxNzQ0OTYwMDAwMF88dXVpZD4="
}
next_cursor is null when there are no more pages.
resolved / resolved_at / resolved_by carry the comment's
resolution state, so a second pass over a document — human or agent —
can tell which feedback is already handled without re-reading the doc.
resolved_by is the username of whoever marked it handled, which is
usually not the author. parent_id is set on a reply and names the
comment it answers.
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.
Body fields:
body(required) — the comment text.anchor(optional) — the inline-comment positioning blob (highlighted/prefix/suffix/offset). Omit for a document-level comment.parent_id(optional) — the comment this one replies to. Must be a live comment on this share; a reply to a reply is flattened onto the top-level comment (one level, deliberately — this is an audit trail entry, not a threading model).
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"},
"anchor": null,
"parent_id": null
}
PUT /api/v1/shares/:id/comments/:commentId — edit a comment#
Auth: per-user token or cookie session. Author-scoped — you may only
edit a comment you wrote. Someone else's returns 403; a missing or
already-deleted comment returns 404 (the two are not distinguished, so
comment ids can't be enumerated by response code).
Only the body is mutable. The anchor and the share it belongs to are
fixed. updated_at flips to now, and the share render tags the comment
"(edited)" so recipients can tell post-hoc edits apart.
Body: {"body": "..."} (content is accepted as an alias). 1..2000
characters after trimming.
curl -sX PUT https://anchorify.io/api/v1/shares/k3p9x2af/comments/<uuid> \
-H "Authorization: Bearer $REPO_SHARE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"body":"Second take, considered."}'
{"id": "<uuid>", "body": "Second take, considered.", "updated_at": 1717449600000}
DELETE /api/v1/shares/:id/comments/:commentId — delete a comment#
Auth: per-user token or cookie session.
Soft delete — the row stays in the table so reactions and the forensic trail survive; every list and render path filters it out, so it is invisible to recipients immediately.
Two actors may delete: the comment's author, always; and the share's
owner / org admin, moderating someone else's comment on their own
document. The moderation gate is share.flip_visibility, which
deliberately excludes share-level content editors ([F-SEC-10]) — an
editor can change the document but not police the discussion. A
moderation delete writes a comment.moderate_delete event to the org
audit log, scoped to the
share, with the comment id in metadata.
Anyone else gets 403; missing or already-deleted gets 404.
curl -sX DELETE https://anchorify.io/api/v1/shares/k3p9x2af/comments/<uuid> \
-H "Authorization: Bearer $REPO_SHARE_TOKEN"
{"id": "<uuid>", "deleted": true}
POST /api/v1/shares/:id/comments/:commentId/resolve — mark handled#
Auth: per-user token or cookie session. Requires permission to comment
on the share — not authorship of the comment. The person who fixes the
thing is normally not the person who raised it, which is exactly why
resolved_by is recorded separately from the author.
Resolving is bookkeeping, not removal: the comment still renders, still lists, still keeps its reactions. It is reversible, and there is deliberately no bulk form — per-comment only, so an agent can't blanket-close threads it didn't address.
Body is optional. {} or no body resolves; {"resolved": false}
re-opens.
curl -sX POST https://anchorify.io/api/v1/shares/k3p9x2af/comments/<uuid>/resolve \
-H "Authorization: Bearer $REPO_SHARE_TOKEN"
{"id": "<uuid>", "resolved": true, "resolved_at": 1717449600000, "resolved_by": "alice"}
| Status | Meaning |
|---|---|
401 |
Anonymous caller |
403 |
Signed in, but no comment permission on this share |
404 |
No such comment, or it has been deleted |
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_type—shareorcommenttarget_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 50before— ms-epoch cursor from a priornext_cursorunread_only—1ortrueto return only rows withread_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. Also emails the owner — see notifications. |
suggestion.approved |
Your suggested change was approved and merged. Also emailed. |
suggestion.rejected |
Your suggested change was rejected. Also emailed. |
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 to a suggest share) 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 to a share whose access is
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 + email 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 + email 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. Each item carries an excerpt field (first ~200 chars of the suggested content).
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 50cursor(orbefore) — opaque cursor from a priornext_cursorstatus—suggested|approved|rejectedfilter
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, access 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 50cursor— opaque cursor from a priornext_cursorstatus—approved|suggested|rejectedfilter
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 50cursor— ms-epoch cursor from a priornext_cursoraction— narrow to one event type (e.g.share.delete,share.visibility(access + indexing flips),org.member.add,share.restore,share.moderation,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_access": "comment", "from_indexed": false,
"to_access": "comment", "to_indexed": true,
"password_cleared": false, "via": "api"
},
"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 50cursor— ms-epoch cursor from a priornext_cursorstatus—pending|approved|denied|cancelledfilter
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,
"domain": "acme.com",
"image_url": null
}
]
}
domain / image_url are the project's visual identity (see
PATCH);
both are null when unset, which is how a backfill script finds the
projects that still need one. Org members see every project; a
project-only viewer sees just the ones they belong to.
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 + visual identity#
Auth: per-user token or cookie session (org admin). A non-admin gets
404, not 403.
Body — every field is optional; omit one to leave it as-is:
{
"slug": "new-slug",
"name": "New name",
"domain": "acme.com",
"image_url": "https://acme.com/logo.png"
}
Renaming the slug 301s old URLs via project_redirects (history
preserved).
domain and image_url drive the project card's avatar on the
dashboard: domain shows that site's favicon, image_url is an explicit
image and wins when both are set, and neither falls back to an
initial-letter avatar. Pass "" to clear either one. domain is
normalized server-side (scheme, www., path, and case are stripped), so
this endpoint and the dashboard settings form can't disagree about what
gets stored — the response echoes the stored values, not what you sent:
curl -sX PATCH https://anchorify.io/api/v1/orgs/acme/projects/q1 \
-H "Authorization: Bearer $REPO_SHARE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"domain":"https://www.Acme.com/pricing"}'
{"slug": "q1", "name": "Q1", "renamed": false, "domain": "acme.com", "image_url": null}
A non-string domain or image_url returns 400. Both fields are also
reported by GET .../projects,
so a script can find the projects that still need one.
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 omitroleand continue to getviewer.
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.404for 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).404for 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"requiresproject_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 (
/dashboardfor 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 →
400block (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-usernamewith a pending-signup cookie carrying the resumenext. - 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 samelimit - A
nullnext_cursormeans 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: plan-dependent. Free orgs get
MAX_SHARE_BYTES(default 1 MiB = 1,048,576 bytes); Pro orgs getMAX_SHARE_BYTES_PRO(default 10 MiB = 10,485,760 bytes). The cap is set by the owner org's plan — on create that's the writer's home org, on update the share's own org. Counted as UTF-8 byte length of the decodedcontentstring (or the raw byte size for binary uploads). Oversize writes return413 {"error":"file too large","limit":<bytes>}before any DB roundtrip, wherelimitreflects the caller's effective cap. - Per-user share cap: plan-dependent. Free orgs get
MAX_SHARES_PER_USER(default 500); Pro orgs getMAX_SHARES_PER_USER_PRO(default 5000). Like the size cap, the number is set by the owner org's plan — the org the new share lands in. Enforced on INSERT only. Hitting the cap returns429 {"error":"share limit reached"}. Updates do not count. - Comment body: 1..2000 characters after trimming. Backed by a
DB
CHECKconstraint. - 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).
Access model#
Every share carries two independent columns: access — one rung of an
ordered ladder — and indexed, a boolean controlling public listing and
search indexing. Together they replaced the pre-2026 visibility tier ×
link_permission pair.
| rung | What a URL-holder can do |
|---|---|
restricted |
Nothing. Only org admins, project members, per-share editors, and org viewers can read; everyone else gets 404 (no info-leak). |
view |
Read. |
comment (default) |
Read; signed-in users can also comment and react. |
suggest |
Read, comment, react; signed-in users can also submit suggested edits. |
Anonymous callers only ever get read, at every rung — comment / reaction / suggestion creation always requires a signed-in user.
| Aspect | indexed: false (default) |
indexed: true |
|---|---|---|
| Reachable via URL | Yes, per the access rung | Yes, per the access rung |
| Search-engine index | Blocked: noindex, nofollow |
Allowed |
/discover + sitemap-shares.xml |
Never | Yes, if eligible |
| Password allowed | Yes, when access > restricted |
No |
Rules the API enforces:
accessdefaults to"comment"andindexedtofalseon INSERT when the fields are omitted fromPOST /. An update that omits them leaves the current values alone.indexedis forced tofalsewheneveraccessis"restricted"— there is nothing to list.- A password is valid only on a share that is link-reachable AND not
indexed. Any request that would produce another combination is rejected
with
400 {"error":"indexed shares cannot have a password"}or400 {"error":"restricted shares cannot have a password"}. - Changing a password-protected share to
restrictedor toindexed: truerequires?force=1onPOST /api/v1/shares/:id/access; force clears the password and reportspassword_cleared: true. - A not-indexed → indexed transition passes the anti-spam gate and can be
refused with
403 {"error":"index_forbidden","reason":"…"}. Pro orgs bypass it; otherwise the account must be verified (Google) or older than 24h, and the org must be under 10 newly-indexed shares in the trailing hour. A share already indexed is never re-gated on edit. - Listing on
/discoverand insitemap-shares.xmladditionally requiresmoderation_status = 'ok', no password, and a small content-quality floor. A reported share flips toflaggedand drops out of both; an operator-blockedshare404s on public render entirely.
See Access and passwords for the same model in prose.
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/access |
POST /api/v1/shares/:id/access |
POST /api/shares/:id/downloads |
POST /api/v1/shares/:id/downloads |
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— exact slug regex and validation rules/docs/anchorify-skill— theanchorifyskill (CLI wrapper) that drives most of these endpoints