statichost.dev
API reference

statichost.dev API

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.

Base URLhttps://statichost.dev
Using an LLM?
Hand it /docs/llm/api.md β€” this whole reference as one Markdown file.
On this page

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 & limits

PlanSitesMax size per siteCustom domainsBadge
Free ($0)2100 MBno"Hosted on statichost.dev" footer
Pro ($19.99/mo, or $203.90/yr)101000 MByesnone

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 a site

  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).

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 (workflows)

Any HTML page can contain tags that are replaced, every time the page is served, by the result of a GluedEasy workflow:

<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

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

A site has one owner (whose plan, limits and AI budget it uses) and can have members:

RoleCan
viewersee 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

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.

Account

GET/api/v1/me#

Who am I β€” the key owner, plan, limits and usage

Response
{
  "user": {
    "id": "k2j4…",
    "email": "you@example.com",
    "name": "You"
  },
  "plan": "free",
  "limits": {
    "sites": 2,
    "maxSiteBytes": 104857600,
    "customDomains": false,
    "badge": true
  },
  "usage": {
    "sites": 1
  }
}
curl
curl -s https://statichost.dev/api/v1/me \
  -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.

Response
{
  "connected": true,
  "via": "key",
  "key": "sk_4f2…9a1c Β· you@example.com",
  "gluedeasyUrl": "https://gluedeasy.com"
}
curl
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.

Parameters

NameInTypeDescription
apiKey requiredbodystringA GluedEasy sk_… key.
Request body
{
  "apiKey": "sk_..."
}
Response
{
  "connected": true,
  "via": "key",
  "key": "sk_4f2…9a1c Β· you@example.com",
  "gluedeasyUrl": "https://gluedeasy.com"
}
curl
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.

Response
{
  "workflows": [
    {
      "id": "wf_abc123",
      "slug": "opening-hours",
      "name": "Opening hours",
      "description": "Today's hours for the shop"
    }
  ]
}
curl
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.

Response
{
  "id": "wf_abc123",
  "slug": "opening-hours",
  "name": "Opening hours",
  "description": "…",
  "entryStep": "hours",
  "inputs": [
    "day",
    "shop"
  ],
  "steps": [
    {
      "id": "hours",
      "trigger": "rest",
      "inputs": [
        "day",
        "shop"
      ]
    }
  ]
}
curl
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).

Response
{
  "connected": false,
  "via": null,
  "key": null,
  "gluedeasyUrl": "https://gluedeasy.com"
}
curl
curl -s \
  -X DELETE https://statichost.dev/api/v1/integrations/gluedeasy \
  -H "Authorization: Bearer $KEY"
GET/api/v1/integrations/gitfork#

Is your GitFork account connected (via AuthLock)?

Response
{
  "connected": true,
  "hint": "via AuthLock Β· repos:write Β· until 2027-10-02 Β· you@example.com",
  "connectedAt": "…",
  "gitforkUrl": "https://gitfork.eu"
}
curl
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)

Parameters

NameInTypeDescription
qquerystringfilter by name
Response
{
  "repos": [
    {
      "id": "r8k2…",
      "fullName": "you/site",
      "defaultBranch": "main",
      "isPrivate": true,
      "canMintKey": true,
      "cloneUrl": "https://gitfork.eu/repos/you/site.git"
    }
  ]
}
curl
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)

Response
{
  "connected": false,
  "sitesAffected": 1
}
curl
curl -s \
  -X DELETE https://statichost.dev/api/v1/integrations/gitfork \
  -H "Authorization: Bearer $KEY"

Projects

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.

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

Parameters

NameInTypeDescription
namebodystringDisplay name (default: the id). Max 80 chars.
descriptionbodystringFree text, max 2000 chars.
Request body
{
  "name": "My site",
  "description": "Landing page"
}
Response
201 {
  "id": "29ufe",
  "name": "My site",
  "url": "https://29ufe.statichost.dev",
  "homeFile": null,
  …
}
curl
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

Parameters

NameInTypeDescription
id requiredpathstringProject id, e.g. 29ufe
Response
{
  "id": "29ufe",
  …,
  "files": [
    {
      "path": "index.html",
      "size": 1520,
      "contentType": "text/html; charset=utf-8",
      "dynamic": false,
      "updatedAt": "…"
    }
  ]
}
curl
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

Parameters

