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
| Code | Meaning |
|---|---|
400 | Validation (zod parse failure) — body has details |
401 | Missing / invalid Bearer or session cookie |
403 | Auth ok but missing scope, or not a member of this org |
404 | Resource not found (also returned for org access mismatches) |
422 | Semantic validation (e.g. manifest lists files not in R2) |
429 | Rate-limited |
All error bodies share the shape:
{ "error": { "code": "string", "message": "string" } }.