# statichost.dev API

Base URL: `https://lno1g9bz.vibecode.cloud`

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.

## Authentication

Every call needs an API key, sent as a Bearer token:

```
Authorization: Bearer sk_...
```

The human creates the key at **https://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud; 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.
- 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`.

## 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://lno1g9bz.vibecode.cloud/api/v1/me -H "Authorization: Bearer $KEY"
```

### `GET /api/v1/projects`

List your projects (sites).

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": [] } ] }
```

```bash
curl -s https://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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. |

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://lno1g9bz.vibecode.cloud/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`

### `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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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. |

Response:

```
{ "uploaded": 12, "removed": ["old.html"], "project": { "id": "29ufe", … } }
```

```bash
curl -s -X PUT --data-binary @site.zip "https://lno1g9bz.vibecode.cloud/api/v1/projects/29ufe/archive?replace=1" -H "Authorization: Bearer $KEY"
```

Errors: `400 invalid_archive`, `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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/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://lno1g9bz.vibecode.cloud/api/v1/integrations/gluedeasy -H "Authorization: Bearer $KEY"
```

Human-readable version of this document: https://lno1g9bz.vibecode.cloud/docs/api