NameInTypeDescription
namebodystringNew display name.
descriptionbodystringNew description.
homeFilebodystring | nullPath of an uploaded file to serve at /. null = automatic (index.html, home.html).
passwordbodystring | nullProtect the whole site with this password (4–200 chars). null removes protection.
publishModebody"auto" | "manual"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
{
  "name": "Portfolio",
  "homeFile": "start.html",
  "password": "hunter22"
}
Response
{
  "id": "29ufe",
  "name": "Portfolio",
  "homeFile": "start.html",
  "passwordProtected": true,
  …
}
curl
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

Response
{
  "deleted": true
}
curl
curl -s \
  -X DELETE https://statichost.dev/api/v1/projects/29ufe \
  -H "Authorization: Bearer $KEY"
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).

Parameters

NameInTypeDescription
limitquerynumberHow 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": "…"
    },
    …
  ]
}
curl
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.

Parameters

NameInTypeDescription
messagebodystringRelease note, up to 200 chars.
Request body
{
  "message": "New pricing page"
}
Response
{
  "changed": true,
  "release": {
    "number": 8,
    "message": "New pricing page",
    "live": true,
    …
  },
  "mode": "manual",
  "live": {
    "number": 8,
    …
  },
  "pending": {
    "total": 0,
    …
  },
  …
}
curl
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.

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

Response
{
  "changed": true,
  "release": {
    "number": 6,
    "live": true,
    …
  },
  "mode": "manual",
  …
}
curl
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.

Response
{
  "changed": true,
  "release": {
    "number": 5,
    …
  },
  "pending": {
    "total": 3,
    …
  },
  …
}
curl
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.

Response
{
  "changed": true,
  "release": {
    "number": 6,
    "live": true,
    …
  },
  "undo": {
    "number": 7,
    …
  },
  …
}
curl
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

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.

Response
{
  "purged": true,
  "hosts": [
    "29ufe.statichost.dev",
    "www.example.com"
  ]
}
curl
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}/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.

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

Parameters

NameInTypeDescription
email requiredbodystringAddress to invite
role requiredbodystringadmin, editor or viewer
Request body
{
  "email": "sam@example.com",
  "role": "editor"
}
Response
201 {
  "id": "m4k2…",
  "email": "sam@example.com",
  "role": "editor",
  "status": "pending",
  "inviteUrl": "https://statichost.dev/invite/…"
}
curl
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)

Parameters

NameInTypeDescription
role requiredbodystringadmin, editor or viewer
Request body
{
  "role": "admin"
}
Response
{
  "id": "m4k2…",
  "email": "sam@example.com",
  "role": "admin",
  "status": "active"
}
curl
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

Response
{
  "deleted": true
}
curl
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)

Response
{
  "id": "m4k2…",
  "status": "pending",
  "inviteUrl": "https://statichost.dev/invite/…"
}
curl
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)

Parameters

NameInTypeDescription
limitqueryinteger1–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
      }
    }
  ]
}
curl
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.

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"
    ]
  }
}
curl
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).

Parameters

NameInTypeDescription
provider requiredbodystringgithub or gitfork
repobodystringGitHub: owner/name or its URL
repoIdbodystringGitFork: a repository id from /integrations/gitfork/repos
repoNamebodystringGitFork: its fullName too (needed for accounts with more than 500 repositories)
branchbodystringdefault main
folderbodystringfolder in the repo that holds the site (default: the root)
modebodystringpull (default), push or both
autoPushbodybooleanpush after every change made on statichost (default true; push/both)
newKeybodybooleanGitHub: generate a new deploy key even for the same repo
Request body
{
  "provider": "github",
  "repo": "you/site",
  "branch": "main",
  "mode": "both"
}
Response
201 {
  "connected": true,
  "provider": "github",
  "repo": "you/site",
  "deployKey": "ssh-ed25519 AAAA…",
  "access": null,
  …
}
curl
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)

Request body
{
  "mode": "push",
  "autoPush": true
}
Response
{
  "connected": true,
  "mode": "push",
  "autoPush": true,
  …
}
curl
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)

Response
{
  "connected": false
}
curl
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)?

Response
{
  "read": true,
  "write": false,
  "empty": false,
  "branchExists": true,
  "defaultBranch": "main",
  "error": "The key you are authenticating with has been marked as read only."
}
curl
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)

Parameters

