9d9

REST API

Create a site and push deploys directly over HTTP.

Base URL: https://api.9d9.dev. Write endpoints take Bearer auth: the key returned when you create a site, or one from Settings → New key.

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

Create a site (no auth)

POST /v1/sites
content-type: application/json

{ "name": "Riverbend Coffee", "slug": "riverbend-coffee" }

→ {
  "org_id": "org_...",
  "slug": "riverbend-coffee",
  "site_url": "https://riverbend-coffee.9d9.dev",
  "api_key": "ak_...",
  "claim_code": "FX5A-Y64D",
  "claim_url": "https://app.9d9.dev/pair?code=FX5A-Y64D",
  "expires_at": 1792606068778
}

slug is optional; taken slugs get a numeric suffix. The site is private until the owner opens claim_url and confirms with their email — its address shows "Not published yet" until then — and it's deleted at expires_at (7 days). The api_key can deploy right away and gains full scopes once claimed.

Limits per network: one site a day without an account, ten owned sites. Called with a signed-in session instead, the site belongs to that user immediately ("claimed": true, no claim fields). Over a limit, you get a 429 whose message says how to continue; organizations can request more at app.9d9.dev/limits.

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 content-type if you like; when it's missing or generic (as with plain curl --data-binary) it's inferred from the file extension.

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. Or send an empty body ({}) and the manifest is built from the files you uploaded, with 404.html picked up automatically.

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}

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" } }.