On this page
Quickstart
Create a site and version, push your Markdown, then verify the result.
Before you start
Request an organization and a write-capable API key at hello@robota.us. Describe what you are documenting and roughly how large it is. Keep the key out of source control.
These examples use Bash, curl, and Node.js with built-in fetch (18 or later). On Windows, run them in a Bash environment. Continue only when whoami confirms a write-capable organization key.
The API base URL is:
https://api.docsary.com/v1
Every request authenticates with a bearer token:
Authorization: Bearer dsy_XXXX
Set it up in your shell:
export DOCSARY_KEY=dsy_XXXX
export DOCSARY_API=https://api.docsary.com
DOCSARY_API is the origin, without /v1 or a trailing slash. The script adds the API path.
1. Check the key
curl -s -H "authorization: Bearer $DOCSARY_KEY" \
"$DOCSARY_API/v1/whoami"
{
"kind": "org",
"org": { "id": "org_...", "slug": "acme", "name": "Acme", "plan": "free" },
"scopes": "write"
}
A 401 {"error":"invalid token"} means the key is wrong or revoked. A 401 {"error":"missing bearer token"} means the header did not arrive.
plan is the effective plan used for quotas. scopes identifies read-only or write access.
2. Create a site
A site groups your documentation, versions, settings, assets, and hostnames.
The slug must match ^[a-z0-9][a-z0-9._-]*$ and is unique within your org. A duplicate returns 409. Everywhere :site appears in the API you may pass either the slug or the site_... id.
Version slugs use the same character pattern, are unique within a site, and cannot use reserved routes or shadow existing page paths. See Versions. The examples use site acme and version 1.0; replace those consistently if you choose different names. Billing URLs use your organization slug, which may differ.
curl -s -X POST "$DOCSARY_API/v1/sites" \
-H "authorization: Bearer $DOCSARY_KEY" \
-H "content-type: application/json" \
-d '{"slug":"acme","name":"Acme Docs"}'
{ "id": "site_...", "slug": "acme", "subdomain": "acme.docsary.com" }
201 Created. Read subdomain from the response. If it is null, stop before uploading and read subdomain_note. If this newly created site is still empty, delete it using its returned id (DELETE /v1/sites/:site), then retry creation with a different slug. This works on Free and avoids consuming another site slot. For a populated site, contact support before deleting anything.
Save the returned address for step 6.
3. Create a version
Pages belong to a version, never directly to a site. Create one before pushing.
curl -s -X POST "$DOCSARY_API/v1/sites/acme/versions" \
-H "authorization: Bearer $DOCSARY_KEY" \
-H "content-type: application/json" \
-d '{"slug":"1.0","name":"1.0"}'
{ "id": "ver_...", "slug": "1.0" }
The first version created on a site automatically becomes that site's default version, which means it serves from unprefixed URLs. See Versions.
4. Push a markdown folder
Lay out a folder. File paths become URL paths:
mkdir -p docs/guide scripts
printf '# Acme Docs\n\nWelcome.\n' > docs/index.md
printf '# Quickstart\n\nInstall it.\n' > docs/quickstart.md
printf '# Guide\n' > docs/guide/index.md
printf '# Install\n\nFollow the installation steps.\n' > docs/guide/install.md
docs/
index.md → /
quickstart.md → /quickstart
guide/index.md → /guide
guide/install.md → /guide/install
The folder script uploads files ending in lowercase .md. It skips images, downloads and .MD files. Rename Markdown extensions to .md; upload other files separately through Assets before pushing pages that reference them. Use /assets/... links in Markdown.
With the push script
Save this script as scripts/push.mjs. It uploads Markdown in batches of up to six files:
// Push a Markdown folder: node scripts/push.mjs <dir> --site <slug> --version <slug> [--prune]
// DOCSARY_API defaults to https://api.docsary.com.
// Uploads at most six Markdown files per request.
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { homedir } from 'node:os';
const args = process.argv.slice(2);
const dir = args.find((a) => !a.startsWith('--'));
const flag = (n) => {
const i = args.indexOf(`--${n}`);
return i >= 0 ? args[i + 1] : undefined;
};
const site = flag('site');
const version = flag('version');
const prune = args.includes('--prune');
if (!dir || !site || !version) {
console.error('usage: node scripts/push.mjs <dir> --site <slug> --version <slug> [--prune]');
process.exit(1);
}
const api = process.env.DOCSARY_API ?? 'https://api.docsary.com';
const key =
process.env.DOCSARY_KEY ??
readFileSync(join(homedir(), '.secrets', 'docsary-api-key'), 'utf8').trim();
const mdFiles = [];
(function walk(d) {
for (const name of readdirSync(d)) {
const p = join(d, name);
if (statSync(p).isDirectory()) walk(p);
else if (name.endsWith('.md')) mdFiles.push(p);
}
})(dir);
if (mdFiles.length === 0) {
console.error(`no .md files under ${dir}`);
process.exit(1);
}
const rel = (p) => relative(dir, p).replaceAll('\\', '/');
const base = `${api}/v1/sites/${site}/versions/${version}`;
const headers = { authorization: `Bearer ${key}`, 'content-type': 'application/json' };
const post = async (body) => {
const res = await fetch(`${base}/import`, { method: 'POST', headers, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`import HTTP ${res.status}: ${await res.text()}`);
return res.json();
};
const CHUNK = 6;
const totals = { created: 0, updated: 0, unchanged: 0, deleted: 0, rejected: [] };
for (let i = 0; i < mdFiles.length; i += CHUNK) {
const files = {};
for (const p of mdFiles.slice(i, i + CHUNK)) files[rel(p)] = readFileSync(p, 'utf8');
const diff = await post({ files });
totals.created += diff.created.length;
totals.updated += diff.updated.length;
totals.unchanged += diff.unchanged.length;
totals.rejected.push(...diff.rejected);
process.stdout.write(`pushed ${Math.min(i + CHUNK, mdFiles.length)}/${mdFiles.length}\r`);
}
if (prune && totals.rejected.length === 0) {
const diff = await post({ prune: { keep: mdFiles.map(rel) } });
totals.deleted = diff.deleted.length;
}
if (prune && totals.rejected.length)
console.error('Prune skipped: fix rejected files before retrying.');
console.log(
`\ndone: ${totals.created} created, ${totals.updated} updated, ${totals.unchanged} unchanged` +
(prune ? `, ${totals.deleted} pruned` : '') +
(totals.rejected.length ? `, REJECTED: ${totals.rejected.join(', ')}` : '')
);
if (totals.rejected.length) process.exit(1); // rejected files are a failure, not a footnote
Then run it:
node scripts/push.mjs ./docs --site acme --version 1.0
pushed 4/4
done: 4 created, 0 updated, 0 unchanged
It reads DOCSARY_KEY from the environment, or falls back to ~/.secrets/docsary-api-key, and reads DOCSARY_API for the base URL. The script exits non-zero if any file was rejected.
Imports add and update pages. They do not remove omitted pages unless you explicitly request pruning.
Use --prune only when this folder contains the complete intended version. Paths are relative to the supplied folder; pushing a subfolder changes those paths. Before pruning, check the organization, site and version, export a backup, and compare the page list with your complete folder. See Prune. Stop other writers during this operation.
If any import fails or rejects a file, fix it before pruning. Earlier successful writes remain.
With a raw manifest POST
To publish without the script, send a JSON object mapping file paths to Markdown source:
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: Home\norder: 0\n---\n\n# Acme Docs\n\nWelcome.\n",
"quickstart.md": "---\ntitle: Quickstart\norder: 1\n---\n\n# Quickstart\n\nInstall it.\n"
}
}'
{
"created": ["", "quickstart"],
"updated": [],
"unchanged": [],
"deleted": [],
"rejected": []
}
Paths in the response are normalized page paths, not filenames — index.md became the empty path, which is the version root.
Send about six files per request. Imports add and update pages; use a separate prune call to remove omitted pages.
Identical Markdown returns unchanged. After a failed push, fix the error and re-push the complete folder; earlier successful writes remain live.
5. Verify
List what landed, with the generated navigation tree:
curl -s -H "authorization: Bearer $DOCSARY_KEY" \
"$DOCSARY_API/v1/sites/acme/versions/1.0/pages"
Read one page back as rendered HTML:
curl -s -H "authorization: Bearer $DOCSARY_KEY" \
"$DOCSARY_API/v1/sites/acme/versions/1.0/pages/quickstart?format=html"
Round-trip the whole version back to markdown:
curl -s -H "authorization: Bearer $DOCSARY_KEY" \
"$DOCSARY_API/v1/sites/acme/versions/1.0/export"
6. Open it
Open the subdomain from step 2. No separate publish call is needed, although cached responses can take time to refresh:
https://acme.docsary.com/
That address was allocated when you created the site and serves over HTTPS. Opening the site needs no additional API call.
Your own domain — requires Pro and DNS access
Use a hostname you own and whose DNS records you can edit. First upgrade to Pro, then confirm GET /v1/orgs/:org/billing reports pro. Your existing docsary.com address remains available. Attach your hostname:
curl -s -X POST "$DOCSARY_API/v1/sites/acme/domains" \
-H "authorization: Bearer $DOCSARY_KEY" \
-H "content-type: application/json" \
-d '{"hostname":"docs.acme.com"}'
The response hands you two DNS records. Publish the TXT one to prove the hostname is yours, then confirm it:
curl -s -X POST "$DOCSARY_API/v1/sites/acme/domains/docs.acme.com/verify" \
-H "authorization: Bearer $DOCSARY_KEY"
{ "hostname": "docs.acme.com", "verified": true, "certificate": { "status": "created" } }
Then publish the traffic record. Allow time for DNS and certificate issuance:
docs.acme.com CNAME ssl.docsary.com
Certificates are issued and renewed automatically. Once HTTPS works, set the hostname as primary for canonical links and search indexing. Your docsary.com address remains public and available.
What you have now
https://acme.docsary.com/and/quickstartrendered with navigation, table of contents, search, and dark mode./quickstart.mdand/index.mdserving raw markdown./llms.txt,/llms-full.txt,/sitemap.xml,/robots.txt./search?q=installover an index built during the push.
Next: Writing docs.