Let your LLM publish for you
Claude, ChatGPT, Cursor, any agent that can make HTTP calls: give it one Markdown file and an API key and it can create, update and publish your sites.
- Log in. Free is enough.
- Create an API key under Account → API keys.
- Hand your LLM this:
Publish my website on statichost.dev. API docs: https://statichost.dev/docs/llm/api.md API key: sk_… (paste your key) The site is in: ./my-site
What the LLM reads
This is the exact file at https://statichost.dev/docs/llm/api.md.
# statichost.dev API
Base URL: `https://statichost.dev`
statichost.dev hosts static websites. A **project** is one site: it gets a short random id
(e.g. `29ufe`) and is served at `https://<id>.statichost.dev`. You upload files into a
project by path; they are live the moment the upload returns — unless the site uses
**test & publish** (`publishMode: "manual"`): then uploads land on the site's test address
`https://<id>-test.statichost.dev` and go live with `POST /api/v1/projects/{id}/releases`.
Every site keeps **releases** (saved versions) you can roll back to.
## Authentication
Every call needs an API key, sent as a Bearer token:
```
Authorization: Bearer sk_...
```
The human creates the key at **https://statichost.dev/account → API keys** (they must be signed in) and gives it
to you. Treat it like a password. Keys act as their owner: everything the owner can do in the
dashboard, the key can do.
## Plans and limits
| Plan | Sites | Max size per site | Custom domains | Badge |
|---|---|---|---|---|
| Free ($0) | 2 | 100 MB | no | "Hosted on statichost.dev" footer |
| Pro ($19.99/mo, or $203.90/yr) | 10 | 1000 MB | yes | none |
Going over a limit returns `403 site_limit` or `413 site_too_large`. Plans are changed by
the human in the dashboard — the API cannot buy anything.
## Publishing
### Publish a folder — the whole recipe
1. `POST /api/v1/projects` with `{"name": "My site"}` → note the returned `id` and `url`.
(To update an existing site instead, find it with `GET /api/v1/projects`.)
2. For every file, `PUT /api/v1/projects/{id}/files/{path}` with the **raw file bytes** as
the body. `{path}` is the file's path relative to the site root, e.g. `index.html`,
`css/site.css`, `img/logo.png` (URL-encode each segment). Uploading the same path again
replaces the file.
3. Optional, when re-deploying: `POST /api/v1/projects/{id}/prune` with
`{"keep": [every path you just uploaded]}` to delete files that no longer exist locally.
4. The site is live at `url`. `index.html` (or `home.html`) in the root becomes the home
page automatically; otherwise set `homeFile` with `PATCH /api/v1/projects/{id}`.
Shortcut: `PUT /api/v1/projects/{id}/archive?replace=1` with a **.zip** as the body uploads
the whole folder in one request (and, with `replace=1`, removes files not in the zip).
```bash
BASE=https://statichost.dev; KEY=sk_...
ID=$(curl -s -X POST $BASE/api/v1/projects -H "Authorization: Bearer $KEY" \
-H 'content-type: application/json' -d '{"name":"My site"}' | jq -r .id)
cd site && find . -type f | sed 's|^\./||' | while read -r f; do
curl -s -X PUT --data-binary @"$f" -H "Authorization: Bearer $KEY" \
"$BASE/api/v1/projects/$ID/files/$(jq -rn --arg p "$f" '$p|@uri|gsub("%2F";"/")')"
done
```
### How sites are served
- `/` serves the home file. `/about` tries `about`, then `about.html`, then `about/index.html`.
A `404.html` in the root is used for missing pages.
- Root-absolute links (`/css/site.css`) work — each site owns its whole subdomain.
- `https://<id>-test.statichost.dev` serves the site's **test copy** (with a "TEST VERSION"
marker and `noindex`). In auto mode it equals production; with test & publish it shows
unpublished changes. Custom domains always serve production.
- Content types come from the file extension.
- Public files are cached at the CDN edge and purged automatically when anything about the site
changes; browsers revalidate HTML on every visit. Password-protected sites, pages behind
visitor login and pages with `{{{workflow}}}` tags are never cached.
- Password protection (`PATCH` with `{"password": "..."}`) puts a statichost sign-in page in
front of the whole site. `{"password": null}` removes it.
- Custom domains (Pro): add the domain with `POST /api/v1/projects/{id}/domains`, then create
`CNAME <domain> → <id>.statichost.dev` (or, for an apex domain,
`TXT _statichost.<domain> = <id>`). Free sites refuse to be served on a custom domain.
### Dynamic pages — `{{{workflow}}}` tags
Any HTML page can contain tags that are replaced, **every time the page is served**, by the
result of a [GluedEasy](https://gluedeasy.com) workflow:
```html
<p>{{{wf_abc123 name="Ada"}}}</p>
<ul>{{{latest-posts @as=html @cache=300}}}</ul>
<p>Weather in your city: {{{weather/lookup city=$query.city @fallback="unavailable"}}}</p>
```
1. The site owner connects GluedEasy once — dashboard **Account → Workflows** (*Connect with
AuthLock*: sign in and approve, no key to copy — or paste a key), or
`PUT /api/v1/integrations/gluedeasy` with a key. Workflows run with that key: only workflows the owner
can run, billed to the owner's GluedEasy credits. The key is stored encrypted and never
reaches visitors.
2. Upload pages with tags as usual. HTML files are scanned during upload; pages without tags
are served exactly as before (streamed, cacheable).
**Tag syntax:** `{{{<workflow>[/<step>] [name=value …] [@option=value …]}}}`
- `<workflow>` — a workflow id (`wf_…`) or its slug. `/<step>` picks the step to start at;
without it the workflow's REST entry step is used. The run waits for the whole workflow and
uses its final output.
- `name=value` pairs become the workflow's JSON input. `"quoted"` values are strings; bare
values are parsed as JSON when they can be (`count=3` → number, `on=true`), else strings; a
bare `name` alone is `true`.
- Visitor values (always strings): `$query.<param>` (from the page URL), `$path`, `$host`,
`$url`, `$lang` (first Accept-Language). Different values are cached separately.
- Options: `@cache=<seconds>` (default 60, `0` = run on every view, max 86400) ·
`@timeout=<seconds>` (default 10, max 30) · `@as=text|html` · `@field=<a.b.c>` (use one
field of the output) · `@fallback="<text>"` (shown when the workflow can't run).
**What is inserted:** a string output as-is; an object's `text` field (or `html` with
`@as=html`); anything else as JSON. With the default `@as=text` it is **HTML-escaped** —
workflow output can never inject markup. `@as=html` allows formatting through an allowlist
sanitizer (no scripts, styles, iframes, event handlers or `javascript:` URLs).
**When it can't run** (not connected, timeout, error, rate limit) the page is still served:
the last good result (up to 1 h old) if there is one, else the `@fallback`, else nothing —
plus an HTML comment such as `<!-- statichost: my-flow not rendered (timeout) -->`.
**Limits:** 20 tags per page, pages up to 10 MB, a site starts at most 120 workflow runs per
minute (cache hits are free). Pages with tags are sent with `Cache-Control: private, no-store`.
Tags work in `.html` files only, and results are never re-scanned for tags.
### Visitor login — who may see which page
When the server has AuthLock configured, a site can get **visitor login**: turn it on with
`POST /api/v1/projects/{id}/access` and statichost creates an AuthLock project for the site
(the owner is invited to it as admin). Visitors sign in with an **emailed code** or a
**social account** (Google, …) — no passwords — and may add an authenticator app (optional 2FA).
Access is set on the **page tree**: every directory (`""` = the root, `"docs/"`) and every
`.htm`/`.html` file. A rule is one of
- `{"mode":"anonymous"}` — open to everyone (the site password, if set, still applies)
- `{"mode":"signed_in"}` — any visitor who signed in
- `{"mode":"roles","roles":["members","staff"]}` — visitors with any of these roles
- `null` — inherit
A page without its own rule uses its directory's, up to the root (default: anonymous). Every
other file (CSS, images, …) follows its directory. Roles live in the site's AuthLock project;
a rule that names a new role creates it. Assign roles to visitors with
`PUT /api/v1/projects/{id}/access/users/{userId}/roles` (or in AuthLock).
On the site: a protected page shows a sign-in page (401) or "no access" (403); protected
responses are never cached. Pages can link to `/__statichost/auth/login?next=/path` and
`/__statichost/auth/logout`, and read the visitor with `fetch('/__statichost/auth/me')` →
`{ signedIn, email, roles }`. Workflow tags can use `$user.email`, `$user.id` and
`$user.roles`.
### Working together — site members
A site has one **owner** (whose plan, limits and AI budget it uses) and can have **members**:
| Role | Can |
|---|---|
| `viewer` | see the site, its files, members and activity |
| `editor` | + upload/change/delete files, the in-browser editor, the AI chat, purge the CDN |
| `admin` | + settings, password, custom domains, visitor login, invite/remove members |
| `owner` | + delete the site |
Invite with `POST /api/v1/projects/{id}/members` (`{"email","role"}`): statichost emails a
one-time link (valid 14 days) and returns it as `inviteUrl` too. Whoever opens it while signed
in joins with that role. `GET /api/v1/projects` lists your own sites **and** the ones shared
with you, each with your `role`; a call your role doesn't allow answers `403 forbidden`.
Shared sites never count against your own plan. Every change by anyone is in
`GET /api/v1/projects/{id}/activity`.
### Git — sync a site with a repository
Connect a site to **one** repository branch (and optionally a folder in it) on GitHub or GitFork:
- **GitHub:** `POST /api/v1/projects/{id}/git` with `{"provider":"github","repo":"owner/name"}` returns a
`deployKey`. The human adds it under the repo's *Settings → Deploy keys* (tick *Allow write access* to push),
then `POST …/git/check` reports `read`/`write`.
- **GitFork:** the human connects GitFork once (dashboard: *Connect GitFork with AuthLock*); then
`GET /api/v1/integrations/gitfork/repos` lists their repos and `POST …/git` with
`{"provider":"gitfork","repoId":"…"}` connects one — statichost gets a key for that repo by itself.
`mode`: `pull` (repo → site), `push` (site → repo; with `autoPush` every change made on statichost is
committed within seconds) or `both`. `POST …/git/pull` publishes the branch folder (it replaces all site
files); `POST …/git/push` commits the site into that folder. In `both` mode neither side is overwritten
blindly: a push answers `409 repo_has_changes` when the branch moved since the last sync, a pull answers
`409 site_has_changes` while site changes are unpushed — repeat with `{"force":true}` to overwrite.
For publish-on-push, add the `webhook` (URL + secret, HMAC-SHA256 like GitHub's) from `GET …/git` to the repo.
## Errors
Errors are JSON: `{ "error": "<code>", "message": "<human text>" }` with a matching HTTP status
(400 invalid input, 401 missing/bad key, 403 not allowed by plan, 404 not found, 413 too large,
429 slow down, 5xx our problem — retry later).
`502 storage_unavailable` (with `"retryable": true` and a `Retry-After` header) means the file store had a transient hiccup and **nothing was stored** — wait a moment and repeat the exact same request. When uploading many files, retry each failed file (3–4 attempts with backoff) instead of giving up on the whole upload.
## Endpoints
### `GET /api/v1/me`
Who am I — the key owner, plan, limits and usage.
Auth: `Authorization: Bearer sk_...`
Response:
```
{
"user": { "id": "k2j4…", "email": "you@example.com", "name": "You" },
"plan": "free",
"limits": { "sites": 2, "maxSiteBytes": 104857600, "customDomains": false, "badge": true },
"usage": { "sites": 1 }
}
```
```bash
curl -s https://statichost.dev/api/v1/me -H "Authorization: Bearer $KEY"
```
### `GET /api/v1/projects`
List your projects (sites) — your own and the ones shared with you.
Each project carries `role`: `owner` for your own sites, else your member role (`admin`, `editor`, `viewer`) on a site shared with you.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "projects": [ { "id": "29ufe", "name": "My site", "url": "https://29ufe.statichost.dev", "homeFile": "index.html",
"passwordProtected": false, "totalBytes": 48213, "fileCount": 7, "paused": null, "domains": [], "role": "owner" } ] }
```
```bash
curl -s https://statichost.dev/api/v1/projects -H "Authorization: Bearer $KEY"
```
### `POST /api/v1/projects`
Create a project.
Mints a new site with a random id. Fails with 403 `site_limit` when the plan allowance is used up.
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `name` | body | `string` | no | Display name (default: the id). Max 80 chars. |
| `description` | body | `string` | no | Free text, max 2000 chars. |
Request body:
```json
{ "name": "My site", "description": "Landing page" }
```
Response:
```
201 { "id": "29ufe", "name": "My site", "url": "https://29ufe.statichost.dev", "homeFile": null, … }
```
```bash
curl -s -X POST https://statichost.dev/api/v1/projects -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d '{"name":"My site"}'
```
Errors: `403 site_limit`
### `GET /api/v1/projects/{id}`
One project, including its file list.
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `id` | path | `string` | yes | Project id, e.g. 29ufe |
Response:
```
{ "id": "29ufe", …, "files": [ { "path": "index.html", "size": 1520, "contentType": "text/html; charset=utf-8", "dynamic": false, "updatedAt": "…" } ] }
```
```bash
curl -s https://statichost.dev/api/v1/projects/29ufe -H "Authorization: Bearer $KEY"
```
### `PATCH /api/v1/projects/{id}`
Rename, describe, pick the home file, set or clear the password.
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `name` | body | `string` | no | New display name. |
| `description` | body | `string` | no | New description. |
| `homeFile` | body | `string | null` | no | Path of an uploaded file to serve at `/`. `null` = automatic (index.html, home.html). |
| `password` | body | `string | null` | no | Protect the whole site with this password (4–200 chars). `null` removes protection. |
| `publishMode` | body | `"auto" | "manual"` | no | `auto` (default): changes are live at once and saved as releases automatically. `manual` (test & publish): changes stay on the test address until published; switching to manual freezes what is live now as the live release, switching back puts the test copy live. Needs the admin role. |
Request body:
```json
{ "name": "Portfolio", "homeFile": "start.html", "password": "hunter22" }
```
Response:
```
{ "id": "29ufe", "name": "Portfolio", "homeFile": "start.html", "passwordProtected": true, … }
```
```bash
curl -s -X PATCH https://statichost.dev/api/v1/projects/29ufe -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d '{"homeFile":"start.html"}'
```
Errors: `400 invalid_home_file`, `400 invalid_password`, `400 invalid_publish_mode`
### `DELETE /api/v1/projects/{id}`
Delete a project and all its files.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "deleted": true }
```
```bash
curl -s -X DELETE https://statichost.dev/api/v1/projects/29ufe -H "Authorization: Bearer $KEY"
```
### `PUT /api/v1/projects/{id}/files/{path}`
Upload (or replace) one file.
The request body is the raw file bytes (not multipart, not base64). `{path}` may contain slashes; URL-encode each segment. Send `Content-Length` when you can — oversize uploads are refused before any byte is stored.
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `path` | path | `string` | yes | File path relative to the site root, e.g. `css/site.css`. `..` is rejected. |
Response:
```
201 { "path": "css/site.css", "size": 5120, "contentType": "text/css; charset=utf-8", "homeFile": "index.html" }
```
```bash
curl -s -X PUT --data-binary @css/site.css https://statichost.dev/api/v1/projects/29ufe/files/css/site.css -H "Authorization: Bearer $KEY"
```
Errors: `400 invalid_path`, `413 site_too_large`
### `GET /api/v1/projects/{id}/files/{path}`
Download one file (raw bytes).
Returns the file exactly as stored. For safety it is always sent as `text/plain` (text files) or `application/octet-stream` (everything else) with `Content-Disposition: attachment`; the real content type is in the `X-File-Content-Type` header. Use this to read a file before editing it.
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `path` | path | `string` | yes | File path relative to the site root, e.g. `index.html`. |
Response:
```
200 <the file bytes> (X-File-Content-Type: text/html; charset=utf-8)
```
```bash
curl -s https://statichost.dev/api/v1/projects/29ufe/files/index.html -H "Authorization: Bearer $KEY" -o index.html
```
Errors: `404 not_found`
### `DELETE /api/v1/projects/{id}/files/{path}`
Delete one file.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "deleted": true }
```
```bash
curl -s -X DELETE https://statichost.dev/api/v1/projects/29ufe/files/old.html -H "Authorization: Bearer $KEY"
```
### `POST /api/v1/projects/{id}/prune`
Delete every file NOT in a keep-list (finish a re-deploy).
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `keep` | body | `string[]` | yes | Paths to keep. Everything else is deleted. |
Request body:
```json
{ "keep": ["index.html", "css/site.css"] }
```
Response:
```
{ "removed": ["old.html"] }
```
```bash
curl -s -X POST https://statichost.dev/api/v1/projects/29ufe/prune -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d '{"keep":["index.html"]}'
```
### `PUT /api/v1/projects/{id}/archive`
Upload a whole site as one .zip.
Body = the zip bytes. Every file in the zip is stored at its path inside the zip; a single top-level folder (e.g. `dist/`) is stripped automatically. Streams — the zip is never stored whole.
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `replace` | query | `0 | 1` | no | `1` = afterwards delete files that were not in the zip. |
| `publish` | query | `0 | 1` | no | Test & publish sites only: `1` = publish the result to production right away (needs an API key of a member who can edit). Auto-mode sites always save the upload as a release. |
| `message` | query | `string` | no | Release note for the release this upload creates (default "Uploaded N files (zip)"). |
Response:
```
{ "uploaded": 12, "removed": ["old.html"], "release": { "number": 8, "message": "Uploaded 12 files (zip)", "live": true, … }, "project": { "id": "29ufe", … } }
```
```bash
curl -s -X PUT --data-binary @site.zip "https://statichost.dev/api/v1/projects/29ufe/archive?replace=1" -H "Authorization: Bearer $KEY"
```
Errors: `400 invalid_archive`, `413 site_too_large`
### `GET /api/v1/projects/{id}/releases`
Releases: test vs production, what changed, and the saved versions.
Every site has a **test copy** (what uploads, the editor and git change; always served at `testUrl`) and **production** (`productionUrl` + custom domains). `mode` `auto`: production is the test copy and a release is saved automatically about a minute after each burst of changes (and right after a zip upload or git pull). `mode` `manual` (test & publish): production serves `live` until you publish. `pending` lists what the test copy has that production (manual) or the newest release (auto) does not. `undo` is the release Undo would bring back. Releases share files, so they cost no storage until a file is replaced; the newest 50 are kept (fewer when old ones hold more than 3× the plan's site size).
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `limit` | query | `number` | no | How many releases to list, newest first (default 100, max 500). |
Response:
```
{ "mode": "manual", "productionUrl": "https://29ufe.statichost.dev", "testUrl": "https://29ufe-test.statichost.dev",
"live": { "id": "q3…", "number": 7, "message": "New pricing page", "live": true, … },
"pending": { "added": ["blog/new.html"], "changed": ["index.html"], "removed": [], "homeFileChanged": false, "total": 2 },
"undo": { "number": 6, "id": "k9…" }, "retainedBytes": 18432, "keep": 50,
"releases": [ { "id": "q3…", "number": 7, "message": "New pricing page", "source": "publish", "fileCount": 41, "totalBytes": 2356493, "createdBy": "you@example.com", "createdAt": "…", "live": true, "lastLiveAt": "…" }, … ] }
```
```bash
curl -s https://statichost.dev/api/v1/projects/29ufe/releases -H "Authorization: Bearer $KEY"
```
### `POST /api/v1/projects/{id}/releases`
Publish the test copy to production (manual) / save a named release (auto).
manual mode: freezes the test copy as a new release and makes it production (`201`); when the test copy is identical to an existing newest release, that one goes live instead, and `changed: false` when it already was. auto mode: saves the current state as a release with your message. Editors and up. The response is the same shape as `GET …/releases`, plus `changed` and `release`.
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `message` | body | `string` | no | Release note, up to 200 chars. |
Request body:
```json
{ "message": "New pricing page" }
```
Response:
```
{ "changed": true, "release": { "number": 8, "message": "New pricing page", "live": true, … }, "mode": "manual", "live": { "number": 8, … }, "pending": { "total": 0, … }, … }
```
```bash
curl -s -X POST https://statichost.dev/api/v1/projects/29ufe/releases -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d '{"message":"New pricing page"}'
```
### `GET /api/v1/projects/{id}/releases/{release}`
One release: its files, and how the test copy differs from it.
`{release}` is the release id or its number (`7`). `testChanges` = what the test copy has that this release does not.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "id": "q3…", "number": 7, "message": "New pricing page", "live": true, "files": [ { "path": "index.html", "size": 5120, "contentType": "text/html; charset=utf-8" }, … ], "testChanges": { "added": [], "changed": ["index.html"], "removed": [], "homeFileChanged": false, "total": 1 } }
```
```bash
curl -s https://statichost.dev/api/v1/projects/29ufe/releases/7 -H "Authorization: Bearer $KEY"
```
Errors: `404 release_not_found`
### `POST /api/v1/projects/{id}/releases/{release}/rollback`
Roll production back (or forward) to a release.
manual mode: production serves that release at once — nothing is copied, the test copy is untouched. auto mode: the current state is saved first, then the site (= test copy = production) becomes that release and is saved as a new release "Back to release #N". Editors and up.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "changed": true, "release": { "number": 6, "live": true, … }, "mode": "manual", … }
```
```bash
curl -s -X POST https://statichost.dev/api/v1/projects/29ufe/releases/6/rollback -H "Authorization: Bearer $KEY"
```
Errors: `404 release_not_found`, `413 site_too_large`
### `POST /api/v1/projects/{id}/releases/{release}/restore`
Load a release into the test copy.
manual mode: the test copy becomes that release so you can keep working from it; production does not change. Unpublished test changes are saved as a release first, so nothing is lost. auto mode: same as rollback. Editors and up.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "changed": true, "release": { "number": 5, … }, "pending": { "total": 3, … }, … }
```
```bash
curl -s -X POST https://statichost.dev/api/v1/projects/29ufe/releases/5/restore -H "Authorization: Bearer $KEY"
```
Errors: `404 release_not_found`, `413 site_too_large`
### `POST /api/v1/projects/{id}/undo`
Undo the last publish or rollback.
manual mode: production goes back to what it served before the last publish/rollback (Undo again = redo). auto mode: back to the previous saved state — unsaved changes from the last minute are saved first, so Undo again brings them back. Editors and up.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "changed": true, "release": { "number": 6, "live": true, … }, "undo": { "number": 7, … }, … }
```
```bash
curl -s -X POST https://statichost.dev/api/v1/projects/29ufe/undo -H "Authorization: Bearer $KEY"
```
Errors: `409 nothing_to_undo`, `413 site_too_large`
### `GET /api/v1/projects/{id}/ai`
AI chat: is it available, and the conversation so far.
The editor has an AI chat when the server is configured for it (`enabled`). `messages` is your conversation about this site (oldest first).
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "enabled": true, "messages": [ { "role": "user", "text": "Make the header green", "at": "…" }, { "role": "assistant", "text": "Changed the header… <statichost-file path="css/site.css">…</statichost-file>", "at": "…" } ] }
```
```bash
curl -s https://statichost.dev/api/v1/projects/29ufe/ai -H "Authorization: Bearer $KEY"
```
### `POST /api/v1/projects/{id}/ai/messages`
AI chat: send a message (reply streamed as Server-Sent Events).
Runs one turn of the site's AI assistant. The AI never writes to the site: it answers with complete-file blocks `<statichost-file path="…">…</statichost-file>` (and `<statichost-delete path="…"/>`) that the editor applies as unsaved edits — to apply them yourself, `PUT` each file. Send `files` (path → current content) for files the AI has not seen yet or that changed since its last turn; it remembers earlier ones. The stream carries `session`, `status` (workspace starting), `text` (the reply as it is written), `tool`, `error` and a final `done` event whose `reply` is the whole answer. Limited per user per day (`429 rate_limited`).
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `message` | body | `string` | yes | What the user wants, 1–20000 chars. |
| `files` | body | `object` | no | path → current text content (≈3 MB max in total). |
| `current` | body | `string` | no | Path of the file the user has open. |
| `previewPage` | body | `string` | no | Path of the page shown in the preview. |
Request body:
```json
{ "message": "Add a contact section", "files": { "index.html": "<!doctype html>…" }, "current": "index.html" }
```
Response:
```
event: text
data: {"text":"Added a contact section…"}
event: done
data: {"turn":1,"status":"completed","reply":"Added a contact section… <statichost-file path="index.html">…</statichost-file>"}
```
```bash
curl -sN -X POST https://statichost.dev/api/v1/projects/29ufe/ai/messages -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d '{"message":"Make the header green"}'
```
Errors: `404 ai_disabled`, `400 invalid_message`, `413 context_too_large`, `429 rate_limited`, `402 ai_out_of_credits`, `502 ai_unavailable`
### `DELETE /api/v1/projects/{id}/ai`
AI chat: start over.
Closes the AI session (its workspace) and forgets this site's conversation.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "reset": true }
```
```bash
curl -s -X DELETE https://statichost.dev/api/v1/projects/29ufe/ai -H "Authorization: Bearer $KEY"
```
Errors: `404 ai_disabled`
### `POST /api/v1/projects/{id}/purge`
Purge the CDN cache of a site.
Site files are cached at the CDN edge and purged automatically whenever the site changes (uploads, deletes, settings, domains, plan). Use this only if you need a manual refresh. `404 cdn_disabled` when the server does not edge-cache sites.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "purged": true, "hosts": ["29ufe.statichost.dev", "www.example.com"] }
```
```bash
curl -s -X POST https://statichost.dev/api/v1/projects/29ufe/purge -H "Authorization: Bearer $KEY"
```
Errors: `404 cdn_disabled`, `429 rate_limited`, `502 cdn_unavailable`
### `GET /api/v1/projects/{id}/access`
Visitor login: state, page tree, rules and roles.
`available` = the server can do visitor login; `enabled` = on for this site. `tree` lists every directory and page, `rules` the rules set (path → rule), `roles` each role with the paths that use it. `authlock.ownerAccess` is `invited` once you were invited to the AuthLock project.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "available": true, "enabled": true,
"tree": [ { "path": "", "type": "dir" }, { "path": "index.html", "type": "page" }, { "path": "members/", "type": "dir" } ],
"rules": { "members/": { "mode": "roles", "roles": ["members"] } },
"roles": [ { "id": "…", "slug": "members", "name": "Members", "usedOn": ["members/"] } ],
"authlock": { "projectId": "…", "dashboardUrl": "https://authlock.eu/dashboard/projects/…", "ownerAccess": "invited", "origins": ["https://29ufe.statichost.dev"] } }
```
```bash
curl -s https://statichost.dev/api/v1/projects/29ufe/access -H "Authorization: Bearer $KEY"
```
Errors: `404 site_auth_unavailable`
### `POST /api/v1/projects/{id}/access`
Visitor login: turn it on (creates the site's AuthLock project).
First time: creates an AuthLock project for the site (email code + social sign-in, no passwords, optional 2FA), registers the site's origins and invites you as admin. Later: turns it back on with the same project, rules and visitors. Returns the same view as GET.
Auth: `Authorization: Bearer sk_...`
Response:
```
201 { "available": true, "enabled": true, "tree": […], "rules": {}, "roles": [], "authlock": { … } }
```
```bash
curl -s -X POST https://statichost.dev/api/v1/projects/29ufe/access -H "Authorization: Bearer $KEY"
```
Errors: `404 site_auth_unavailable`, `429 rate_limited`, `502 authlock_error`
### `DELETE /api/v1/projects/{id}/access`
Visitor login: turn it off.
Every page is open again (the site password still applies). The AuthLock project, rules and roles are kept for when you turn it back on.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "enabled": false }
```
```bash
curl -s -X DELETE https://statichost.dev/api/v1/projects/29ufe/access -H "Authorization: Bearer $KEY"
```
Errors: `409 site_auth_off`
### `PUT /api/v1/projects/{id}/access/rules`
Visitor login: set who may see which directory or page.
Replaces all rules. Keys are paths from the page tree (`""` = root, `"dir/"`, `"page.html"`); values are `{"mode":"anonymous"}`, `{"mode":"signed_in"}`, `{"mode":"roles","roles":[…]}` or `null` (inherit). Unknown roles are created in AuthLock.
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `rules` | body | `object` | yes | path → rule |
Request body:
```json
{ "rules": { "members/": { "mode": "roles", "roles": ["members"] }, "account.html": { "mode": "signed_in" } } }
```
Response:
```
{ "enabled": true, "rules": { … }, "roles": [ … ], … }
```
```bash
curl -s -X PUT https://statichost.dev/api/v1/projects/29ufe/access/rules -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d '{"rules":{"members/":{"mode":"roles","roles":["members"]}}}'
```
Errors: `400 invalid_path`, `400 invalid_rule`, `409 site_auth_off`
### `POST /api/v1/projects/{id}/access/roles`
Visitor login: create a role.
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `slug` | body | `string` | yes | Letters, digits, dashes (max 40). |
| `name` | body | `string` | no | Display name. |
Request body:
```json
{ "slug": "staff", "name": "Staff" }
```
Response:
```
201 { "id": "…", "slug": "staff", "name": "Staff" }
```
```bash
curl -s -X POST https://statichost.dev/api/v1/projects/29ufe/access/roles -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d '{"slug":"staff"}'
```
Errors: `400 invalid_role`, `409 site_auth_off`
### `GET /api/v1/projects/{id}/access/users`
Visitor login: the site's visitors and their roles.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "users": [ { "id": "usr_…", "email": "ada@example.com", "name": "Ada", "status": "active", "roles": ["members"], "lastLoginAt": "…" } ] }
```
```bash
curl -s https://statichost.dev/api/v1/projects/29ufe/access/users -H "Authorization: Bearer $KEY"
```
Errors: `409 site_auth_off`
### `PUT /api/v1/projects/{id}/access/users/{userId}/roles`
Visitor login: set a visitor's roles.
The visitor ends up with exactly these roles (unknown ones are created). Takes effect on their next page view within ~5 minutes.
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `roles` | body | `string[]` | yes | Role slugs. |
Request body:
```json
{ "roles": ["members", "staff"] }
```
Response:
```
{ "roles": ["members", "staff"] }
```
```bash
curl -s -X PUT https://statichost.dev/api/v1/projects/29ufe/access/users/usr_123/roles -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d '{"roles":["members"]}'
```
Errors: `404 not_found`, `409 site_auth_off`
### `GET /api/v1/projects/{id}/domains`
List custom domains and their DNS verification state.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "domains": [ { "domain": "www.example.com", "verified": false } ], "target": "29ufe.statichost.dev" }
```
```bash
curl -s https://statichost.dev/api/v1/projects/29ufe/domains -H "Authorization: Bearer $KEY"
```
### `POST /api/v1/projects/{id}/domains`
Connect a custom domain (Pro).
Registers the domain and checks DNS immediately. Point `CNAME <domain> → <id>.statichost.dev` (or `TXT _statichost.<domain> = <id>` for an apex). Unverified domains are re-checked automatically when traffic arrives.
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `domain` | body | `string` | yes | e.g. `www.example.com` |
Request body:
```json
{ "domain": "www.example.com" }
```
Response:
```
201 { "domain": "www.example.com", "verified": true }
```
```bash
curl -s -X POST https://statichost.dev/api/v1/projects/29ufe/domains -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d '{"domain":"www.example.com"}'
```
Errors: `403 plan_required`, `400 invalid_domain`, `409 domain_taken`
### `DELETE /api/v1/projects/{id}/domains/{domain}`
Disconnect a custom domain.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "deleted": true }
```
```bash
curl -s -X DELETE https://statichost.dev/api/v1/projects/29ufe/domains/www.example.com -H "Authorization: Bearer $KEY"
```
### `GET /api/v1/integrations/gluedeasy`
Is a GluedEasy key connected (for {{{workflow}}} tags)?.
Shows whether pages can run GluedEasy workflows and how they are connected (`via`: `"key"` = a pasted key, `"authlock"` = Connect with AuthLock). The key itself is never returned — only a hint.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "connected": true, "via": "key", "key": "sk_4f2…9a1c · you@example.com", "gluedeasyUrl": "https://gluedeasy.com" }
```
```bash
curl -s https://statichost.dev/api/v1/integrations/gluedeasy -H "Authorization: Bearer $KEY"
```
### `PUT /api/v1/integrations/gluedeasy`
Connect (or replace) your GluedEasy API key.
The key is checked against GluedEasy first, then stored encrypted. Every `{{{workflow}}}` tag in your pages runs with it — see "Dynamic pages" above. Create the key in GluedEasy under Settings → API Keys, with write access to the workflows your pages use.
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `apiKey` | body | `string` | yes | A GluedEasy `sk_…` key. |
Request body:
```json
{ "apiKey": "sk_..." }
```
Response:
```
{ "connected": true, "via": "key", "key": "sk_4f2…9a1c · you@example.com", "gluedeasyUrl": "https://gluedeasy.com" }
```
```bash
curl -s -X PUT https://statichost.dev/api/v1/integrations/gluedeasy -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d '{"apiKey":"sk_..."}'
```
Errors: `400 invalid_gluedeasy_key`, `429 rate_limited`
### `GET /api/v1/integrations/gluedeasy/workflows`
List the GluedEasy workflows your pages can use.
Live, published workflows your connected GluedEasy key can run — the names to put in `{{{…}}}` tags. The editor autocompletes from this.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "workflows": [ { "id": "wf_abc123", "slug": "opening-hours", "name": "Opening hours", "description": "Today's hours for the shop" } ] }
```
```bash
curl -s https://statichost.dev/api/v1/integrations/gluedeasy/workflows -H "Authorization: Bearer $KEY"
```
Errors: `409 gluedeasy_not_connected`, `400 gluedeasy_key_rejected`, `502 gluedeasy_unavailable`
### `GET /api/v1/integrations/gluedeasy/workflows/{ref}`
One workflow: steps and the input names its code reads.
`{ref}` is a workflow id (`wf_…`) or slug. `inputs` lists the argument names the entry step reads (`input.x`, destructured parameters, TypeScript input types) — inferred from the code, since GluedEasy has no input schema; use them as `name=value` in the tag.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "id": "wf_abc123", "slug": "opening-hours", "name": "Opening hours", "description": "…", "entryStep": "hours",
"inputs": ["day", "shop"], "steps": [ { "id": "hours", "trigger": "rest", "inputs": ["day", "shop"] } ] }
```
```bash
curl -s https://statichost.dev/api/v1/integrations/gluedeasy/workflows/opening-hours -H "Authorization: Bearer $KEY"
```
Errors: `404 not_found`, `409 gluedeasy_not_connected`, `502 gluedeasy_unavailable`
### `DELETE /api/v1/integrations/gluedeasy`
Disconnect GluedEasy.
Removes the stored key. Tags in your pages then render their `@fallback` (or nothing).
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "connected": false, "via": null, "key": null, "gluedeasyUrl": "https://gluedeasy.com" }
```
```bash
curl -s -X DELETE https://statichost.dev/api/v1/integrations/gluedeasy -H "Authorization: Bearer $KEY"
```
### `GET /api/v1/projects/{id}/members`
Who works on this site: owner, members and (for admins) pending invites.
`you` is your role. Admins and the owner also see pending/expired invites.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "you": "owner", "owner": { "email": "you@example.com", "name": "You" },
"members": [ { "id": "m4k2…", "email": "sam@example.com", "name": "Sam", "role": "editor", "status": "active",
"invitedAs": "sam@example.com", "invitedAt": "2026-09-28T09:00:00.000Z", "acceptedAt": "2026-09-28T09:05:00.000Z" } ] }
```
```bash
curl -s https://statichost.dev/api/v1/projects/29ufe/members -H "Authorization: Bearer $KEY"
```
### `POST /api/v1/projects/{id}/members`
Invite someone to the site (admin/owner).
Emails a one-time invite link (valid 14 days) and returns it as `inviteUrl` so you can share it yourself too. Inviting a pending address again refreshes its link and role.
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `email` | body | `string` | yes | Address to invite |
| `role` | body | `string` | yes | `admin`, `editor` or `viewer` |
Request body:
```json
{ "email": "sam@example.com", "role": "editor" }
```
Response:
```
201 { "id": "m4k2…", "email": "sam@example.com", "role": "editor", "status": "pending", "inviteUrl": "https://statichost.dev/invite/…" }
```
```bash
curl -s -X POST https://statichost.dev/api/v1/projects/29ufe/members -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d '{"email":"sam@example.com","role":"editor"}'
```
Errors: `400 invalid_email`, `400 invalid_role`, `403 forbidden`, `409 already_member`, `403 member_limit`
### `PATCH /api/v1/projects/{id}/members/{memberId}`
Change a member's role (admin/owner).
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `role` | body | `string` | yes | `admin`, `editor` or `viewer` |
Request body:
```json
{ "role": "admin" }
```
Response:
```
{ "id": "m4k2…", "email": "sam@example.com", "role": "admin", "status": "active" }
```
```bash
curl -s -X PATCH https://statichost.dev/api/v1/projects/29ufe/members/m4k2 -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d '{"role":"admin"}'
```
### `DELETE /api/v1/projects/{id}/members/{memberId}`
Remove a member or revoke an invite (admin/owner) — or leave the site yourself.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "deleted": true }
```
```bash
curl -s -X DELETE https://statichost.dev/api/v1/projects/29ufe/members/m4k2 -H "Authorization: Bearer $KEY"
```
### `POST /api/v1/projects/{id}/members/{memberId}/resend`
Send a pending invite again with a fresh link (admin/owner).
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "id": "m4k2…", "status": "pending", "inviteUrl": "https://statichost.dev/invite/…" }
```
```bash
curl -s -X POST https://statichost.dev/api/v1/projects/29ufe/members/m4k2/resend -H "Authorization: Bearer $KEY"
```
### `GET /api/v1/projects/{id}/activity`
Recent changes on the site, by anyone (newest first).
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `limit` | query | `integer` | no | 1–200, default 50 |
Response:
```
{ "events": [ { "at": "2026-09-28T09:10:00.000Z", "action": "file.save", "by": "sam@example.com", "detail": { "path": "index.html", "size": 5120 } } ] }
```
```bash
curl -s https://statichost.dev/api/v1/projects/29ufe/activity -H "Authorization: Bearer $KEY"
```
### `GET /api/v1/projects/{id}/git`
The site's repository connection (if any) and its sync state.
Admins/owners also get `deployKey` (GitHub) and the `webhook` URL + secret.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "connected": true, "provider": "github", "repo": "you/site", "branch": "main", "folder": "", "mode": "both", "autoPush": true,
"access": { "read": true, "write": true, "checkedAt": "…", "error": null }, "syncedCommit": "4be1c0d…", "pendingChanges": false,
"lastPullAt": "…", "lastPushAt": "…", "lastError": null, "deployKey": "ssh-ed25519 AAAA… statichost.dev site 29ufe",
"webhook": { "url": "https://statichost.dev/hooks/git/29ufe", "secret": "…", "contentType": "application/json", "events": ["push"] } }
```
```bash
curl -s https://statichost.dev/api/v1/projects/29ufe/git -H "Authorization: Bearer $KEY"
```
### `POST /api/v1/projects/{id}/git`
Connect the site to a GitHub or GitFork repository (admin/owner).
GitHub: returns a fresh `deployKey` to add to the repo (re-connecting the same repo keeps the key unless `newKey`). GitFork: needs your GitFork connection (`GET /api/v1/integrations/gitfork`); statichost mints a key for that repo (read-only for `pull`).
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `provider` | body | `string` | yes | `github` or `gitfork` |
| `repo` | body | `string` | no | GitHub: `owner/name` or its URL |
| `repoId` | body | `string` | no | GitFork: a repository id from `/integrations/gitfork/repos` |
| `repoName` | body | `string` | no | GitFork: its `fullName` too (needed for accounts with more than 500 repositories) |
| `branch` | body | `string` | no | default `main` |
| `folder` | body | `string` | no | folder in the repo that holds the site (default: the root) |
| `mode` | body | `string` | no | `pull` (default), `push` or `both` |
| `autoPush` | body | `boolean` | no | push after every change made on statichost (default true; push/both) |
| `newKey` | body | `boolean` | no | GitHub: generate a new deploy key even for the same repo |
Request body:
```json
{ "provider": "github", "repo": "you/site", "branch": "main", "mode": "both" }
```
Response:
```
201 { "connected": true, "provider": "github", "repo": "you/site", "deployKey": "ssh-ed25519 AAAA…", "access": null, … }
```
```bash
curl -s -X POST https://statichost.dev/api/v1/projects/29ufe/git -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d '{"provider":"github","repo":"you/site","mode":"both"}'
```
Errors: `400 invalid_repo`, `400 invalid_branch`, `400 invalid_mode`, `409 gitfork_not_connected`, `404 repo_not_found`
### `PATCH /api/v1/projects/{id}/git`
Change branch, folder, mode or auto-push (admin/owner).
Auth: `Authorization: Bearer sk_...`
Request body:
```json
{ "mode": "push", "autoPush": true }
```
Response:
```
{ "connected": true, "mode": "push", "autoPush": true, … }
```
```bash
curl -s -X PATCH https://statichost.dev/api/v1/projects/29ufe/git -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d '{"mode":"push"}'
```
### `DELETE /api/v1/projects/{id}/git`
Disconnect the repository (nothing is deleted on either side).
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "connected": false }
```
```bash
curl -s -X DELETE https://statichost.dev/api/v1/projects/29ufe/git -H "Authorization: Bearer $KEY"
```
### `POST /api/v1/projects/{id}/git/check`
Test the connection: can statichost pull (read) and push (write)?.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "read": true, "write": false, "empty": false, "branchExists": true, "defaultBranch": "main", "error": "The key you are authenticating with has been marked as read only." }
```
```bash
curl -s -X POST https://statichost.dev/api/v1/projects/29ufe/git/check -H "Authorization: Bearer $KEY"
```
### `POST /api/v1/projects/{id}/git/pull`
Publish the branch folder to the site (replaces all site files).
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `force` | body | `boolean` | no | `both` mode: overwrite site changes that were not pushed yet |
Response:
```
{ "commit": "4be1c0d…", "uploaded": 42, "removed": 3, "skipped": [] }
```
```bash
curl -s -X POST https://statichost.dev/api/v1/projects/29ufe/git/pull -H "Authorization: Bearer $KEY"
```
Errors: `409 site_has_changes`, `409 mode_push_only`, `404 branch_not_found`, `409 git_busy`, `413 site_too_large`
### `POST /api/v1/projects/{id}/git/push`
Commit the site into the branch folder and push.
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `message` | body | `string` | no | commit message |
| `force` | body | `boolean` | no | `both` mode: push even though the branch moved since the last sync |
Response:
```
{ "commit": "9f0a12e…", "files": 42, "changed": true }
```
```bash
curl -s -X POST https://statichost.dev/api/v1/projects/29ufe/git/push -H "Authorization: Bearer $KEY" -H 'content-type: application/json' -d '{"message":"Update copy"}'
```
Errors: `409 repo_has_changes`, `409 mode_pull_only`, `403 push_denied`, `409 git_busy`
### `GET /api/v1/integrations/gitfork`
Is your GitFork account connected (via AuthLock)?.
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "connected": true, "hint": "via AuthLock · repos:write · until 2027-10-02 · you@example.com", "connectedAt": "…", "gitforkUrl": "https://gitfork.eu" }
```
```bash
curl -s https://statichost.dev/api/v1/integrations/gitfork -H "Authorization: Bearer $KEY"
```
### `GET /api/v1/integrations/gitfork/repos`
Your GitFork repositories (to pick one for a site).
Auth: `Authorization: Bearer sk_...`
| Param | In | Type | Required | Description |
|---|---|---|---|---|
| `q` | query | `string` | no | filter by name |
Response:
```
{ "repos": [ { "id": "r8k2…", "fullName": "you/site", "defaultBranch": "main", "isPrivate": true, "canMintKey": true, "cloneUrl": "https://gitfork.eu/repos/you/site.git" } ] }
```
```bash
curl -s https://statichost.dev/api/v1/integrations/gitfork/repos -H "Authorization: Bearer $KEY"
```
Errors: `409 gitfork_not_connected`
### `DELETE /api/v1/integrations/gitfork`
Disconnect GitFork (revokes the access; sites linked through it stop syncing).
Auth: `Authorization: Bearer sk_...`
Response:
```
{ "connected": false, "sitesAffected": 1 }
```
```bash
curl -s -X DELETE https://statichost.dev/api/v1/integrations/gitfork -H "Authorization: Bearer $KEY"
```
Human-readable version of this document: https://statichost.dev/docs/api