One audience of one site, over HTTP.
Read the pages of a site, open a proposal against them, watch what happened, and search — with a credential that is worth exactly one audience of one site, and which discloses nothing about the audiences it is not worth.
What this API is
Two paragraphs, then the requests.
This is the API of the engine underneath Verso — the same one the product’s own screens call, and the one संचिका Sanchika, a separate product for government record-keeping, is built against. There is no second, reduced surface for outsiders: an editor pressing Send for review and a script posting to /api/v1/pages arrive at the same place and leave the same record behind.
Everything is addressed as one audience of one site. A site is a set of pages; an audience is who may see them, and each audience answers with its own pages, its own proposals and its own history. A credential is issued for a single audience, which is what makes it safe to hand to a piece of software — and a request that names an audience the credential is not for is not refused, it simply finds nothing there.
Authentication
A bearer token, issued for one audience and one audience only. Naming another in the query string grants nothing.
Where a token comes from
A site administrator issues one in the product, under Settings → API tokens (/s/<site>/settings/api). The form asks what the token is for, which audience — one, chosen from that site’s audiences, and not changeable afterwards — what it may do, and when it should expire. The secret is shown once, on that screen, and is never recoverable; only its hash is stored, so a database dump yields no working credentials. Reissue rather than recover.
Send it as an ordinary bearer credential. The token string begins vs_, so it is recognisable in a log or a support ticket.
export VERSO=https://getverso.dev export TOKEN=vs_… curl -s "$VERSO/api/v1/pages?site=harbour" \ -H "Authorization: Bearer $TOKEN"
The part worth reading twice
A token is bound to its audience at the moment it is issued. The grant is keyed on that audience inside the resolver, so naming a different one in the query string does not widen it — and, because a credential must never confirm what it cannot reach, the answer is 404, not 403. Both calls below carry the same valid token; the second one names the members-only audience.
curl -s -o /dev/null -w '%{http_code}\n' \
"$VERSO/api/v1/pages?site=harbour" -H "Authorization: Bearer $TOKEN"
curl -s -o /dev/null -w '%{http_code}\n' \
"$VERSO/api/v1/pages?site=harbour&surface=internal" -H "Authorization: Bearer $TOKEN"200 404
The blast radius of a leaked token is therefore one audience of one site, and a holder of it cannot learn that another audience exists.
What a token may do
Capabilities, not a role. Roles belong to people, who acquire responsibilities over time; a token is issued for a job and should not gain one because somebody was promoted.
Writing needs a credential the token screen will not issue
The token form offers read, search and ai and nothing else. The resolver would honour propose and merge on a token perfectly well — the write routes in §03 and §04 are real and are exercised by the test suite — but a bearer string with no second factor and no lockout should not be one form submission away from changing what a customer has published. So the writing half of this API is reachable today by a signed-in person’s own session, and a writing token is a decision that will arrive with its own review rather than as a checkbox that was already there.
No token at all, and a token that is wrong
Neither is an error. A request with no credential is an anonymous visitor: it gets whatever the public audience of that site publishes, and finds every other audience absent. A request carrying a string that is not a live token is the same anonymous visitor — the credential resolves to nobody rather than to a refusal, so nothing is learned by guessing. Both are the honest answer, and both are why 404 is the interesting status code here and 401 never appears.
The first request
Address the site and the audience explicitly; read the pages back.
Two query parameters address everything: site is the site’s slug and surface is the audience’s key. Both are optional, and a machine caller should nonetheless always send them — omitted, the answer is decided by the request’s host and cookies, which is right for a browser and wrong for a script. Naming them grants nothing: they decide which question is asked, never who is allowed to ask it.
curl -s "$VERSO/api/v1/pages?site=harbour" \ -H "Authorization: Bearer $TOKEN"
{
"pages": [
{
"id": "pg_home",
"slug": "/",
"title": "Harbour",
"description": "How Harbour works, for the people who use it.",
"section": null,
"tags": [],
"path": "index.mdx",
"frontmatter": {
"title": "Harbour",
"id": "pg_home",
"description": "How Harbour works, for the people who use it."
}
},
{
"id": "pg_refunds",
"slug": "/guides/refunds",
"title": "Refunds",
"description": "When money goes back, and how long it takes.",
"section": "/guides",
"tags": ["payments", "policy"],
"path": "guides/refunds.mdx",
"frontmatter": {
"title": "Refunds",
"id": "pg_refunds",
"description": "When money goes back, and how long it takes.",
"tags": ["payments", "policy"]
}
}
…
]
}Three fields are worth knowing before §03. id is permanent: it survives a page being renamed or moved to another section, so it is the thing to store. slug is the address a reader visits, and it changes when the page moves. frontmatter is the page’s own settings block, returned verbatim and never rewritten on the way out.
Pages
The pages of an audience, read at the address a reader would use — and written as proposals, never in place.
One page, record and text
A page is addressed by its slug, slashes included: /api/v1/pages/guides/refunds. The answer carries the indexed record and the exact text of the page as this audience has it, so a client that fetches, edits and sends it back is doing precisely what the editor does.
curl -s "$VERSO/api/v1/pages/guides/refunds?site=harbour" \ -H "Authorization: Bearer $TOKEN"
{
"page": {
"id": "pg_refunds",
"slug": "/guides/refunds",
"title": "Refunds",
"description": "When money goes back, and how long it takes.",
"section": "/guides",
"tags": ["payments", "policy"],
"path": "guides/refunds.mdx",
"frontmatter": { … }
},
"source": "---\ntitle: Refunds\nid: pg_refunds\ndescription: When money goes back, and how long it takes.\ntags: [payments, policy]\n---\n\n# Refunds\n\nA refund is issued to the original method of payment within five working days.\n"
}source is the whole file, settings block and all. Send it back through PUT with the parts you changed and nothing else touched — a client that reconstructs the file from the record instead will silently drop any setting it does not know about.
Adding a page adds a proposal
POST /api/v1/pages takes a title, optionally the section it belongs in and the text to start with. What it creates is a proposal: nothing is on the live site, the answer names the proposal now carrying the new page, and publishing is a separate act in §04 with a separate capability. The API gets no shortcut past review, because a shortcut here would be a second, quieter door onto a customer’s published documentation.
curl -s -X POST "$VERSO/api/v1/pages?site=harbour" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"title":"Returns","section":"guides","content":"# Returns\n\nSend it back within thirty days.\n"}'{
"proposalId": "prop_EzrcwQcJeLhgqMth",
"pageId": "pg_KVqzTVeF2qUrz5Gz",
"path": "guides/returns.mdx",
"slug": "/guides/returns"
}The address is derived from the title rather than asked for, and a handful of root addresses are refused because the product already occupies them — those come back 400 naming the address, and a page that already lives at the derived slug comes back 409.
{ "source": "…" }; the answer names the proposal carrying the edit.Proposals
The queue of changes waiting on one audience, what each one would do, and the act that makes it live.
The list is filtered to the audience the credential resolves to, not to the site: a credential for the public audience must not see another audience’s drafts in flight, for exactly the reason it cannot read their pages. Absent, not refused. Filter with state=open|merged|conflicted|closed.
curl -s "$VERSO/api/v1/proposals?site=harbour" \ -H "Authorization: Bearer $TOKEN"
{
"proposals": [
{
"id": "prop_ensgj6dsneMepU9x",
"number": 2,
"title": "Correct the refund window",
"body": "Finance says five working days, not seven.",
"state": "open",
"origin": "api",
"author": "API token “docs”",
"baseRef": "main",
"baseSha": "3f7f587ddb3dffcc055f44747b88c4ce415f4a9e",
"headSha": "647cf72501ba594d5e68e794ed6b948a0b8dfa96",
"mergeCommitSha": null,
"createdAt": "2026-08-10T18:53:38.492Z",
"updatedAt": "2026-08-10T18:53:38.550Z",
"mergedAt": null
}
…
]
}author says which credential wrote it — a person’s name from a session, the token’s own name from a token. That is what an operator revoking one of nine tokens needs to see in the history. origin says which door it came through: app, api, git_push, import or ai.
Opening one directly
POST /api/v1/proposals takes a title and a files map — each key a path inside the site, each value the whole file, or null to remove that one. It is the general form of everything in §03.
curl -s -X POST "$VERSO/api/v1/proposals?site=harbour" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"title": "Correct the refund window",
"body": "Finance says five working days, not seven.",
"files": { "guides/refunds.mdx": "---\ntitle: Refunds\nid: pg_refunds\n---\n\n# Refunds\n\nFive working days.\n" }
}'{ "proposalId": "prop_ensgj6dsneMepU9x" }What it would actually do
Fetching one proposal returns it together with its ledger: every change it contains, grouped and ranked by consequence, each with the reason in plain language. This is the same reading a reviewer gets on screen, and it is the part of this API that has no equivalent elsewhere — a machine can ask what a change would do before anybody approves it.
curl -s "$VERSO/api/v1/proposals/prop_EzrcwQcJeLhgqMth" \ -H "Authorization: Bearer $TOKEN"
{
"proposal": { "id": "prop_EzrcwQcJeLhgqMth", "number": 1, "title": "Add /guides/returns", "state": "open", … },
"ledger": {
"total": 5,
"items": [
{
"id": "set:guides/returns.mdx:title",
"group": "page_settings",
"consequences": [],
"reason": "Changed the page title: unset → Returns",
"page": "/guides/returns",
"path": "guides/returns.mdx"
},
{
"id": "blk:guides/returns.mdx:1",
"group": "added_removed",
"consequences": ["added"],
"reason": "New text was added: \"Send it back within thirty days.\"",
"page": "/guides/returns",
"path": "guides/returns.mdx"
}
…
]
}
}A proposal is fetched by id alone. The audience it belongs to is the one it names itself — the caller’s site and surface parameters get no vote — so a proposal on an audience you cannot read is a plain 404, indistinguishable from an id that never existed.
Publishing
Publishing needs merge and an accountable person: the session’s user, or the person who issued the token. A credential that names nobody accountable is refused with 403 and told why. The act records an approval covering the whole ledger in the publisher’s name and then publishes — the same record every publish made on screen leaves, so the history cannot tell which door it came through, and nobody should be able to.
curl -s -X POST "$VERSO/api/v1/proposals/prop_EzrcwQcJeLhgqMth/publish" \ -H "Authorization: Bearer $TOKEN"
{ "ok": true, "sha": "fb4d140d1d6f763afbe8f403315a52088a450d8c" }Events
What happened on this audience, newest first, paged backwards by id.
The feed is filtered to the audience by equality, never by “not somebody else’s”. Acts that belong to no audience — a member added, a site-wide setting changed — are absent from it by construction, so a credential learns exactly what its own audience could have watched happen and nothing more.
curl -s "$VERSO/api/v1/events?site=harbour&limit=3" \ -H "Authorization: Bearer $TOKEN"
{
"events": [
{
"id": "ev_01KZPGBXHP46PGXKPT44JQX0NC",
"type": "proposal.merged",
"subject": { "type": "proposal", "id": "prop_EzrcwQcJeLhgqMth" },
"payload": {
"number": 1,
"commit_sha": "fb4d140d1d6f763afbe8f403315a52088a450d8c",
"pages_touched": ["guides/returns.mdx"]
},
"occurredAt": "2026-08-10T18:53:38.742Z"
},
{
"id": "ev_01KZPGBXE104F5PVGBZ7R7VJ69",
"type": "proposal.approved",
"subject": { "type": "proposal", "id": "prop_EzrcwQcJeLhgqMth" },
"payload": {
"number": 1,
"approver": "Ida Okonjo",
"changes_total": 5,
"changes_opened": 5,
"approved_unopened": 0
},
"occurredAt": "2026-08-10T18:53:38.625Z"
}
…
],
"nextBefore": "ev_01KZPGBX9WPGZ2ETCZ78S9PERG"
}nextBefore straight back to walk the history. It is null on the last page.page.published.Ids are ULIDs, so sorting them lexicographically is sorting them by time. That is why the cursor is an id rather than a timestamp: two things that happened in the same millisecond still have an order, and paging cannot skip or repeat one.
Search
Retrieval over one audience's own index — keyword and meaning, fused.
Search needs the search capability. It runs over this audience’s own physical partition of the index rather than over a shared table with a filter on it: a credential cannot reach another audience’s text because those rows are not in the tables its queries name. mode=keyword skips the meaning half, which is the right choice for very short queries; limit is 1–50 and defaults to 10.
curl -s "$VERSO/api/v1/search?site=harbour&q=refund" \ -H "Authorization: Bearer $TOKEN"
{
"hits": [
{
"chunkId": "chk_40178cce0bbd05710210",
"pageId": "pg_refunds",
"slug": "/guides/refunds",
"title": "Refunds",
"breadcrumb": "Harbour › Refunds",
"anchorSlug": null,
"url": "/guides/refunds",
"snippet": "# Refunds A refund is issued to the original method of payment within five working days.",
"blockKeys": ["blk_zwqbUYUnHEGyr92p", "blk_fLvut4uSHX98UqPU"],
"score": 0.03278688524590164,
"rank": 1,
"sources": [
{ "source": "fts", "rank": 1 },
{ "source": "vec", "rank": 1 }
]
}
],
"mode": "hybrid",
"notes": [],
"tookMs": 2,
"indexed": true
}hybrid on a corpus with no vectors and it answers keyword.false when this audience has never been indexed — an empty result, not an error, and the note says so.null above the first one.Search is rate-limited. Over the limit answers 429 with Retry-After in seconds; honour it rather than retrying immediately.
Refusals, and what they disclose
One error body everywhere, and one rule about which status code a refusal is allowed to be.
Every refusal has the same two fields: a sentence a person can act on, and a code a program can switch on. There is no third shape and no route under /api/v1 that invents its own.
curl -s "$VERSO/api/v1/pages/guides/nope?site=harbour" \ -H "Authorization: Bearer $TOKEN"
{ "error": "Not found", "code": "not_found" }404, not 403 — and the difference between them
The rule is worth stating precisely, because it is not “everything is a 404”. Not being able to see something is 404. Being able to see it and not being allowed to change it is 403. A credential that can already read an audience learns nothing new from being told it may not write there, so the honest refusal is the specific one; a credential that cannot read the audience must not be able to distinguish it from an audience that does not exist, so the honest refusal is silence in the shape of a 404.
# a read-only credential, naming a write it can see
curl -s -X POST "$VERSO/api/v1/pages?site=harbour" \
-H "Authorization: Bearer $READ_ONLY" \
-H 'Content-Type: application/json' -d '{"title":"Refused"}'{ "error": "Forbidden", "code": "forbidden" }The same rule covers a site that does not exist, an audience that is closed or past its expiry, and a proposal on an audience you cannot read: all four are 404, all four with the same body, and none of them tells you which of the four it was.
The codes
Send JSON with a non-empty "title".Retry-After in seconds.There is no 401. A missing or unrecognised credential is not an error here — it is an anonymous caller, who gets the public audience and finds every other one absent. See §01.
The reference
Every operation, generated from the specification this deployment is actually serving.
Below is the whole of /api/v1, rendered on the server from the same specification document that /api/openapi.json serves to machines — one source, so a route added without a line in it fails a test rather than quietly appearing here. The reference describes; it does not fire requests. There is no try-it console, and a reference that quietly shipped one would be shipping a security decision nobody made.
GET/api/v1/pagesList the pages of a surface
Requires read. Answers every page on the surface the credential resolves to.
sitequery · string — Site slug. Explicit addressing for machine callers; omitted, the host decides.surfacequery · string — Surface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.
200The pages, in navigation order.pagesPage[]
404No such surface, or noreadon it.errorstring · required — A sentence a person can act on.codestring · required — 'not_found' | 'forbidden' | 'bad_request' | 'conflict'
POST/api/v1/pagesCreate a page (as a proposal)
Requires propose. Opens a proposal containing the new page and answers its id — nothing is live until the proposal publishes.
sitequery · string — Site slug. Explicit addressing for machine callers; omitted, the host decides.surfacequery · string — Surface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.
titlestring · requiredsectionstring — Section slug, e.g. guides. Top level when absent.contentstring — MDX body. Defaults to the title as a heading.
201The proposal now carrying the page.proposalIdstringpageIdstringpathstringslugstring
400Missing title, or an address the product reserves.errorstring · required — A sentence a person can act on.codestring · required — 'not_found' | 'forbidden' | 'bad_request' | 'conflict'
409A page already lives at that address.errorstring · required — A sentence a person can act on.codestring · required — 'not_found' | 'forbidden' | 'bad_request' | 'conflict'
GET/api/v1/pages/{slug}Read one page, record and source
Requires read. slug is the reader’s address, e.g. guides/refunds. Answers the indexed record and the exact MDX at the surface’s ref.
slugpath · required · string — Path-style slug; slashes allowed.sitequery · string — Site slug. Explicit addressing for machine callers; omitted, the host decides.surfacequery · string — Surface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.
200The page.pagePagesourcestring — The complete MDX file, frontmatter included.
404No page there, or nothing you may read.errorstring · required — A sentence a person can act on.codestring · required — 'not_found' | 'forbidden' | 'bad_request' | 'conflict'
PUT/api/v1/pages/{slug}Replace a page’s source (as a proposal)
Requires propose. Send back the edited source a GET returned.
slugpath · required · stringsitequery · string — Site slug. Explicit addressing for machine callers; omitted, the host decides.surfacequery · string — Surface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.
sourcestring · required — The complete MDX file to store.
200The proposal carrying the edit.proposalIdstring · requiredpathstring
404No page there.errorstring · required — A sentence a person can act on.codestring · required — 'not_found' | 'forbidden' | 'bad_request' | 'conflict'
DELETE/api/v1/pages/{slug}Remove a page (as a proposal)
Requires propose. The removal is a proposal like any other change.
slugpath · required · stringsitequery · string — Site slug. Explicit addressing for machine callers; omitted, the host decides.surfacequery · string — Surface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.
200The proposal carrying the removal.proposalIdstring · requiredpathstring
404No page there.errorstring · required — A sentence a person can act on.codestring · required — 'not_found' | 'forbidden' | 'bad_request' | 'conflict'
GET/api/v1/proposalsList proposals on a surface
Requires read. Only this surface’s proposals — another audience’s drafts are absent, not refused. Filter with state=open|merged|conflicted|closed.
sitequery · string — Site slug. Explicit addressing for machine callers; omitted, the host decides.surfacequery · string — Surface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.statequery · string
200The proposals, most recently updated first.proposalsProposal[]
POST/api/v1/proposalsOpen a proposal
Requires propose. files maps repo-relative paths to full content, or null to remove a path.
sitequery · string — Site slug. Explicit addressing for machine callers; omitted, the host decides.surfacequery · string — Surface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.
titlestring · requiredbodystringfilesobject · required — Repo-relative path → full file content, or null to remove that path.
201Opened.proposalIdstring · requiredpathstring
400No title, no files, or a path that is not repo-relative.errorstring · required — A sentence a person can act on.codestring · required — 'not_found' | 'forbidden' | 'bad_request' | 'conflict'
GET/api/v1/proposals/{id}Read one proposal and its ledger
Requires read on the surface the proposal itself names — the caller’s site and surface parameters get no vote.
idpath · required · string
200The proposal and a consequence-ranked summary of its changes.proposalProposalledgerLedgerSummary
404No such proposal you may see.errorstring · required — A sentence a person can act on.codestring · required — 'not_found' | 'forbidden' | 'bad_request' | 'conflict'
POST/api/v1/proposals/{id}/publishPublish a proposal
Requires merge, and an accountable person: the session’s user, or the user who minted the token. Records an approval covering the whole ledger in the publisher’s name, then squash-merges — the same record every other publish leaves.
idpath · required · string
200Live.okbooleanshastring
403Nomerge, or no accountable person behind the credential.errorstring · required — A sentence a person can act on.codestring · required — 'not_found' | 'forbidden' | 'bad_request' | 'conflict'
409Already published, or it conflicts with Live.errorstring · required — A sentence a person can act on.codestring · required — 'not_found' | 'forbidden' | 'bad_request' | 'conflict'
GET/api/v1/searchSearch a surface
Requires search. Hybrid keyword + vector over this surface’s own physical index partition; mode=keyword skips vectors.
qquery · required · stringlimitquery · integer — 1–50, default 10.modequery · string — 'hybrid' (default) or 'keyword'.sitequery · string — Site slug. Explicit addressing for machine callers; omitted, the host decides.surfacequery · string — Surface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.
200Ranked results with block-level citations.429Rate limited; honour Retry-After.errorstring · required — A sentence a person can act on.codestring · required — 'not_found' | 'forbidden' | 'bad_request' | 'conflict'
GET/api/v1/eventsList a surface’s events
Requires read. Newest first; events with no surface (site-wide acts) are absent from this feed by construction. Page backwards with before=<id>.
sitequery · string — Site slug. Explicit addressing for machine callers; omitted, the host decides.surfacequery · string — Surface key (audience). A bearer token is minted for exactly one surface; naming any other grants nothing.limitquery · integer — 1–200, default 50.beforequery · string — An event id; answers events strictly older.typequery · string — e.g. 'page.published'.
200The events.eventsEvent[]nextBeforestring — Cursor for the next page, or null.