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
| 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 a site
POST /api/v1/projectswith{"name": "My site"}β note the returnedidandurl. (To update an existing site instead, find it withGET /api/v1/projects.)- 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. - Optional, when re-deploying:
POST /api/v1/projects/{id}/prunewith{"keep": [every path you just uploaded]}to delete files that no longer exist locally. - The site is live at
url.index.html(orhome.html) in the root becomes the home page automatically; otherwise sethomeFilewithPATCH /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";"/")')"
doneHow sites are served
/serves the home file./abouttriesabout, thenabout.html, thenabout/index.html. A404.htmlin the root is used for missing pages.- Root-absolute links (
/css/site.css) work β each site owns its whole subdomain. https://<id>-test.statichost.devserves the site's test copy (with a "TEST VERSION" marker andnoindex). 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 (
PATCHwith{"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 createCNAME <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>
- 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/gluedeasywith 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. - 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=valuepairs 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 barenamealone istrue.- 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 rolesnullβ 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:
| 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
Connect a site to one repository branch (and optionally a folder in it) on GitHub or GitFork:
- GitHub:
POST /api/v1/projects/{id}/gitwith{"provider":"github","repo":"owner/name"}returns adeployKey. The human adds it under the repo's *Settings β Deploy keys* (tick *Allow write access* to push), thenPOST β¦/git/checkreportsread/write. - GitFork: the human connects GitFork once (dashboard: *Connect GitFork with AuthLock*); then
GET /api/v1/integrations/gitfork/reposlists their repos andPOST β¦/gitwith{"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
/api/v1/me#Who am I β the key owner, plan, limits and usage
{
"user": {
"id": "k2j4β¦",
"email": "you@example.com",
"name": "You"
},
"plan": "free",
"limits": {
"sites": 2,
"maxSiteBytes": 104857600,
"customDomains": false,
"badge": true
},
"usage": {
"sites": 1
}
}curl -s https://statichost.dev/api/v1/me \
-H "Authorization: Bearer $KEY"/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.
{
"connected": true,
"via": "key",
"key": "sk_4f2β¦9a1c Β· you@example.com",
"gluedeasyUrl": "https://gluedeasy.com"
}curl -s https://statichost.dev/api/v1/integrations/gluedeasy \
-H "Authorization: Bearer $KEY"/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
| Name | In | Type | Description |
|---|---|---|---|
apiKey required | body | string | A GluedEasy sk_β¦ key. |
{
"apiKey": "sk_..."
}{
"connected": true,
"via": "key",
"key": "sk_4f2β¦9a1c Β· you@example.com",
"gluedeasyUrl": "https://gluedeasy.com"
}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
/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.
{
"workflows": [
{
"id": "wf_abc123",
"slug": "opening-hours",
"name": "Opening hours",
"description": "Today's hours for the shop"
}
]
}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
/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.
{
"id": "wf_abc123",
"slug": "opening-hours",
"name": "Opening hours",
"description": "β¦",
"entryStep": "hours",
"inputs": [
"day",
"shop"
],
"steps": [
{
"id": "hours",
"trigger": "rest",
"inputs": [
"day",
"shop"
]
}
]
}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
/api/v1/integrations/gluedeasy#Disconnect GluedEasy
Removes the stored key. Tags in your pages then render their @fallback (or nothing).
{
"connected": false,
"via": null,
"key": null,
"gluedeasyUrl": "https://gluedeasy.com"
}curl -s \
-X DELETE https://statichost.dev/api/v1/integrations/gluedeasy \
-H "Authorization: Bearer $KEY"/api/v1/integrations/gitfork#Is your GitFork account connected (via AuthLock)?
{
"connected": true,
"hint": "via AuthLock Β· repos:write Β· until 2027-10-02 Β· you@example.com",
"connectedAt": "β¦",
"gitforkUrl": "https://gitfork.eu"
}curl -s https://statichost.dev/api/v1/integrations/gitfork \
-H "Authorization: Bearer $KEY"/api/v1/integrations/gitfork/repos#Your GitFork repositories (to pick one for a site)
Parameters
| Name | In | Type | Description |
|---|---|---|---|
q | query | string | filter by name |
{
"repos": [
{
"id": "r8k2β¦",
"fullName": "you/site",
"defaultBranch": "main",
"isPrivate": true,
"canMintKey": true,
"cloneUrl": "https://gitfork.eu/repos/you/site.git"
}
]
}curl -s https://statichost.dev/api/v1/integrations/gitfork/repos \
-H "Authorization: Bearer $KEY"Errors: 409 gitfork_not_connected
/api/v1/integrations/gitfork#Disconnect GitFork (revokes the access; sites linked through it stop syncing)
{
"connected": false,
"sitesAffected": 1
}curl -s \
-X DELETE https://statichost.dev/api/v1/integrations/gitfork \
-H "Authorization: Bearer $KEY"Projects
/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.
{
"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 -s https://statichost.dev/api/v1/projects \
-H "Authorization: Bearer $KEY"/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
| Name | In | Type | Description |
|---|---|---|---|
name | body | string | Display name (default: the id). Max 80 chars. |
description | body | string | Free text, max 2000 chars. |
{
"name": "My site",
"description": "Landing page"
}201 {
"id": "29ufe",
"name": "My site",
"url": "https://29ufe.statichost.dev",
"homeFile": null,
β¦
}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
/api/v1/projects/{id}#One project, including its file list
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | Project id, e.g. 29ufe |
{
"id": "29ufe",
β¦,
"files": [
{
"path": "index.html",
"size": 1520,
"contentType": "text/html; charset=utf-8",
"dynamic": false,
"updatedAt": "β¦"
}
]
}curl -s https://statichost.dev/api/v1/projects/29ufe \
-H "Authorization: Bearer $KEY"/api/v1/projects/{id}#Rename, describe, pick the home file, set or clear the password
Parameters
| Name | In | Type | Description |
|---|---|---|---|
name | body | string | New display name. |
description | body | string | New description. |
homeFile | body | string | null | Path of an uploaded file to serve at /. null = automatic (index.html, home.html). |
password | body | string | null | Protect the whole site with this password (4β200 chars). null removes protection. |
publishMode | body | "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. |
{
"name": "Portfolio",
"homeFile": "start.html",
"password": "hunter22"
}{
"id": "29ufe",
"name": "Portfolio",
"homeFile": "start.html",
"passwordProtected": true,
β¦
}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
/api/v1/projects/{id}#Delete a project and all its files
{
"deleted": true
}curl -s \
-X DELETE https://statichost.dev/api/v1/projects/29ufe \
-H "Authorization: Bearer $KEY"/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
| Name | In | Type | Description |
|---|---|---|---|
limit | query | number | How many releases to list, newest first (default 100, max 500). |
{
"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 -s https://statichost.dev/api/v1/projects/29ufe/releases \
-H "Authorization: Bearer $KEY"/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
| Name | In | Type | Description |
|---|---|---|---|
message | body | string | Release note, up to 200 chars. |
{
"message": "New pricing page"
}{
"changed": true,
"release": {
"number": 8,
"message": "New pricing page",
"live": true,
β¦
},
"mode": "manual",
"live": {
"number": 8,
β¦
},
"pending": {
"total": 0,
β¦
},
β¦
}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"}'/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.
{
"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 -s https://statichost.dev/api/v1/projects/29ufe/releases/7 \
-H "Authorization: Bearer $KEY"Errors: 404 release_not_found
/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.
{
"changed": true,
"release": {
"number": 6,
"live": true,
β¦
},
"mode": "manual",
β¦
}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
/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.
{
"changed": true,
"release": {
"number": 5,
β¦
},
"pending": {
"total": 3,
β¦
},
β¦
}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
/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.
{
"changed": true,
"release": {
"number": 6,
"live": true,
β¦
},
"undo": {
"number": 7,
β¦
},
β¦
}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
/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.
{
"purged": true,
"hosts": [
"29ufe.statichost.dev",
"www.example.com"
]
}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
/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.
{
"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 -s https://statichost.dev/api/v1/projects/29ufe/members \
-H "Authorization: Bearer $KEY"/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
| Name | In | Type | Description |
|---|---|---|---|
email required | body | string | Address to invite |
role required | body | string | admin, editor or viewer |
{
"email": "sam@example.com",
"role": "editor"
}201 {
"id": "m4k2β¦",
"email": "sam@example.com",
"role": "editor",
"status": "pending",
"inviteUrl": "https://statichost.dev/invite/β¦"
}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
/api/v1/projects/{id}/members/{memberId}#Change a member's role (admin/owner)
Parameters
| Name | In | Type | Description |
|---|---|---|---|
role required | body | string | admin, editor or viewer |
{
"role": "admin"
}{
"id": "m4k2β¦",
"email": "sam@example.com",
"role": "admin",
"status": "active"
}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"}'/api/v1/projects/{id}/members/{memberId}#Remove a member or revoke an invite (admin/owner) β or leave the site yourself
{
"deleted": true
}curl -s \
-X DELETE https://statichost.dev/api/v1/projects/29ufe/members/m4k2 \
-H "Authorization: Bearer $KEY"/api/v1/projects/{id}/members/{memberId}/resend#Send a pending invite again with a fresh link (admin/owner)
{
"id": "m4k2β¦",
"status": "pending",
"inviteUrl": "https://statichost.dev/invite/β¦"
}curl -s \
-X POST https://statichost.dev/api/v1/projects/29ufe/members/m4k2/resend \
-H "Authorization: Bearer $KEY"/api/v1/projects/{id}/activity#Recent changes on the site, by anyone (newest first)
Parameters
| Name | In | Type | Description |
|---|---|---|---|
limit | query | integer | 1β200, default 50 |
{
"events": [
{
"at": "2026-09-28T09:10:00.000Z",
"action": "file.save",
"by": "sam@example.com",
"detail": {
"path": "index.html",
"size": 5120
}
}
]
}curl -s https://statichost.dev/api/v1/projects/29ufe/activity \
-H "Authorization: Bearer $KEY"/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.
{
"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 -s https://statichost.dev/api/v1/projects/29ufe/git \
-H "Authorization: Bearer $KEY"/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
| Name | In | Type | Description |
|---|---|---|---|
provider required | body | string | github or gitfork |
repo | body | string | GitHub: owner/name or its URL |
repoId | body | string | GitFork: a repository id from /integrations/gitfork/repos |
repoName | body | string | GitFork: its fullName too (needed for accounts with more than 500 repositories) |
branch | body | string | default main |
folder | body | string | folder in the repo that holds the site (default: the root) |
mode | body | string | pull (default), push or both |
autoPush | body | boolean | push after every change made on statichost (default true; push/both) |
newKey | body | boolean | GitHub: generate a new deploy key even for the same repo |
{
"provider": "github",
"repo": "you/site",
"branch": "main",
"mode": "both"
}201 {
"connected": true,
"provider": "github",
"repo": "you/site",
"deployKey": "ssh-ed25519 AAAAβ¦",
"access": null,
β¦
}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
/api/v1/projects/{id}/git#Change branch, folder, mode or auto-push (admin/owner)
{
"mode": "push",
"autoPush": true
}{
"connected": true,
"mode": "push",
"autoPush": true,
β¦
}curl -s \
-X PATCH https://statichost.dev/api/v1/projects/29ufe/git \
-H "Authorization: Bearer $KEY" \
-H 'content-type: application/json' \
-d '{"mode":"push"}'/api/v1/projects/{id}/git#Disconnect the repository (nothing is deleted on either side)
{
"connected": false
}curl -s \
-X DELETE https://statichost.dev/api/v1/projects/29ufe/git \
-H "Authorization: Bearer $KEY"/api/v1/projects/{id}/git/check#Test the connection: can statichost pull (read) and push (write)?
{
"read": true,
"write": false,
"empty": false,
"branchExists": true,
"defaultBranch": "main",
"error": "The key you are authenticating with has been marked as read only."
}curl -s \
-X POST https://statichost.dev/api/v1/projects/29ufe/git/check \
-H "Authorization: Bearer $KEY"/api/v1/projects/{id}/git/pull#Publish the branch folder to the site (replaces all site files)
Parameters
| Name | In | Type | Description |
|---|---|---|---|
force | body | boolean | both mode: overwrite site changes that were not pushed yet |
{
"commit": "4be1c0dβ¦",
"uploaded": 42,
"removed": 3,
"skipped": []
}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
/api/v1/projects/{id}/git/push#Commit the site into the branch folder and push
Parameters
| Name | In | Type | Description |
|---|---|---|---|
message | body | string | commit message |
force | body | boolean | both mode: push even though the branch moved since the last sync |
{
"commit": "9f0a12eβ¦",
"files": 42,
"changed": true
}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
/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
| Name | In | Type | Description |
|---|---|---|---|
path required | path | string | File path relative to the site root, e.g. css/site.css. .. is rejected. |
201 {
"path": "css/site.css",
"size": 5120,
"contentType": "text/css; charset=utf-8",
"homeFile": "index.html"
}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
/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
| Name | In | Type | Description |
|---|---|---|---|
path required | path | string | File path relative to the site root, e.g. index.html. |
200 <the file bytes> (X-File-Content-Type: text/html; charset=utf-8)curl -s https://statichost.dev/api/v1/projects/29ufe/files/index.html \
-H "Authorization: Bearer $KEY" -o index.htmlErrors: 404 not_found
/api/v1/projects/{id}/files/{path}#Delete one file
{
"deleted": true
}curl -s \
-X DELETE https://statichost.dev/api/v1/projects/29ufe/files/old.html \
-H "Authorization: Bearer $KEY"/api/v1/projects/{id}/prune#Delete every file NOT in a keep-list (finish a re-deploy)
Parameters
| Name | In | Type | Description |
|---|---|---|---|
keep required | body | string[] | Paths to keep. Everything else is deleted. |
{
"keep": [
"index.html",
"css/site.css"
]
}{
"removed": [
"old.html"
]
}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"]}'/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
| Name | In | Type | Description |
|---|---|---|---|
replace | query | 0 | 1 | 1 = afterwards delete files that were not in the zip. |
publish | query | 0 | 1 | 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 | Release note for the release this upload creates (default "Uploaded N files (zip)"). |
{
"uploaded": 12,
"removed": [
"old.html"
],
"release": {
"number": 8,
"message": "Uploaded 12 files (zip)",
"live": true,
β¦
},
"project": {
"id": "29ufe",
β¦
}
}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
/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.
{
"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 -s https://statichost.dev/api/v1/projects/29ufe/access \
-H "Authorization: Bearer $KEY"Errors: 404 site_auth_unavailable
/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.
201 {
"available": true,
"enabled": true,
"tree": [
β¦
],
"rules": {},
"roles": [],
"authlock": {
β¦
}
}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
/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.
{
"enabled": false
}curl -s \
-X DELETE https://statichost.dev/api/v1/projects/29ufe/access \
-H "Authorization: Bearer $KEY"Errors: 409 site_auth_off
/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
| Name | In | Type | Description |
|---|---|---|---|
rules required | body | object | path β rule |
{
"rules": {
"members/": {
"mode": "roles",
"roles": [
"members"
]
},
"account.html": {
"mode": "signed_in"
}
}
}{
"enabled": true,
"rules": {
β¦
},
"roles": [
β¦
],
β¦
}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
/api/v1/projects/{id}/access/roles#Visitor login: create a role
Parameters
| Name | In | Type | Description |
|---|---|---|---|
slug required | body | string | Letters, digits, dashes (max 40). |
name | body | string | Display name. |
{
"slug": "staff",
"name": "Staff"
}201 {
"id": "β¦",
"slug": "staff",
"name": "Staff"
}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
/api/v1/projects/{id}/access/users#Visitor login: the site's visitors and their roles
{
"users": [
{
"id": "usr_β¦",
"email": "ada@example.com",
"name": "Ada",
"status": "active",
"roles": [
"members"
],
"lastLoginAt": "β¦"
}
]
}curl -s https://statichost.dev/api/v1/projects/29ufe/access/users \
-H "Authorization: Bearer $KEY"Errors: 409 site_auth_off
/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
| Name | In | Type | Description |
|---|---|---|---|
roles required | body | string[] | Role slugs. |
{
"roles": [
"members",
"staff"
]
}{
"roles": [
"members",
"staff"
]
}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
/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).
{
"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 -s https://statichost.dev/api/v1/projects/29ufe/ai \
-H "Authorization: Bearer $KEY"/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
| Name | In | Type | Description |
|---|---|---|---|
message required | body | string | What the user wants, 1β20000 chars. |
files | body | object | path β current text content (β3 MB max in total). |
current | body | string | Path of the file the user has open. |
previewPage | body | string | Path of the page shown in the preview. |
{
"message": "Add a contact section",
"files": {
"index.html": "<!doctype html>β¦"
},
"current": "index.html"
}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 -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
/api/v1/projects/{id}/ai#AI chat: start over
Closes the AI session (its workspace) and forgets this site's conversation.
{
"reset": true
}curl -s \
-X DELETE https://statichost.dev/api/v1/projects/29ufe/ai \
-H "Authorization: Bearer $KEY"Errors: 404 ai_disabled
Custom domains
/api/v1/projects/{id}/domains#List custom domains and their DNS verification state
{
"domains": [
{
"domain": "www.example.com",
"verified": false
}
],
"target": "29ufe.statichost.dev"
}curl -s https://statichost.dev/api/v1/projects/29ufe/domains \
-H "Authorization: Bearer $KEY"/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
| Name | In | Type | Description |
|---|---|---|---|
domain required | body | string | e.g. www.example.com |
{
"domain": "www.example.com"
}201 {
"domain": "www.example.com",
"verified": true
}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
/api/v1/projects/{id}/domains/{domain}#Disconnect a custom domain
{
"deleted": true
}curl -s \
-X DELETE https://statichost.dev/api/v1/projects/29ufe/domains/www.example.com \
-H "Authorization: Bearer $KEY"