Assisted-by: GLM 5.3
31 KiB
API
Volumen serves a JSON API under /api/volumen; the admin interface at
/admin is server-rendered HTML for humans and is not part of this API.
HTTP API
Base URL: {base_url}/api/volumen, where base_url is the [site].base_url
setting (CONFIGURATION.md). Every path below is relative
to that prefix.
Reads need no credential. A write carries Authorization: Bearer <token>,
where the token is minted in the admin at Settings, API tokens and shown
once, at creation. Two scopes exist: write for POST and PUT, and
delete for DELETE. A token created with no scope selected is unrestricted,
and a scope list that names no recognised scope is refused rather than turned
into an unrestricted token. There is no read scope, because every read
endpoint is public, so a token can only widen access to the write and delete
operations.
A HEAD request is answered as the GET of the same path: same status, same
headers, no body.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/volumen/site |
Site metadata |
GET |
/api/volumen/posts |
Paginated, filterable post list |
GET |
/api/volumen/posts/batch |
Several post details in one request |
GET |
/api/volumen/posts/{slug} |
One post with body and rendered HTML |
POST |
/api/volumen/posts |
Create a post (scope write) |
PUT |
/api/volumen/posts/{slug} |
Update a post (scope write) |
DELETE |
/api/volumen/posts/{slug} |
Delete a post (scope delete) |
GET |
/api/volumen/tags |
Tag cloud with counts |
GET |
/api/volumen/tags/{tag} |
Posts carrying one tag |
GET |
/api/volumen/tags/{tag}/feed.xml |
RSS 2.0 feed for one tag |
GET |
/api/volumen/tags/{tag}/feed.atom |
Atom 1.0 feed for one tag |
GET |
/api/volumen/tags/{tag}/feed.json |
JSON Feed 1.1 for one tag |
GET |
/api/volumen/series |
Series list with post counts |
GET |
/api/volumen/series/{name} |
Posts of one series, in reading order |
GET |
/api/volumen/series/{name}/feed.xml |
RSS 2.0 feed for one series |
GET |
/api/volumen/series/{name}/feed.atom |
Atom 1.0 feed for one series |
GET |
/api/volumen/series/{name}/feed.json |
JSON Feed 1.1 for one series |
GET |
/api/volumen/feed.xml |
RSS 2.0 feed |
GET |
/api/volumen/feed.atom |
Atom 1.0 feed |
GET |
/api/volumen/feed.json |
JSON Feed 1.1 document |
GET |
/api/volumen/sitemap.xml |
XML sitemap |
OPTIONS |
/api/volumen/{rest...} |
CORS preflight for the whole subtree |
GET /api/volumen/site
Site metadata, taken from the configuration. Every value is echoed as
configured and every one of them is a string, so fediverse_creator is ""
when the key is unset rather than null.
curl -s 'https://lab.example.com/api/volumen/site'
{
"title": "Research Notes",
"description": "Powered by Volumen.",
"base_url": "https://lab.example",
"language": "en",
"author": "Anonymous",
"fediverse_creator": ""
}
The response carries an ETag; send it back in If-None-Match to receive
304 Not Modified with no body. The post list, the post batch, the tag cloud,
a tag's post list and a single post carry one as well. The feeds, the sitemap
and the other endpoints do not.
GET /api/volumen/posts
| Parameter | Type | Default | Effect |
|---|---|---|---|
page |
integer | 1 |
Page number, 1 to 1000000. Ignored when cursor is set |
limit |
integer | 20 |
Page size, 1 to 100 |
lang |
string | unset | Keep posts whose language is this value, plus posts flagged all_langs |
tag |
string | unset | Keep posts carrying this tag |
q |
string | unset | Case-insensitive search over the title, the tags, the excerpt and the body. Matching posts are ordered by relevance: a title hit outranks a tag hit, an excerpt hit and a body hit, and a title that starts with the query leads its class; equal scores keep the date order |
cursor |
string | unset | Slug to start after; switches the response to the cursor shape |
A malformed page or limit is rejected with 422 and the validation
envelope. An unknown slug in cursor yields a page with no posts and a null
cursor rather than silently rewinding to the first page, so a paging client
never receives posts it already holds.
curl -s 'https://lab.example.com/api/volumen/posts?limit=20'
{
"page_size": 20,
"total": 2,
"posts": [
{
"slug": "alpha",
"title": "Alpha",
"excerpt": "Alpha body with a [link](https://example.com).",
"date": "2026-08-18",
"lang": "cs",
"tags": ["go", "research"],
"author": "Petr",
"fediverse_creator": "@petr@social",
"cover": "/media/c.webp",
"cover_alt": "alt",
"cover_caption": "caption",
"reading_time": 1,
"translations": { "en": "alpha-en" },
"series": "Series",
"series_order": 1,
"url": "/api/volumen/posts/alpha"
},
{
"slug": "beta",
"title": "Beta",
"excerpt": "Beta body.",
"date": "2026-07-01",
"lang": "en",
"tags": ["go"],
"reading_time": 1,
"url": "/api/volumen/posts/beta"
}
],
"page": 1,
"has_next": false,
"has_prev": false
}
Without a cursor the response carries page, has_next and has_prev. With
a cursor it carries next_cursor instead: the slug of the last post on the
page, or null when the list is exhausted. When two posts share a slug (a
post and its translation may) the cursor is slug.lang, so the next page
resumes after the exact post rather than after the first variant that
matches. Either way total counts every post the filter matched, before
pagination, and page_size is the limit the server applied.
{
"page_size": 1,
"total": 2,
"posts": [
{
"slug": "beta",
"title": "Beta",
"excerpt": "Beta body.",
"date": "2026-07-01",
"lang": "en",
"tags": ["go"],
"reading_time": 1,
"url": "/api/volumen/posts/beta"
}
],
"next_cursor": null
}
Fields
Used by the list endpoints, the tag and series lists, the series detail and
the write responses. The shape is payloads.Summary, built by
payloads.BuildSummary; the struct tags decide which fields are omitted.
| Field | Type | Presence | Notes |
|---|---|---|---|
slug |
string | omitted when empty | Stable post identifier |
title |
string | omitted when empty | Post title |
excerpt |
string | always present | The frontmatter excerpt, else the first non-heading body paragraph cut to 200 characters, with an ellipsis when anything was dropped |
date |
string | omitted when empty | Publication date, YYYY-MM-DD |
lang |
string | omitted when empty | Post language |
tags |
array of strings | omitted when empty | Post tags |
author |
string | omitted when empty | Post author from the frontmatter; the site author is not substituted into the payload |
fediverse_creator |
string | omitted when empty | @user@host, per-post override of the site default |
doi |
string | omitted when empty | Digital Object Identifier, bare 10.…/… form; a stored doi.org URL or doi: prefix is normalised for display, and a value that fails the syntax rule passes through untouched. Set in the frontmatter or the admin editor, not by the write endpoints |
orcid |
string | omitted when empty | The author's ORCID iD, 0000-0000-0000-000X, check digit verified when the value is written from the admin editor; upper-cased for display when valid, the raw text otherwise. Set in the frontmatter or the admin editor, not by the write endpoints |
cover |
string | omitted when empty | Cover image path or URL, typically /media/... |
cover_alt |
string | omitted when empty | Alt text for the cover |
cover_caption |
string | omitted when empty | Caption for the cover |
reading_time |
integer | always present | Minutes, ceil(words / 200), at least 1 |
translations |
object | omitted when empty | Maps a language code to the slug of a sibling translation |
series |
string | omitted when empty | Series name |
series_order |
integer | omitted when the frontmatter has none | Position within the series |
url |
string | always present | API detail URL, /api/volumen/posts/{slug} |
GET /api/volumen/posts/batch
Returns the detail of several posts in one response. slugs is a
comma-separated list; entries are trimmed, empty ones dropped, and the list is
capped at 100 slugs. A slug that names no published post is skipped, so the
posts array may be shorter than the request and the endpoint never answers
404. Without slugs the posts array is empty.
curl -s 'https://lab.example.com/api/volumen/posts/batch?slugs=alpha,beta'
The entry below is one detail response, printed in full:
{
"posts": [
{
"slug": "alpha",
"title": "Alpha",
"excerpt": "Alpha body with a [link](https://example.com).",
"date": "2026-08-18",
"lang": "cs",
"tags": ["go", "research"],
"author": "Petr",
"fediverse_creator": "@petr@social",
"cover": "/media/c.webp",
"cover_alt": "alt",
"cover_caption": "caption",
"reading_time": 1,
"translations": { "en": "alpha-en" },
"series": "Series",
"series_order": 1,
"url": "/api/volumen/posts/alpha",
"body": "Alpha **body** with a [link](https://example.com).\n",
"html": "<p>Alpha <strong>body</strong> with a <a rel=\"noopener noreferrer\" href=\"https://example.com\">link</a>.</p>\n",
"toc": "<div class=\"toc\">\n<ul></ul>\n</div>\n",
"meta": {
"url": "https://lab.example/alpha",
"json_ld": "{\"@context\":\"https://schema.org\",\"@type\":\"Article\",\"author\":{\"@type\":\"Person\",\"name\":\"Petr\"},\"creator\":{\"@type\":\"Person\",\"name\":\"@petr@social\"},\"dateModified\":\"2026-08-18\",\"datePublished\":\"2026-08-18\",\"description\":\"Alpha body with a [link](https://example.com).\",\"headline\":\"Alpha\",\"image\":[\"/media/c.webp\"],\"inLanguage\":\"cs\",\"keywords\":[\"go\",\"research\"],\"mainEntityOfPage\":{\"@id\":\"https://lab.example/alpha\",\"@type\":\"WebPage\"},\"url\":\"https://lab.example/alpha\"}",
"og": {
"article:author": "Petr",
"article:published_time": "2026-08-18",
"article:tag": ["go", "research"],
"og:description": "Alpha body with a [link](https://example.com).",
"og:image": "/media/c.webp",
"og:locale": "cs",
"og:title": "Alpha",
"og:type": "article",
"og:url": "https://lab.example/alpha"
},
"twitter": {
"twitter:card": "summary_large_image",
"twitter:creator": "@petr@social",
"twitter:description": "Alpha body with a [link](https://example.com).",
"twitter:image": "/media/c.webp",
"twitter:title": "Alpha"
}
}
}
]
}
The JSON here is reformatted for the page; the response shape is pinned by
the fixtures under
internal/httpapi/testdata/contract/,
which carry a differently configured deployment's values.
GET /api/volumen/posts/{slug}
| Parameter | Type | Default | Effect |
|---|---|---|---|
lang |
string | unset | Select the language variant; a post flagged all_langs matches every value |
preview_token |
string | unset | Signed preview credential for a draft or a scheduled post |
The response is a post detail. A slug that matches an alias is answered with
301 Moved Permanently and a Location header naming the canonical post. A
draft and a scheduled post are distinguishable on purpose: the error code says
which of the two withheld the post, because the client already knows the slug
and the two states need different handling.
The response carries an ETag naming the post's state: send it back in
If-None-Match for a 304, or in If-Match on a write to refuse overwriting
a change you never saw.
| Status | Meaning |
|---|---|
200 |
The post detail |
301 |
The slug is an alias; Location names the canonical slug |
304 |
The request carried If-None-Match matching the current ETag |
404 |
{"error": "not_found"}, or {"error": "draft"} / {"error": "scheduled"} for a withheld post with no valid preview token |
500 |
{"error": "render_failed"} when the Markdown pipeline fails |
A preview link is signed as an HMAC over the slug and an expiry stamp, keyed
with [admin].session_key, and stays valid for seven days. Without a session
key no link is issued at all: the admin route GET /admin/posts/{slug}/preview-link then answers 409 with the code
no_session_key. A preview response carries Cache-Control: no-store: it
holds unpublished content, and a shared cache must not keep it.
Fields
Returned by GET /api/volumen/posts/{slug} and by the batch endpoint. The
shape is payloads.Detail: the summary above, plus:
| Field | Type | Notes |
|---|---|---|
body |
string | Raw Markdown source |
html |
string | Rendered, sanitised HTML |
toc |
string | Sanitised table of contents |
meta |
object | SEO and discovery metadata, computed per request from the post and never persisted |
references |
array of objects | The structured bibliography from the post's refs frontmatter: each entry has num, optional authors ({name, orcid}), title, venue, year, volume, pages, doi, arxiv, url, or the verbatim raw text. When the entry's DOI is published by another post of the same instance, it also carries internal: the API address of that post, so a consumer can keep the reader on site. Omitted when the post cites nothing |
fields |
object | The frontmatter keys the engine does not consume itself, the author's own: colour: "#0f0" lands here as {"colour": "#0f0"}. Nested tables, arrays and TOML date-times pass through (dates as their canonical strings). Omitted when every key is a known one |
html is rendered by scriptorium (CommonMark, GFM, footnotes, definition lists) with
$$…$$ and $…$ mathematics converted to MathML Core and fenced mermaid blocks
(flowcharts and sequence diagrams) drawn as inline SVG, and it is
sanitised by bluemonday against a narrow allowlist: headings keep their id
attributes, fenced code blocks carry class="language-...", an image whose
title is set becomes a figure with a figcaption, a post with refs gets a
numbered <section class="refs" id="references"> spliced at its [[refs]]
marker, or appended at the end of the body when it carries none, with every
inline [n] citation linked to it. A reference whose DOI
is published by another post of the same instance links to that post's API
address instead of the external resolver, and the same address appears as
the url of its JSON-LD citation next to the canonical identifier; a post
citing its own DOI keeps the resolver link. Every link in the
body and in the table of contents gains rel="noopener noreferrer". The
allowlist admits http, https and mailto URLs plus relative ones, so a
body cannot inject a script, a style or an event handler. A mermaid diagram
the renderer does not carry stays a fenced code block, and a mathematics
construct outside the mappable surface stays visible as its verbatim source
in the place it was written.
toc is the wrapper <div class="toc"> with a nested list of heading links.
The wrapper is always present, even when the body has no headings, in which
case it holds an empty list:
"toc": "<div class=\"toc\">\n<ul></ul>\n</div>\n"
An empty body has no table of contents at all and yields an empty toc
string.
meta carries:
| Field | Type | Notes |
|---|---|---|
url |
string | Canonical post URL: base_url plus the slug, or the API path when no base_url is set |
json_ld |
string | A Schema.org Article document, serialised as a JSON string |
og |
object | Open Graph fields, keyed og:type, og:title, og:description, og:url, og:locale, article:published_time, plus og:image, article:tag and article:author when the post carries them |
twitter |
object | Card fields, keyed twitter:card, twitter:title, twitter:description, plus twitter:image and twitter:creator when the post carries them |
POST /api/volumen/posts
Creates a post. The body is a JSON object; fields not listed are ignored. The
response is 201 Created with the post summary as its body, no Location
header, and an ETag naming the new state for a later If-Match write.
| Field | Type | Effect |
|---|---|---|
title |
string | Post title |
slug |
string | Post slug; required, and unique |
lang |
string | Language code |
author |
string | Author name |
fediverse_creator |
string | @user@host handle |
excerpt |
string | Summary text, overrides the derived excerpt |
body |
string | Markdown source, at most 1 MiB |
tags |
array of strings, or a comma-separated string | Post tags |
cover |
string | Cover image path or URL |
cover_alt |
string | Cover alt text |
cover_caption |
string | Cover caption |
series |
string | Series name |
series_order |
integer | Position within the series |
date |
string | Publication date, YYYY-MM-DD |
publish_at |
string | Scheduled publication date, YYYY-MM-DD |
draft |
boolean | Withhold the post |
all_langs |
boolean | Serve the post for every requested language |
An empty string or null clears an optional field. A value of the wrong type
is rejected with 400 rather than coerced. Validation failures answer
{"error": "validation", "message": "..."} with messages such as
Slug is required., Invalid slug., A post with that slug already exists.
and Body must be at most 1048576 bytes.
curl -s -X POST 'https://lab.example.com/api/volumen/posts' \
-H 'Authorization: Bearer vol_...' \
-H 'Content-Type: application/json' \
-d '{"slug":"hello","title":"Hello","body":"Hi.","tags":["meta"]}'
PUT /api/volumen/posts/{slug}
Updates the post named by the path. Fields omitted from the body keep their
stored value, so the request is a merge rather than a replacement. The
response is 200 OK with the post summary.
A slug in the body that differs from the path renames the post: the file is
written under the new slug and the old file is soft-deleted, so the rename
stays undoable.
The request may carry If-Match with the ETag a GET of the post served:
when it no longer names the stored state, the write is refused with 412
instead of overwriting a change the client never saw. If-Match: * demands
only that the post exists. A write without the header stays unconditional.
The 200 response carries the new state's ETag, so edits can chain without
re-reading. The precondition is checked against the post the path names, the
one a GET without lang serves.
| Status | Meaning |
|---|---|
200 |
The post summary, with the new ETag |
400 |
{"error": "invalid_json"} or the validation envelope |
401 |
{"error": "unauthorized"} |
403 |
{"error": "forbidden", "message": "Token lacks 'write' scope"} |
404 |
{"error": "not_found"} |
412 |
{"error": "precondition_failed"} when If-Match names an older state |
500 |
{"error": "save_failed"} when the file cannot be written, or {"error": "render_failed"} when an If-Match precondition has to render a stored post that no longer renders |
DELETE /api/volumen/posts/{slug}
Deletes the post. The response is 204 No Content with no body. The post is
soft-deleted as a tombstone, so it can be restored from the admin while the
tombstone survives.
An If-Match header carrying the ETag a GET of the post served refuses
the delete with 412 when it names an older state; * and a missing header
are unconditional.
| Status | Meaning |
|---|---|
204 |
Deleted |
401 |
{"error": "unauthorized"} |
403 |
{"error": "forbidden", "message": "Token lacks 'delete' scope"} |
404 |
{"error": "not_found"} |
412 |
{"error": "precondition_failed"} when If-Match names an older state |
500 |
{"error": "delete_failed"}, or {"error": "render_failed"} when an If-Match precondition has to render a stored post that no longer renders |
GET /api/volumen/tags
{
"tags": [
{ "name": "go", "count": 2 },
{ "name": "research", "count": 1 }
]
}
Tags are ordered by count, most used first, then by name. Only published posts
are counted. The response carries an ETag.
GET /api/volumen/tags/{tag}
A page of the posts carrying one tag, in the shape of
GET /api/volumen/posts with the same page,
limit, lang and cursor parameters. A tag that names no published post
answers 404 with {"error": "not_found"}.
GET /api/volumen/series
{
"series": [{ "name": "Series", "count": 1 }]
}
Ordered by count, most posts first, then by name.
GET /api/volumen/series/{name}
{
"name": "Series",
"count": 1,
"posts": [
{
"slug": "alpha",
"title": "Alpha",
"excerpt": "Alpha body with a [link](https://example.com).",
"date": "2026-08-18",
"lang": "cs",
"tags": ["go", "research"],
"author": "Petr",
"reading_time": 1,
"series": "Series",
"series_order": 1,
"url": "/api/volumen/posts/alpha"
}
]
}
The posts are in reading order: by series_order first, and a post without
one after every ordered post, then by date and slug. A name that matches no
series answers 404 with {"error": "not_found"}.
Feeds
| Path | Document |
|---|---|
/api/volumen/feed.xml |
RSS 2.0, application/rss+xml |
/api/volumen/feed.atom |
Atom 1.0, application/atom+xml |
/api/volumen/feed.json |
JSON Feed 1.1, application/json |
/api/volumen/tags/{tag}/feed.xml, /api/volumen/tags/{tag}/feed.atom, /api/volumen/tags/{tag}/feed.json |
The same three formats for one tag |
/api/volumen/series/{name}/feed.xml, /api/volumen/series/{name}/feed.atom, /api/volumen/series/{name}/feed.json |
The same three formats for one series |
A feed carries at most 20 posts: the newest 20 for the site feed and a tag
feed, and the first 20 of the series, in its reading order, for a series feed.
Every document names its own address: RSS carries <atom:link rel="self">,
Atom carries <link rel="self">, and JSON Feed carries feed_url, each with
the path that was actually requested, so a tag feed or a series feed reports
its own URL rather than the site feed's. A tag or series feed answers 404
with {"error": "not_found"} when nothing matches.
GET /api/volumen/sitemap.xml
Every published post, truncated to the newest 50 000 (the ceiling the
sitemaps.org protocol sets for one document), with <lastmod> from the file's
modification time and, for a post whose file cannot be read, from its date.
The content type is application/xml.
OPTIONS /api/volumen/{rest...}
The CORS preflight for the whole API subtree. It advertises
GET, POST, PUT, DELETE, OPTIONS with Content-Type, Authorization, sets
Access-Control-Max-Age: 600, and answers 200 with an empty
text/plain; charset=utf-8 body. The write methods are listed on purpose: a
browser preflight for a cross-origin POST fails when the answer names only
GET. The answer carries the write CORS set rather than the read one, so the
allowed origin follows the rule the Headers section states for a
token-authenticated write.
Flow
The token-authenticated write flow, from the request to the stored file:
sequenceDiagram
participant Client
participant API
participant Tokens as Token store
participant Posts as Content directory
Client->>API: POST /api/volumen/posts with Authorization: Bearer
API->>Tokens: Authenticate the raw token
Tokens->>Tokens: compare the SHA-256 digest
Tokens-->>API: token record with its scopes
API->>API: require the write scope
API->>Posts: Save the post, archiving the previous version
Posts-->>API: the saved path
API-->>Client: 201 Created with the post summary
At each step, a failure surfaces in the response: a missing or unknown token
as 401 unauthorized, a token without the scope as 403 forbidden, a body
that is not a JSON object as 400 invalid_json, and a rejected field as 400 validation. When the post is stored, the post.created event fires and every
webhook subscribed to it receives the post summary.
Notes
The Go structs and their JSON tags are the authority on the response shapes,
and the committed fixtures under
internal/httpapi/testdata/contract/
pin them: a change to a body or a status code fails the contract test until
the fixture is regenerated with go test ./internal/httpapi -update-contract
and the change is recorded in the changelog. go doc ./internal/payloads
covers the exported types. This file explains what the surface is for; it does
not repeat the struct definitions, because a copy of a signature is a future
lie.
Errors
Every failure uses one envelope: an error code, optionally with a
human-readable message and extra fields.
{ "error": "not_found" }
{ "error": "validation", "message": "Invalid slug.", "field": "slug" }
| Code | Status | Extra fields | Meaning |
|---|---|---|---|
not_found |
404 | No such post, tag, series, or media file | |
draft |
404 | The post exists but is a draft | |
scheduled |
404 | The post exists but is scheduled for the future | |
validation |
400, 422 | message, and field for a query parameter |
The payload or query is invalid |
invalid_json |
400 | The request body is not a JSON object | |
payload_too_large |
413 | The request body exceeds 10 MiB | |
precondition_failed |
412 | The request's If-Match names an older state than the stored one |
|
unauthorized |
401 | Missing or invalid bearer token | |
forbidden |
403 | message |
The token lacks the required scope |
rate_limited |
429 | retry_after |
Too many requests |
render_failed |
500 | The Markdown pipeline failed | |
save_failed |
500 | The post could not be written to disk | |
delete_failed |
500 | The post could not be deleted |
A 401 carries WWW-Authenticate: Bearer. Failures written by the API carry
Cache-Control: no-store, so a shared cache never keeps one; the 429 is
written by the rate limiter before the handler and carries the rate-limit
headers instead.
The router answers for itself outside the routes above: a path under
/api/volumen/ that matches no route, or a method a route does not serve,
gets 405 Method Not Allowed with an Allow header listing the methods the
path does serve, and a plain-text body rather than the JSON envelope.
Headers and caching
- JSON responses use
Content-Type: application/json. The XML documents carryapplication/rss+xml,application/atom+xmlandapplication/xml. - A read answers with the permissive set:
Access-Control-Allow-Origin: *,Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONSandAccess-Control-Allow-Headers: Content-Type, Authorization. A token-authenticated write echoes the configuredbase_urlas the allowed origin when the request carries anOriginheader, and falls back to*when no base URL is configured or the request carries noOriginheader, so a browser can read a cross-origin write from the site itself and not from anywhere else. The router's405and the rate limiter's429are written outside a handler and carry only what their own sections note. - Successful reads carry
Cache-Control: public, max-age=60, stale-while-revalidate=21600. Write responses carryno-store. - Bodies of 500 bytes or more are gzip-compressed when the client sends
Accept-Encoding: gzip, and such a response carriesVary: Accept-Encodingso a shared cache keys on it. - Dates are ISO 8601 (
YYYY-MM-DD). - Lists are newest first by post date. Drafts and scheduled posts never appear.
- Empty optional fields are omitted rather than sent as
null; a client must treat a missing key as "not set". Two fields of a post summary are the exception and are always present:excerptandreading_time. The site payload is different in kind: it echoes the configured values, and every one of them is a string, so itsfediverse_creatoris""when the key is unset. - Bodies are written by the Go 1.27
encoding/json/v2encoder with deterministic member order and no HTML escaping, so<,>and&appear as themselves and the same content produces the same bytes. That is what makes theETaground trip above reliable. A request body that names the same member twice is rejected asinvalid_jsonrather than silently taking one of the two values. - Malformed query parameters are rejected with
422and the validation envelope.
Rate limiting
With [api].rate_limit above zero, every /api/volumen/* response carries
X-RateLimit-Limit and X-RateLimit-Remaining, counted per client address in
a sliding window of [api].rate_limit_window seconds. A request over the
budget is answered 429:
{ "error": "rate_limited", "retry_after": 42 }
with a Retry-After header carrying the same number of seconds. The limiter
covers the API prefix only: /media/*, /admin/* and /healthz are not
counted. The client address is the last entry of X-Forwarded-For when
[server].trust_proxy is set and the connection comes from a peer listed in
[server].trusted_proxies, and the connection address otherwise. The refusal
carries the read CORS set (GET, OPTIONS with Content-Type) rather than the
wide one, and no cache header.
Operational routes
Outside the API prefix, the application serves a few operational routes:
| Route | Behaviour |
|---|---|
GET /healthz |
{"status": "ok", "checks": {...}} with Cache-Control: no-store. The status is degraded and the code 503 when the content directory is missing, a post file cannot be parsed, the users, tokens or templates file cannot be read, or the filesystem has under 100 MB free. content_dir, users_file and disk are always present; content_files, tokens_file and templates_file appear only when something is wrong with them. Not rate limited |
GET /robots.txt |
Allows all crawlers and points at the sitemap: Sitemap: {base_url}/api/volumen/sitemap.xml |
GET /sitemap.xml |
301 redirect to /api/volumen/sitemap.xml |
GET /favicon.ico |
The bundled SVG icon, Cache-Control: public, max-age=86400 |
GET /media/{path} |
An uploaded image from <content_dir>/media/. The file name is a UUID with a .webp, .avif or .svg extension, assigned when the upload is stored, so no uploader chooses a name; only those three extensions resolve, and anything else, including a missing file, answers 404 with {"error": "not_found"}. The type is set from the extension rather than sniffed, so a file whose bytes do not match is still served as an image and never as a document; an SVG, the one accepted image that is also a document, is additionally served with a sandboxing Content-Security-Policy, so opened at its own URL it runs as no one. Responses carry Cache-Control: public, max-age=604800. They are served outside the gzip and session layers, so a large image is never buffered in memory |
GET / |
303 redirect to /admin/ |
| anything else outside the API prefix | 404 with {"error": "not_found"} |