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