Docsary
Markdown Ask Claude Ask ChatGPT
On this page

Pages, import and export

Use imports to publish a folder and single-page endpoints for individual edits. Export a version before destructive changes or to move your Markdown elsewhere.

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, collides with a version slug or reserved path, or normalizes to index or to a path ending in .md (index/index.md, notes.md.md)

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. Pruning is a separate, deliberate step — see Prune.

Limits

Each import accepts at most 100 files and 2,097,152 characters of Markdown. Exceeding either limit returns 413 before any writes. Send smaller batches of about six files.

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": [] }

Invalid keep entries reject the request with 400 before any writes. An explicitly empty keep list deletes every page. If a combined import rejects a file, pruning is skipped; inspect rejected before retrying.

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

Navigation updates automatically. No separate publish call is needed. Imports are incremental, may partly succeed, and can leave readers seeing mixed content while caches refresh.

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.

Folder landing pages export in flattened form: guide/index.md becomes guide.md. Both import to /guide, but export does not reproduce your original folder layout.

To restore or copy a version, post the exported files in small import batches. Export includes Markdown only; save assets separately.

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":"md contains a NUL (U+0000) character; remove it"} if the source contains a NUL byte (imports reject such a file the same way). 400 {"error":"bad path: name the page; use 'index' for the version's front page"} if the path is empty or cannot be normalized. 400 {"error":"path is longer than 1024 characters; pages this long cannot be created"} when a NEW page's path is over 1024 characters; an existing page with a longer path can still be updated. 400 {"error":"path collides with a version slug or reserved path"} if the first path segment is a version slug or a reserved word (including a version created while the write was in progress), or the path normalizes to index (…/pages/index/index; …/pages/index is the front page). 400 if the path still ends in .md after normalization (notes.md.md): that page's URL would serve another page's Markdown.

The write updates the page and navigation. Cached responses can take time to refresh.

Delete one page

DELETE /v1/sites/:site/versions/:ver/pages/<path>
{ "deleted": "guide/install" }

Removes the page and its search entry and updates navigation. 404 {"error":"page not found"} means the page did not exist. Cached or downloaded copies may remain.

Deleting a page leaves its URL returning a 404. Add a redirect if the URL had an audience.

Addressing

The page path is everything after /sites/:site/versions/:ver/pages/, including any additional pages segment in your own path.

PUT and DELETE must name the page. An empty path (…/pages/) returns 400 rather than acting on the version root page; name the root page as index. The same applies to import: a file keyed "" is listed under rejected, and index.md is the root page.