NameInTypeDescription
forcebodybooleanboth mode: overwrite site changes that were not pushed yet
Response
{
  "commit": "4be1c0d…",
  "uploaded": 42,
  "removed": 3,
  "skipped": []
}
curl
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

Parameters

NameInTypeDescription
messagebodystringcommit message
forcebodybooleanboth mode: push even though the branch moved since the last sync
Response
{
  "commit": "9f0a12e…",
  "files": 42,
  "changed": true
}
curl
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

Files

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.

Parameters

NameInTypeDescription
path requiredpathstringFile 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"
}
curl
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.

Parameters

NameInTypeDescription
path requiredpathstringFile path relative to the site root, e.g. index.html.
Response
200 <the file bytes>   (X-File-Content-Type: text/html; charset=utf-8)
curl
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

Response
{
  "deleted": true
}
curl
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)

Parameters

NameInTypeDescription
keep requiredbodystring[]Paths to keep. Everything else is deleted.
Request body
{
  "keep": [
    "index.html",
    "css/site.css"
  ]
}
Response
{
  "removed": [
    "old.html"
  ]
}
curl
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.

Parameters

NameInTypeDescription
replacequery0 | 11 = afterwards delete files that were not in the zip.
publishquery0 | 1Test & 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.
messagequerystringRelease 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",
    …
  }
}
curl
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

Visitor login

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.

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

Response
201 {
  "available": true,
  "enabled": true,
  "tree": [
    …
  ],
  "rules": {},
  "roles": [],
  "authlock": {
    …
  }
}
curl
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.

Response
{
  "enabled": false
}
curl
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.

Parameters

NameInTypeDescription
rules requiredbodyobjectpath β†’ rule
Request body
{
  "rules": {
    "members/": {
      "mode": "roles",
      "roles": [
        "members"
      ]
    },
    "account.html": {
      "mode": "signed_in"
    }
  }
}
Response
{
  "enabled": true,
  "rules": {
    …
  },
  "roles": [
    …
  ],
  …
}
curl
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

Parameters

NameInTypeDescription
slug requiredbodystringLetters, digits, dashes (max 40).
namebodystringDisplay name.
Request body
{
  "slug": "staff",
  "name": "Staff"
}
Response
201 {
  "id": "…",
  "slug": "staff",
  "name": "Staff"
}
curl
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

Response
{
  "users": [
    {
      "id": "usr_…",
      "email": "ada@example.com",
      "name": "Ada",
      "status": "active",
      "roles": [
        "members"
      ],
      "lastLoginAt": "…"
    }
  ]
}
curl
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.

Parameters

NameInTypeDescription
roles requiredbodystring[]Role slugs.
Request body
{
  "roles": [
    "members",
    "staff"
  ]
}
Response
{
  "roles": [
    "members",
    "staff"
  ]
}
curl
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

AI chat

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).

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": "…"
    }
  ]
}
curl
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).

Parameters

NameInTypeDescription
message requiredbodystringWhat the user wants, 1–20000 chars.
filesbodyobjectpath β†’ current text content (β‰ˆ3 MB max in total).
currentbodystringPath of the file the user has open.
previewPagebodystringPath of the page shown in the preview.
Request body
{
  "message": "Add a contact section",
  "files": {
    "index.html": "<!doctype html>…"
  },
  "current": "index.html"
}
Response
event: textdata: {
  "text":"Added a contact section…"
}event: donedata: {
  "turn":1,
  "status":"completed",
  "reply":"Added a contact section… <statichost-file path="index.html">…</statichost-file>"
}
curl
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.

Response
{
  "reset": true
}
curl
curl -s \
  -X DELETE https://statichost.dev/api/v1/projects/29ufe/ai \
  -H "Authorization: Bearer $KEY"

Errors: 404 ai_disabled

Custom domains

GET/api/v1/projects/{id}/domains#

List custom domains and their DNS verification state

Response
{
  "domains": [
    {
      "domain": "www.example.com",
      "verified": false
    }
  ],
  "target": "29ufe.statichost.dev"
}
curl
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.

Parameters

NameInTypeDescription
domain requiredbodystringe.g. www.example.com
Request body
{
  "domain": "www.example.com"
}
Response
201 {
  "domain": "www.example.com",
  "verified": true
}
curl
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

Response
{
  "deleted": true
}
curl
curl -s \
  -X DELETE https://statichost.dev/api/v1/projects/29ufe/domains/www.example.com \
  -H "Authorization: Bearer $KEY"