9d9

REST API

Push deploys, manage content, read public CMS data — directly over HTTP.

Base URL: https://api.9d9.dev. All write endpoints require Bearer auth via an API key from Settings → New key.

curl -H "Authorization: Bearer ak_xxx" https://api.9d9.dev/v1/me

Deploys

The full push-a-static-site flow the CLI uses.

Create a deploy

POST /v1/sites/{org_id}/deploys
Authorization: Bearer ak_xxx

→ { "deploy": { "id": "dep_...", "status": "uploading", ... } }

Upload files

PUT each file by path. Path is relative to the deploy root and uses forward slashes. Body is the raw file content; set the appropriate content-type.

PUT /v1/sites/{org_id}/deploys/{deploy_id}/files/index.html
content-type: text/html; charset=utf-8

<!doctype html>...

Max 25 MB per file. Reject reasons: path traversal (..), empty segments, backslashes, NUL bytes.

Commit the manifest

POST /v1/sites/{org_id}/deploys/{deploy_id}/manifest
content-type: application/json

{
  "files": [
    { "path": "index.html", "size": 1024, "hash": "deadbeef...", "mime": "text/html" },
    ...
  ],
  "not_found": "404.html"
}

The server HEADs every claimed path; missing files fail with 422.

Activate

POST /v1/sites/{org_id}/deploys/{deploy_id}/activate

→ { "ok": true, "prior_deploy_id": "dep_old..." }

Atomic. The KV pointer for every host bound to this org flips to the new deploy_id. Old deploy files stay in R2 for 7 days for rollback.

List / inspect

GET /v1/sites/{org_id}/deploys
GET /v1/sites/{org_id}/deploys/{deploy_id}

Public content (no auth)

Your static site fetches these at build or runtime. CORS *, cache-control: public, max-age=60, stale-while-revalidate=600.

GET /v1/sites/{org_id}/public/pages
GET /v1/sites/{org_id}/public/pages/{slug}
GET /v1/sites/{org_id}/public/navigation
GET /v1/sites/{org_id}/public/brand

Pages with a pub_date are your time-ordered content (blog, changelog, news). Filter and sort client-side; the list endpoint returns every published page.

Example: build-time fetch in Astro

// src/pages/blog/index.astro
const r = await fetch(
  `$https://api.9d9.dev/v1/sites/undefined/public/pages`
);
const { pages } = await r.json();
const posts = pages.filter((p) => p.pub_date).sort((a, b) => b.pub_date.localeCompare(a.pub_date));

Authed content (writes)

Same endpoints without the /public segment, with Authorization: Bearer ak_xxx:

PUT /v1/sites/{org_id}/pages/{slug}
content-type: application/json

{
  "title": "About",
  "description": "Who we are.",
  "body_md": "# About

We're a small consultancy...",
  "status": "published"
}

Errors

CodeMeaning
400Validation (zod parse failure) — body has details
401Missing / invalid Bearer or session cookie
403Auth ok but missing scope, or not a member of this org
404Resource not found (also returned for org access mismatches)
422Semantic validation (e.g. manifest lists files not in R2)
429Rate-limited

All error bodies share the shape: { "error": { "code": "string", "message": "string" } }.