On this page
Pages, import and export
These are the content endpoints. Bulk import is the one you will use in anger; the single-page routes exist for scripted edits and inspection.
All endpoints require an organization API key. :site and :ver each accept a slug or an id.
Page paths
A page's path is derived from the file path you push:
| File | Page path | URL (default version) |
|---|---|---|
index.md |
`` (empty) | / |
quickstart.md |
quickstart |
/quickstart |
Guide/Install.md |
guide/install |
/guide/install |
guide/index.md |
guide |
/guide |
guide/README.md |
guide |
/guide |
Normalization lowercases the path, converts backslashes to forward slashes, strips a leading ./ or /, strips a .md extension, and drops a final index or readme segment. The result must match ^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$ and must not contain ...
Anything else is rejected, most commonly a filename containing a space.
List pages
GET /v1/sites/:site/versions/:ver/pages
curl -s -H "authorization: Bearer $DOCSARY_KEY" \
"$DOCSARY_API/v1/sites/acme/versions/1.0/pages"
{
"pages": [
{
"path": "",
"title": "Welcome",
"description": "What Acme is and how to get started.",
"nav_order": 0,
"content_hash": "3b1f...",
"updated_at": 1756300000000
},
{
"path": "guide/install",
"title": "Install",
"description": "Install the CLI.",
"nav_order": 1,
"content_hash": "9c4a...",
"updated_at": 1756300000000
}
],
"nav": [
{ "path": "", "title": "Welcome", "order": 0, "children": [] },
{
"path": "guide",
"title": "Guide",
"order": 1,
"children": [
{ "path": "guide/install", "title": "Install", "order": 1, "children": [] }
]
}
]
}
pages is a flat list ordered by path. nav is the rendered navigation tree in display order — the same structure the sidebar is built from. A node with "synthetic": true is a folder that has no landing page; it renders as a heading and is skipped by prev/next.
updated_at is a millisecond epoch timestamp. content_hash is the SHA-256 of the page's markdown source, and comparing it against a local hash tells you whether a push would change anything.
Import
POST /v1/sites/:site/versions/:ver/import
The bulk write endpoint. It takes a manifest of files, a prune instruction, or both.
| Field | Type | Notes |
|---|---|---|
files |
object | Map of file path to markdown source |
prune.keep |
array of strings | The complete set of file paths that should survive |
curl -s -X POST "$DOCSARY_API/v1/sites/acme/versions/1.0/import" \
-H "authorization: Bearer $DOCSARY_KEY" \
-H "content-type: application/json" \
-d '{
"files": {
"index.md": "---\ntitle: Welcome\norder: 0\n---\n\n# Welcome\n\nStart here.\n",
"guide/install.md": "---\ntitle: Install\norder: 1\n---\n\n# Install\n\nRun it.\n"
}
}'
{
"created": ["", "guide/install"],
"updated": [],
"unchanged": [],
"deleted": [],
"rejected": []
}
Every path is classified into exactly one bucket:
| Bucket | Meaning |
|---|---|
created |
The page did not exist |
updated |
The page existed and its content hash differed |
unchanged |
The page existed with an identical content hash — nothing was rendered or written |
deleted |
Removed by a prune in this same request |
rejected |
The path could not be normalized, or collides with a version slug or reserved path |
created, updated, unchanged, and deleted hold normalized page paths. rejected holds the raw keys you sent, so you can find the offending file. A non-empty rejected array is a failure — the push script exits non-zero on it.
Chunking
Send about six files per request — keep chunks small. Import is additive, so post as many chunks as you need, then prune at the end.
Limits
One request has its own ceiling, separate from how many pages a version may hold: at most 100 files and 2 MB of markdown per import call, refused with 413 and nothing written. That is roughly fifteen times the chunk size this page recommends, so a normal push never meets it. It exists because re-importing pages that already exist re-renders every one of them without consuming any of the version's page allowance — the request cap is what bounds that work.
A version holds as many pages as your page ceiling allows. An import that would cross that limit stops there and answers 403 with quota: "pages_per_version", the limit that applied, your plan, and a diff object holding the same five buckets as a normal response.
The pages already written in that chunk are kept and reported in that diff, so you can see exactly where the push ran out of room. Updating a page that already exists never counts against the limit — only new paths do.
Prune
Prune deletes every page in the version that is not in the keep list. Send it last, after every content chunk, with the complete file list:
curl -s -X POST "$DOCSARY_API/v1/sites/acme/versions/1.0/import" \
-H "authorization: Bearer $DOCSARY_KEY" \
-H "content-type: application/json" \
-d '{"prune":{"keep":["index.md","guide/install.md"]}}'
{ "created": [], "updated": [], "unchanged": [], "deleted": ["old-page"], "rejected": [] }
keep entries go through the same normalization as file paths, so send the same file names you sent in the manifest. An entry that fails to normalize is silently dropped from the keep set — which means the page it was meant to protect gets deleted. Build the keep list from the same file list you pushed, never by hand.
Deletions run as a single transaction: a prune applies completely or not at all. Search index entries are removed along with the pages.
files and prune may be sent in one request. Files are processed first.
After an import
The navigation tree is rebuilt and the new content is live. No separate publish call is needed.
Export
GET /v1/sites/:site/versions/:ver/export
Returns the whole version as markdown, keyed by file path:
curl -s -H "authorization: Bearer $DOCSARY_KEY" \
"$DOCSARY_API/v1/sites/acme/versions/1.0/export"
{
"files": {
"index.md": "---\ntitle: Welcome\n---\n\n# Welcome\n\nStart here.\n",
"guide/install.md": "---\ntitle: Install\n---\n\n# Install\n\nRun it.\n"
}
}
The root page is keyed index.md; every other page is its path plus .md. The markdown is byte-identical to what was pushed, front matter included.
Note that a section landing page comes back flattened: a page pushed as guide/index.md exports as guide.md, because its stored path is guide. Both file names normalize to the same page path, so the content round-trips faithfully — but writing an export straight to disk does not reproduce your original folder layout.
Export is the round-trip of import: the response's files object can be posted straight back to an import endpoint, in chunks. It is the backup to take before a destructive operation, and the way to fork one version's content into a new one.
There is no chunking parameter on export — it returns everything in one response.
Read one page
GET /v1/sites/:site/versions/:ver/pages/<path>
curl -s -H "authorization: Bearer $DOCSARY_KEY" \
"$DOCSARY_API/v1/sites/acme/versions/1.0/pages/guide/install"
{
"path": "guide/install",
"title": "Install",
"description": "Install the CLI.",
"front_matter": { "title": "Install", "order": 1 },
"md": "---\ntitle: Install\norder: 1\n---\n\n# Install\n\nRun it.\n",
"updated_at": 1756300000000
}
Add ?format=html for the stored render:
curl -s -H "authorization: Bearer $DOCSARY_KEY" \
"$DOCSARY_API/v1/sites/acme/versions/1.0/pages/guide/install?format=html"
The response gains two fields:
{
"html": "<h1 id=\"install\" tabindex=\"-1\">Install</h1>\n<p>Run it.</p>\n",
"toc": [{ "level": 2, "id": "requirements", "text": "Requirements" }]
}
html is the page body only — no navigation, header, or theme. toc is the h2–h4 outline.
format accepts only html; omit it for markdown. Any other value returns 400 {"error":"unknown format '...' — omit for markdown, or use format=html"}.
The version root page is addressed as /pages/index.md. Paths may be given with or without the .md extension.
404 {"error":"page not found"} if no page exists at that path.
Write one page
PUT /v1/sites/:site/versions/:ver/pages/<path>
curl -s -X PUT "$DOCSARY_API/v1/sites/acme/versions/1.0/pages/guide/install" \
-H "authorization: Bearer $DOCSARY_KEY" \
-H "content-type: application/json" \
-d '{"md":"---\ntitle: Install\norder: 1\n---\n\n# Install\n\nRun it.\n"}'
| Field | Type | Required |
|---|---|---|
md |
string | yes |
{ "path": "guide/install", "result": "created" }
201 when the page was created, 200 when it was updated or unchanged. result is created, updated, or unchanged — the same content-hash comparison the import path uses.
400 {"error":"md required"} if md is missing or not a string. 400 {"error":"bad path"} if the path cannot be normalized. 400 {"error":"path collides with a version slug or reserved path"} if the first path segment is a version slug or a reserved word.
The navigation tree is rebuilt and the page is live, exactly as for an import.
Delete one page
DELETE /v1/sites/:site/versions/:ver/pages/<path>
{ "deleted": "guide/install" }
Removes the page and its search index entry and rebuilds navigation, all live immediately. 404 {"error":"page not found"} if there was nothing there.
Deleting a page leaves its URL returning a 404. Add a redirect if the URL had an audience.
One addressing limitation
The single-page routes locate the path by splitting the request URL on /pages/. A page whose own path contains a pages segment — reference/pages/layout, for example — cannot be addressed through GET, PUT, or DELETE on the single-page routes. Manage those pages through import, prune, and export, which pass paths in the request body and are unaffected.