xhostddocs
Console ↗
On this page

API Reference

Base URL: https://api.xhostd.com

Authentication

All endpoints require a bearer token in the Authorization header. Tokens are prefixed with xh_. Mint one at https://console.xhostd.com/tokens (the plaintext is shown once). Pass ?label=<tool-name> to prefill the label form. If a call returns 401, the token is dead — re-mint at the same URL.

MCP clients don't need a manual token: the MCP server at https://mcp.xhostd.com/mcp/ uses OAuth (Google sign-in) for both claude.ai connectors and Claude Code, and the OAuth flow mints a token behind the scenes.

An agent with no person present registers its own account with an SSH key and receives a token in the response — see Agent registration and the guide Register as an agent.

Authorization header
Authorization: Bearer xh_abc123...

Missing or invalid tokens return a 401 response. Insufficient scopes return 403.

Error envelope

Every error response uses the same shape:

Error response
{
  "error": {
    "code": "not_found",
    "message": "app not found"
  }
}

Endpoints

Signing up happens in the browser via Google sign-in at xhostd.com. An agent registers through POST /registrations; a person signs up in the browser. After signing in, create API tokens for CLI/agent use on the dashboard.

Apps

GET /apps

List all apps owned by the authenticated user.

Auth: Bearer token

Request
curl https://api.xhostd.com/apps \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
{
  "apps": [
    {
      "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
      "name": "my-site",
      "repo_url": "https://git.xhostd.com/alice/my-site.git",
      "template": "static",
      "created_at": "2026-04-22T10:30:00Z",
      "external_db_access_enabled": false,
      "external_blob_access_enabled": false,
      "port_forwarding_enabled": false,
      "port_forwarding_available": true,
      "agent_protected_actions_enabled": null,
      "agent_protected_actions_effective": false,
      "channels": [
        {
          "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
          "name": "prod",
          "hostname": "my-site-alice.xhostd.app",
          "git_ref_binding": "branch:master",
          "current_sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
          "status": "running",
          "pending_deploy": null
        }
      ],
      "owner_username": "alice",
      "role": "owner"
    }
  ]
}

role is the caller's role on the app (owner, admin, or member) and owner_username the app owner's username — they differ from the caller only on shared apps. pending_deploy is the channel's newest queued or running deploy ({deploy_id, sha, status}), or null when nothing is in flight; an old current_sha next to a non-null pending_deploy means the deploy has not finished yet, not that it failed. external_db_access_enabled / external_blob_access_enabled report the app's external Postgres and object-store access opt-ins; the toggles themselves are protected actions and answer protected_action (403) to an agent credential, until the app owner turns agent access on in the console. port_forwarding_enabled is the app toggle for public raw-TCP endpoints, and port_forwarding_available reports whether the plan of the app owner includes them. agent_protected_actions_enabled is the raw app override of the agent-access switch. Null means the app inherits the account default of the owner. The console shows three states: Inherit, On and Off. agent_protected_actions_effective is the value that applies now: the override when the owner set it, and otherwise the account default.

POST /apps

Create a new app. Provisions a git repo and a prod channel.

Auth: Bearer token — requires repo:* scope

Request body

FieldTypeDescription
namestring requiredApp name. DNS label rules: lowercase, digits, hyphens. Max 40 chars. Must not start with a reserved prefix (git, api, www, admin, preview, staging).
templatestring optionalApp template. static (default), app, or docker. app and docker both build a per-deploy image (per-plan image-size caps apply); with docker the repo-root Dockerfile is built and run. Both signal readiness one of two ways: listen on $XHOST_HTTP_PORT and answer GET / with a 2xx, or create the file named by the injected $XHOST_READY_FILE (for a channel with no HTTP surface, e.g. a queue consumer). $PORT is still injected at the same value, so existing apps keep working, but it is deprecated and will be removed — use $XHOST_HTTP_PORT in new code.
Request
curl -X POST https://api.xhostd.com/apps \
  -H "Authorization: Bearer $XHOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "my-site", "template": "static"}'
Response — 200
{
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "name": "my-site",
  "repo_url": "https://git.xhostd.com/alice/my-site.git",
  "template": "static",
  "created_at": "2026-04-22T10:30:00Z",
  "external_db_access_enabled": false,
  "external_blob_access_enabled": false,
  "port_forwarding_enabled": false,
  "port_forwarding_available": true,
  "agent_protected_actions_enabled": null,
  "agent_protected_actions_effective": false,
  "channels": [
    {
      "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "name": "prod",
      "hostname": "my-site-alice.xhostd.app",
      "git_ref_binding": "branch:master",
      "current_sha": null,
      "status": "provisioning",
      "pending_deploy": null
    }
  ],
  "owner_username": "alice",
  "role": "owner"
}

Errors

StatusCodeWhen
400bad_requestInvalid name, reserved prefix, name taken, or invalid template
403scope_deniedToken lacks repo:* scope
GET /apps/{app_id}

Get details of a single app, including all channels.

Auth: Bearer token

Request
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479 \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
{
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "name": "my-site",
  "repo_url": "https://git.xhostd.com/alice/my-site.git",
  "template": "static",
  "created_at": "2026-04-22T10:30:00Z",
  "external_db_access_enabled": false,
  "external_blob_access_enabled": false,
  "port_forwarding_enabled": false,
  "port_forwarding_available": true,
  "agent_protected_actions_enabled": null,
  "agent_protected_actions_effective": false,
  "channels": [
    {
      "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "name": "prod",
      "hostname": "my-site-alice.xhostd.app",
      "git_ref_binding": "branch:master",
      "current_sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
      "status": "running",
      "pending_deploy": null
    }
  ],
  "owner_username": "alice",
  "role": "owner"
}

Errors

StatusCodeWhen
404not_foundApp does not exist or is not owned by the caller
DELETE /apps/{app_id}

Delete an app. Stops all containers, removes the git repo, and cleans up DNS routes.

Auth: Bearer token — requires repo:* scope

Request
curl -X DELETE https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479 \
  -H "Authorization: Bearer $XHOST_TOKEN"

Returns 204 No Content on success.

Errors

StatusCodeWhen
403scope_deniedToken lacks repo:* scope
404not_foundApp does not exist or is not owned by the caller

Channels

GET /apps/{app_id}/channels

List all channels for an app.

Auth: Bearer token

Request
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
[
  {
    "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "name": "prod",
    "hostname": "my-site-alice.xhostd.app",
    "git_ref_binding": "branch:master",
    "current_sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
    "status": "running",
    "pending_deploy": null
  },
  {
    "id": "a3bb189e-8bf9-3888-9912-ace4e6543002",
    "name": "staging",
    "hostname": "staging-my-site-alice.xhostd.app",
    "git_ref_binding": "branch:staging",
    "current_sha": null,
    "status": "provisioning",
    "pending_deploy": null
  }
]
POST /apps/{app_id}/channels

Create a new channel (e.g., a preview or staging environment).

Auth: Bearer token — requires channel:* scope

Request body

FieldTypeDescription
namestring requiredChannel name. DNS label rules. Cannot be prod (auto-created).
git_ref_bindingstring requiredGit ref binding. Format: branch:<name>. One channel per branch; the legacy branch:* wildcard is deprecated and rejected at create time.
Request
curl -X POST https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels \
  -H "Authorization: Bearer $XHOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "staging", "git_ref_binding": "branch:staging"}'
Response — 200
{
  "id": "a3bb189e-8bf9-3888-9912-ace4e6543002",
  "name": "staging",
  "hostname": "staging-my-site-alice.xhostd.app",
  "git_ref_binding": "branch:staging",
  "current_sha": null,
  "status": "provisioning",
  "pending_deploy": null
}

Errors

StatusCodeWhen
400bad_requestInvalid name, reserved name (prod), or invalid git_ref_binding format
403scope_deniedToken lacks channel:* scope
404not_foundApp not found
GET /apps/{app_id}/channels/{channel_id}

Get details of a single channel.

Auth: Bearer token

Request
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7 \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
{
  "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "name": "prod",
  "hostname": "my-site-alice.xhostd.app",
  "git_ref_binding": "branch:master",
  "current_sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
  "status": "running",
  "pending_deploy": null
}

Errors

StatusCodeWhen
404not_foundApp or channel not found
DELETE /apps/{app_id}/channels/{channel_id}

Delete a channel. Stops the container and removes DNS routes. Cannot delete the prod channel.

Auth: Bearer token — requires channel:* scope

Request
curl -X DELETE https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/a3bb189e-8bf9-3888-9912-ace4e6543002 \
  -H "Authorization: Bearer $XHOST_TOKEN"

Returns 204 No Content on success.

Errors

StatusCodeWhen
400bad_requestAttempted to delete the prod channel
403scope_deniedToken lacks channel:* scope
404not_foundApp or channel not found
GET /apps/{app_id}/channels/{channel_id}/images

Live built-image inventory for one channel, newest first, plus the per-plan image-size cap. app and docker templates build images; static returns an empty list.

Auth: Bearer token — requires deploy:* scope

Request
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/images \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
{
  "images": [
    {
      "tag": "xhost/7c9e6679-7425-40de-944b-e07fc1f90ae7:a1b2c3d",
      "sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
      "size_bytes": 213909504,
      "charged_size_bytes": 41943040,
      "matched_base": "node:22-trixie-slim",
      "created": 1752969600,
      "current": true
    }
  ],
  "image_cap_bytes": 536870912
}

Response fields

FieldTypeDescription
imagesarray or nullBuilt images for this channel, newest first. null (never an error) when the channel's host agent is unreachable.
images[].charged_size_bytesinteger or nullSize counted against the plan's image cap — recognized base-image layers are excluded. null when not computed.
images[].matched_basestring or nullThe recognized base image whose layers are not charged, or null.
images[].currentbooleanWhether the image is the channel's currently deployed SHA.
image_cap_bytesintegerPer-plan cap on an image's charged size.

Errors

StatusCodeWhen
403scope_deniedToken lacks deploy:* scope
404not_foundApp or channel not found
GET /apps/{app_id}/channels/{channel_id}/code/download

Stream a tar of the channel's code at one checkpoint. With snapshot_id the route serves the commit that snapshot recorded, so the code matches the database of the same checkpoint. Without it the route serves the channel's current commit. For an arbitrary ref use GET /apps/{app_id}/tree instead. Each download is audit-logged.

Auth: Bearer token — requires repo:* scope

Query parameters

FieldTypeDescription
snapshot_idUUIDA snapshot id from GET .../postgres/snapshots. The route serves the commit that row recorded. Omit it for the channel's current commit.
Request
curl "https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/code/download?snapshot_id=1f0e2d3c-4b5a-6978-8796-a5b4c3d2e1f0" \
  -H "Authorization: Bearer $XHOST_TOKEN" \
  -o code.tar

Returns 200 OK with Content-Type: application/x-tar and a Content-Disposition attachment header. The body is the tar, streamed.

Errors

StatusCodeWhen
403scope_deniedToken lacks repo:* scope
403download_disabledThe operator keeps this download off
404not_foundApp or channel not found
404snapshot_not_foundThe id names no snapshot of this channel
404snapshot_sha_unknownThe row records no commit, or the channel never deployed. Permanent — do not repeat the call.
404snapshot_code_goneA force-push and a later git gc removed the commit. Permanent — do not repeat the call.

Deploy & logs

POST /apps/{app_id}/channels/{channel_id}/deploy

Trigger a deploy. Pulls the specified SHA or branch from git, builds the container, and brings it live.

Auth: Bearer token — requires deploy:* scope

Request body

FieldTypeDescription
shastringA 40-character hex SHA or a branch name (e.g. master, HEAD). The server resolves branch names to SHAs at deploy time.
refstringA branch name to resolve and deploy. Equivalent to passing a branch name as sha.

At least one of sha or ref must be provided. If both are given, sha wins and ref is ignored.

Request
curl -X POST https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/deploy \
  -H "Authorization: Bearer $XHOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"sha": "HEAD"}'
Response — 200
{
  "deploy_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "channel_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "status": "queued"
}

Errors

StatusCodeWhen
400bad_requestInvalid SHA format, or neither sha nor ref given
403scope_deniedToken lacks deploy:* scope
404not_foundApp or channel not found
GET /apps/{app_id}/channels/{channel_id}/logs?deploy={deploy_id}

Fetch one deploy's status and a byte window of its build log. Read the outcome from the status field, never from the log text. A queued deploy with no log yet answers 200 with an empty log and log_bytes: 0, not 404.

Auth: Bearer token — requires deploy:* scope

Query parameters

ParamTypeDescription
deployUUID requiredThe deploy ID returned by the deploy endpoint
offsetint optionalByte offset to read the log from. Without it, the reply carries the last max_bytes bytes of the log, advanced past the first newline so the window starts on a whole line. An explicit offset reads byte-exactly, with no line snapping.
max_bytesint optionalWindow size in bytes. Default 16384, max 262144.
Request
curl "https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/logs?deploy=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
{
  "deploy_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "git_sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
  "status": "success",
  "started_at": "2026-04-22T10:31:00Z",
  "finished_at": "2026-04-22T10:31:03Z",
  "log_bytes": 1207,
  "offset": 0,
  "window_bytes": 1207,
  "log": "[2026-04-22T10:31:00Z] git-sync: resolved HEAD -> a1b2c3d4\n..."
}

status is one of queued, running, success, or failed; poll while it is queued or running. finished_at is null until the deploy finishes. log_bytes is the total size of the log, offset is where the returned window starts, and window_bytes is the byte length of the returned window before utf-8 decoding. The next page starts at offset + window_bytes; when that sum equals log_bytes, the reply reaches the end of the log.

Errors

StatusCodeWhen
403scope_deniedToken lacks deploy:* scope
404not_foundDeploy not found under this app and channel
POST /apps/{app_id}/channels/{channel_id}/runtime/log

Read a channel container's stdout/stderr, live or archived. The runtime counterpart of GET .../logs, which covers the build window only. The log is materialized as app.log inside a throwaway, network-less container, and command runs there as a shell pipeline. Omit command and no container starts — the reply carries the status facts alone, which is the "tell me how it died" query. When a redeploy replaces a container, the old container's log is archived, so an older container_index reads why the previous version crashed.

Auth: Bearer token — requires deploy:* scope

Request body

FieldTypeDescription
commandstring optionalA shell pipeline run against app.log, at most 4096 characters — for example "tail -n 200 app.log" or "grep -i error app.log | tail -20". Debian userland (grep, sed, awk, python3, node; no jq or rg). 30-second limit; partial output is still returned with timed_out: true. Without it the reply carries the status facts alone.
container_indexint optionalWhich container generation to read. Omitted, the newest. available_indices in every reply lists what exists, oldest first.
Request
curl -X POST https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/runtime/log \
  -H "Authorization: Bearer $XHOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"command": "tail -n 50 app.log"}'
Response — 200
{
  "container_index": 2,
  "container_id": "9c1f8a2b04e7",
  "container_name": "xhost-f47ac10b-7c9e6679-00000002",
  "running": true,
  "source": "live",
  "status": "running",
  "exit_code": null,
  "oom_killed": false,
  "restart_count": 0,
  "started_at": "2026-08-29T09:12:41.183220794Z",
  "finished_at": null,
  "available_indices": [0, 1, 2],
  "log_bytes": 48213,
  "output": "2026-08-29T09:14:02.077410122Z listening on :8080\n...",
  "command_exit_code": 0,
  "timed_out": false,
  "truncated": false
}

exit_code, status, oom_killed, and restart_count describe the logged container — your app. command_exit_code is the query command's own exit code, and is null when no command ran or the sandbox hit the timeout. output is the command's combined stdout and stderr, capped at 256 KiB with truncated: true past the cap. Only stdout/stderr is captured — an app that writes its logs to a file inside the container has nothing here.

Errors

StatusCodeWhen
403scope_deniedToken lacks deploy:* scope
404not_foundApp or channel not found, or nothing readable for this channel yet
503service_unavailableThe host cannot answer right now — retry later

Environment & secrets

POST /apps/{app_id}/env

Set (upsert) an environment variable or secret. App-level by default; pass channel_id for a per-channel override. Takes effect on the next deploy.

Auth: Bearer token — requires deploy:* scope

Request body

FieldTypeDescription
keystring requiredEnv var name. Uppercase letters, digits, underscores. Must match ^[A-Z_][A-Z0-9_]*$. Reserved (rejected, system-injected per channel): XHOST_USER, XHOST_SHA, XHOST_HTTP_PORT, PORT, XHOST_FORWARD_PORT, XHOST_READY_FILE, DATABASE_URL, DATABASE_URL_READONLY, DATABASE_HOST, DATABASE_PASSWORD, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION.
valuestring requiredThe value. Encrypted at rest. Capped at 16 KiB of UTF-8 — well above a single-line credential and well below the size that would stop the container starting.
kindstringenv (plain variable) or secret. Omitted, an existing key keeps its current kind and a new key defaults to env — only an explicit kind flips a key. List responses return metadata only for secrets (value is null); read a secret's value with GET /apps/{app_id}/env/{key}/value, where each reveal is audit-logged.
channel_idUUIDOmit for an app-level default; set to a channel id for a per-channel override. At deploy time the channel override wins over the app default, and system-injected keys win over both.
Request
curl -X POST https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/env \
  -H "Authorization: Bearer $XHOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"key": "STRIPE_SECRET_KEY", "value": "sk_live_...", "kind": "secret"}'

Returns 204 No Content on success.

Errors

StatusCodeWhen
400bad_requestInvalid key format or reserved key
403scope_deniedToken lacks deploy:* scope
404not_foundApp not found, or channel_id not a channel of this app
DELETE /apps/{app_id}/env/{key}

Delete an environment variable or secret. Takes effect on the next deploy.

Auth: Bearer token — requires deploy:* scope

Query parameters

ParamTypeDescription
channel_idUUIDWith it, deletes only that channel's override; without it, deletes the app-level default.
Request
curl -X DELETE https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/env/STRIPE_SECRET_KEY \
  -H "Authorization: Bearer $XHOST_TOKEN"

Returns 204 No Content on success.

Errors

StatusCodeWhen
403scope_deniedToken lacks deploy:* scope
404not_foundApp not found
GET /apps/{app_id}/env

List an app's environment variables and secrets. Plain values are returned in cleartext; the list returns metadata only for secrets — read a secret's value with GET /apps/{app_id}/env/{key}/value.

Auth: Bearer token — requires deploy:* scope

Query parameters

ParamTypeDescription
channel_idUUIDWithout it, raw rows (app-level and per-channel). With it, the resolved view for that channel: app defaults merged with the channel's overrides, the override winning.
Request
curl "https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/env?channel_id=7c9e6679-7425-40de-944b-e07fc1f90ae7" \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
{
  "env": [
    {
      "key": "MY_VAR",
      "kind": "env",
      "scope": "app",
      "channel_id": null,
      "updated_at": "2026-01-16T10:30:00Z",
      "value": "my-value"
    },
    {
      "key": "STRIPE_SECRET_KEY",
      "kind": "secret",
      "scope": "channel",
      "channel_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "updated_at": "2026-01-16T10:31:00Z",
      "value": null
    }
  ]
}

scope is app (app-level default) or channel (channel override). value is the cleartext for kind: "env" rows and always null for secrets in the list — the read path for a secret's value is GET /apps/{app_id}/env/{key}/value, where each reveal is audit-logged.

Errors

StatusCodeWhen
403scope_deniedToken lacks deploy:* scope
404not_foundApp not found, or channel_id not a channel of this app
GET /apps/{app_id}/env/{key}/value

Reveal a single environment value in cleartext — the only read path for kind: "secret". Every call records an env.reveal audit event in the app journal before the value is returned.

Auth: Bearer token — requires deploy:* scope

Query parameters

ParamTypeDescription
channel_idUUIDWith it, resolved semantics: that channel's override wins, falling back to the app-level default. Without it, the app-level row only.
Request
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/env/STRIPE_SECRET_KEY/value \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
{
  "key": "STRIPE_SECRET_KEY",
  "kind": "secret",
  "scope": "app",
  "value": "sk_live_..."
}

Errors

StatusCodeWhen
403scope_deniedToken lacks deploy:* scope
404not_foundApp, channel, or env key not found
GET /apps/{app_id}/channels/{channel_id}/deploys/{deploy_id}/env

Return the env snapshot recorded when a deploy started — what the app actually ran with, independent of edits made since.

Auth: Bearer token — requires deploy:* scope

Response — 200
{
  "deploy_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "env": [
    {"key": "MY_VAR", "kind": "env", "source": "app", "value": "my-value"},
    {"key": "STRIPE_SECRET_KEY", "kind": "secret", "source": "channel", "value": null}
  ],
  "system_keys": ["DATABASE_URL", "XHOST_SHA", "XHOST_USER"]
}

source is the scope the value resolved from (app or channel). Secret values are masked (null); system-injected keys are listed by name only — their values are credentials and are not stored in the snapshot.

Errors

StatusCodeWhen
403scope_deniedToken lacks deploy:* scope
404not_foundDeploy not found, or it predates env snapshots

Sign in with Google

xhostd runs the Google sign-in dance and sets a signed identity cookie (__Host-xhost_id, an RS256 JWT) on your channel's hostname. Nothing is gated at the edge — your app verifies the cookie against xhostd's published public keys and decides access itself. This is zero-config: there is no per-channel API to call. See Sign in with Google for per-stack verify snippets.

Verify, don't trust headers. Read identity from the verified __Host-xhost_id cookie (claims: iss, aud=hostname, sub, email, name, iat, exp). Pin RS256, select the key by kid, and check iss/aud/exp. xhostd strips inbound X-Xhost-* headers, so identity headers cannot be spoofed. __Host-xhost_id is a reserved cookie name.

These endpoints are served by the OAuth gateway at auth.xhostd.com and on every channel host under /xhost-auth/* — no Bearer token, they're public:

EndpointPurpose
GET /xhost-auth/login?return_to=<path>Start Google sign-in; returns to return_to with the cookie set.
GET /xhost-auth/logout?return_to=/Clear the identity cookie.
GET /xhost-auth/whoamiJSON {logged_in, email, name, sub} or {logged_in:false, login_url}. For SPA/JS-only apps (cookie is HttpOnly).
GET https://auth.xhostd.com/xhost-auth/jwksRS256 public keys (JWK set) for app-side verification.
GET https://auth.xhostd.com/.well-known/openid-configurationOIDC discovery document.

Custom domains (per channel)

Attach up to 5 custom domains to a channel. Verification is a TXT token plus a routing record (CNAME for subdomains, A for the apex). HTTPS is automatic via on-demand TLS — certificates mint at the first request after verification. See Custom domains for the full story.

Domains are globally unique. A 409 domain_taken means another xhostd channel already owns the hostname; re-attaching the same domain on the same channel is idempotent (the token is preserved).
POST /apps/{app_id}/channels/{channel_id}/domains

Attach a custom domain. Returns the DNS records you must create at your registrar before calling /verify.

Auth: Bearer token

Request body

FieldTypeDescription
domainstring requiredThe hostname to attach. IDNA-encoded form is stored canonically; case-folded; trailing dot stripped.
Response — 201
{
  "domain": "app.customer.com",
  "status": "pending",
  "reason": null,
  "dns_records": {
    "txt_host": "_xhost.app.customer.com",
    "txt_value": "xhost-verify-abcdef0123456789abcdef0123456789",
    "cname_target": "prod-blog-alice.xhostd.app",
    "a_values": ["198.51.100.7"]
  },
  "created_at": "2026-06-12T00:00:00Z",
  "verified_at": null
}

Errors

StatusCodeWhen
400invalid domain: <reason>Validation failure. Reasons: is_ip_address, invalid_idna, too_long, invalid_label, platform_domain_forbidden, needs_dot.
400domain_limit_reachedChannel already has 5 attached domains.
404not_foundApp or channel not found.
409domain_takenAnother channel owns this hostname.
GET /apps/{app_id}/channels/{channel_id}/domains

List every custom domain attached to a channel. Same per-item shape as POST.

Auth: Bearer token

Response — 200
{"domains": [{...}, {...}]}
POST /apps/{app_id}/channels/{channel_id}/domains/{domain}/verify

Re-check the DNS records for an attached domain. Idempotent and retryable — DNS propagation usually takes a few minutes.

Auth: Bearer token

On a passing check, status flips from pending to verified and the public route + on-demand TLS go live. A verified domain only downgrades on the four definitive reasons (txt_nxdomain, txt_token_mismatch, dns_not_pointing, domain_nxdomain); transient resolver failures (txt_lookup_failed, dns_lookup_failed, platform_ip_unknown) are recorded as reason but never downgrade.

Response — 200
{
  "domain": "app.customer.com",
  "status": "verified",
  "reason": null,
  "dns_records": { ... },
  "created_at": "2026-06-12T00:00:00Z",
  "verified_at": "2026-06-12T00:05:00Z"
}
DELETE /apps/{app_id}/channels/{channel_id}/domains/{domain}

Detach a custom domain. The route is removed immediately; certificate renewals stop.

Auth: Bearer token

Response — 200
{"ok": true}

Postgres

Every non-static channel automatically gets its own Postgres database, with a dedicated role and a DATABASE_URL injected into the container at start; tables live in that database's stock public schema. A static-template channel gets no database, no role, and no DATABASE_URL. Schema migrations are user-managed — run alembic upgrade head, prisma migrate deploy, or equivalent at deploy time, where DATABASE_URL is set: from launch.sh for the app template, or the start command (CMD) for docker. Don't put them in install.sh — it runs at build time with no database access.

GET /apps/{app_id}/channels/{channel_id}/postgres

Inspect the channel's Postgres database: name, role, status, live connection count, storage usage.

Auth: Bearer token

Request
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/postgres \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
{
  "db_name": "ch_7c9e6679742540de944be07fc1f90ae7",
  "role_name": "r_7c9e6679742540de944be07fc1f90ae7",
  "status": "ready",
  "last_error": null,
  "connection_count": 2,
  "connection_limit": 20,
  "password_set": true,
  "storage_bytes": 81920
}

Response fields

FieldTypeDescription
db_namestringThe name of the channel's own Postgres database, and the typed confirmation for destructive operations
role_namestringThe Postgres role used in DATABASE_URL
statusstringOne of provisioning, ready, failed
last_errorstring or nullProvisioner error message if status is failed
connection_countintegerLive connections currently held by this role
connection_limitintegerConfigured CONNECTION LIMIT for the write role
password_setbooleanWhether the role has a stored password
storage_bytesintegerBytes this channel's data occupies on disk

Errors

StatusCodeWhen
404not_foundApp, channel, or schema row not found
POST /apps/{app_id}/channels/{channel_id}/postgres/reset

Empty the channel's database. The role and password are preserved, so the same DATABASE_URL keeps working.

Destructive. All data and migration history in the channel's database is permanently destroyed. This is intended for deliberate, human-approved use; the required confirm_db_name field acts as a typed confirmation. It applies to any channel including prod (no prod gate, unlike restore), and each reset is audit-logged.

Auth: Bearer token

Request body

FieldTypeDescription
confirm_db_namestring requiredMust match the channel's current db_name exactly. Acts as a typed confirmation.
Request
curl -X POST https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/postgres/reset \
  -H "Authorization: Bearer $XHOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"confirm_db_name": "ch_7c9e6679742540de944be07fc1f90ae7"}'

Returns 204 No Content on success.

Errors

StatusCodeWhen
400invalid_confirmationconfirm_db_name does not match the channel's db_name
404not_foundApp, channel, or schema row not found
409conflictChannel postgres is not in ready state
503postgres_unavailablePostgres admin pool is not configured (degraded mode)
GET /apps/{app_id}/channels/{channel_id}/postgres/dump

Stream a pg_dump of the channel's database. That channel's data and nothing else. Useful for backups and migrating data between channels. Each dump is audit-logged.

Auth: Bearer token

Request
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/postgres/dump \
  -H "Authorization: Bearer $XHOST_TOKEN" \
  -o channel.sql

Returns 200 OK with Content-Type: application/sql and a Content-Disposition attachment header. The body is the raw pg_dump output, streamed.

Errors

StatusCodeWhen
404not_foundApp, channel, or schema row not found
409conflictChannel postgres is not in ready state
503postgres_unavailablePostgres admin pool is not configured (degraded mode)
GET /apps/{app_id}/channels/{channel_id}/postgres/snapshots

List the channel's Postgres snapshots of every kind, newest first. xhostd takes one automatically before each deploy (kind pre_deploy) and one per day (kind nightly).

Auth: Bearer token

Response — 200
[
  {
    "snapshot_id": "1f0e2d3c-4b5a-6978-8796-a5b4c3d2e1f0",
    "deploy_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
    "created_at": "2026-07-20T09:12:00Z",
    "git_sha": "3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a"
  }
]

git_sha names the commit this snapshot belongs to. On a pre_deploy snapshot it is the commit that ran before the deploy, because the snapshot holds the state that commit left. It is null when xhostd recorded no commit for the row. A null is a state, not a fault: that row simply has no code leg. Pass the id of this row as snapshot_id to GET .../code/download to fetch the matching code.

Errors

StatusCodeWhen
404not_foundApp, channel, or schema row not found
POST /apps/{app_id}/channels/{channel_id}/postgres/snapshots/{snapshot_id}/download-token

Mint a short-lived snapshots:read token for one owned snapshot. The calling token must carry snapshots:read itself, because a mint never grants more than the caller already holds. The plaintext is returned once and expires in one hour. A general credential already reaches the download URL, because snapshots:read is default-granted; mint this when you want a single-purpose token instead.

Auth: Bearer token carrying snapshots:read

Response — 200
{
  "token": "xh_snap123...",
  "download_url": "/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/postgres/snapshots/1f0e2d3c-4b5a-6978-8796-a5b4c3d2e1f0/download",
  "expires_at": "2026-07-24T10:05:00Z"
}

Errors

StatusCodeWhen
403download_disabledThe operator keeps this download off
404not_foundApp or channel not found
404snapshot_not_foundThe id names no snapshot of this channel
GET /apps/{app_id}/channels/{channel_id}/postgres/snapshots/{snapshot_id}/download

Stream one snapshot's archive. The archive is a complete custom-format pg_dump, so pg_restore reads it directly. To put the snapshot back into the channel's own database, use POST .../postgres/restore instead. Each download is audit-logged.

Auth: Bearer token — requires snapshots:read scope (mint via download-token)

Request
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/postgres/snapshots/1f0e2d3c-4b5a-6978-8796-a5b4c3d2e1f0/download \
  -H "Authorization: Bearer $DOWNLOAD_TOKEN" \
  -o snapshot.sqlc

Returns 200 OK with Content-Type: application/octet-stream and a Content-Disposition attachment header. The body is the snapshot archive, streamed.

Errors

StatusCodeWhen
403download_disabledThe operator keeps this download off
403scope_deniedToken lacks snapshots:read scope
404not_foundApp or channel not found
404snapshot_not_foundThe id names no snapshot of this channel
POST /apps/{app_id}/channels/{channel_id}/postgres/restore

Restore the channel's database from a snapshot of either kind, replacing current data. Restoring the prod channel is blocked unless the app has the env var XHOST_ALLOW_PROD_RESTORE=1. Each restore is audit-logged.

Auth: Bearer token

Request body

FieldTypeDescription
confirm_db_namestring requiredMust match the channel's current db_name exactly. Acts as a typed confirmation.
snapshot_idUUID requiredA snapshot id from the snapshots list.
Request
curl -X POST https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/postgres/restore \
  -H "Authorization: Bearer $XHOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"confirm_db_name": "ch_7c9e6679742540de944be07fc1f90ae7", "snapshot_id": "1f0e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"}'

Returns 200 OK with the channel's Postgres status (same shape as GET .../postgres).

Errors

StatusCodeWhen
400invalid_confirmationconfirm_db_name does not match the channel's db_name
403prod_restore_blockedprod channel without XHOST_ALLOW_PROD_RESTORE=1
404snapshot_not_foundSnapshot does not exist (or its file is missing)
409channel_busyChannel is busy, or the account is undergoing scheduled maintenance
503postgres_unavailablePostgres admin pool is not configured (degraded mode)
GET /me/postgres/storage

Report total Postgres storage and database count for the authenticated user, across all channels.

Auth: Bearer token

Request
curl https://api.xhostd.com/me/postgres/storage \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
{
  "database_size_bytes": 1572864,
  "database_count": 5
}

Response fields

FieldTypeDescription
database_size_bytesintegerBytes the account's data occupies on disk, summed over every database that holds it
database_countintegerNumber of databases that total was measured across

Errors

StatusCodeWhen
503postgres_unavailablePostgres admin pool is not configured (degraded mode), or a database could not be measured — a partial total is never reported

Object storage (blob)

Every channel gets its own S3-compatible bucket, with credentials (S3_ENDPOINT, S3_BUCKET, keys) injected into the container at start. The routes below inspect the store, mint credentials for outside-the-container use, and restore a snapshot of either kind.

GET /apps/{app_id}/channels/{channel_id}/blob

Inspect the channel's object store: provisioning status, usage, and the virtual S3 endpoint/bucket the app sees.

Auth: Bearer token

Response — 200
{
  "status": "ready",
  "last_error": null,
  "usage_bytes": 10485760,
  "external_enabled": false,
  "virtual_bucket": "my-site-alice-xhostd-com",
  "virtual_endpoint": "https://my-site-alice.s3.xhostd.app",
  "region": "xhost"
}

Errors

StatusCodeWhen
404not_foundApp, channel, or blob store not found
503blob_unavailableObject storage is unavailable
POST /apps/{app_id}/channels/{channel_id}/blob/credentials

Return the channel's S3-compatible credentials — the only payload carrying the secret key. Each call is audit-logged.

Auth: Bearer token — requires blob:* scope

Response — 200
{
  "access_key_id": "AKxhost...",
  "secret_access_key": "…",
  "endpoint": "https://my-site-alice.s3.xhostd.app",
  "region": "xhost",
  "bucket": "my-site-alice-xhostd-com"
}

Errors

StatusCodeWhen
403scope_deniedToken lacks blob:* scope
404not_foundApp, channel, or blob store not found
409blob_not_readyBlob store is not in ready state
503blob_unavailableObject storage is unavailable
GET /apps/{app_id}/channels/{channel_id}/blob/snapshots

List the channel's object-store snapshots of every kind, newest first. xhostd marks one before each deploy (kind pre_deploy) and one per day (kind nightly).

Auth: Bearer token

Response — 200
[
  {
    "snapshot_id": "2a1b0c9d-8e7f-6a5b-4c3d-2e1f0a9b8c7d",
    "deploy_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
    "snapshot_ts": "2026-07-20T09:11:58Z",
    "created_at": "2026-07-20T09:12:00Z",
    "pg_snapshot_id": "1f0e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"
  }
]

pg_snapshot_id names the Postgres snapshot of the same checkpoint, so the two legs hold one instant. It is null when the two passes did not align. The two prunes run on their own, so the id can also name a Postgres row that no longer exists. Both cases mean the same thing: the two legs are not aligned. Neither is a fault.

Errors

StatusCodeWhen
404not_foundApp, channel, or blob store not found
POST /apps/{app_id}/channels/{channel_id}/blob/snapshots/{snapshot_id}/download-token

Mint a short-lived blobs:read token for one owned checkpoint. The calling token must carry blobs:read itself, because a mint never grants more than the caller already holds. The snapshot_id is the Postgres checkpoint id; the route resolves the aligned blob leg from it. The plaintext is returned once and expires in one hour. A general credential already reaches the download URL, because blobs:read is default-granted; mint this when you want a single-purpose token instead.

Auth: Bearer token carrying blobs:read

Response — 200
{
  "token": "xh_blob123...",
  "download_url": "/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/blob/snapshots/1f0e2d3c-4b5a-6978-8796-a5b4c3d2e1f0/download",
  "expires_at": "2026-07-24T10:05:00Z"
}

Errors

StatusCodeWhen
403download_disabledThe operator keeps this download off
404not_foundApp or channel not found
404snapshot_not_foundThe id names no checkpoint of this channel
409no_aligned_blob_snapshotThe checkpoint has no aligned object-store snapshot
GET /apps/{app_id}/channels/{channel_id}/blob/snapshots/{snapshot_id}/download

Stream the channel's objects as they were at the checkpoint's instant, as one tar. The snapshot_id is the Postgres checkpoint id; xhostd resolves the aligned blob leg and reads the object versions live at that instant, so the tar holds what POST .../blob/restore would put back. Each download is audit-logged.

Auth: Bearer token — requires blobs:read scope (mint via download-token)

Request
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/blob/snapshots/2a1b0c9d-8e7f-6a5b-4c3d-2e1f0a9b8c7d/download \
  -H "Authorization: Bearer $DOWNLOAD_TOKEN" \
  -o files.tar.gz

Returns 200 OK with Content-Type: application/gzip and a Content-Disposition attachment header. The body is the tar, streamed.

Errors

StatusCodeWhen
403download_disabledThe operator keeps this download off
403scope_deniedToken lacks blobs:read scope
404not_foundApp or channel not found
404snapshot_not_foundThe id names no checkpoint of this channel
409no_aligned_blob_snapshotThe checkpoint has no aligned object-store snapshot
409conflictThe snapshot is older than the age limit, so the object versions it names can already be gone — choose a newer snapshot. Or the object set is above the file-count or byte limit — read the files with your own S3 client instead.
503blob_unavailableObject storage is unavailable
POST /apps/{app_id}/channels/{channel_id}/blob/restore

Restore the channel's object-store prefix to a checkpoint's point-in-time, replacing current objects. The snapshot_id is the checkpoint id from GET .../postgres/snapshots whose aligned_blob flag is true; the route resolves the aligned blob leg from it. Restoring the prod channel is blocked unless the app has the env var XHOST_ALLOW_PROD_RESTORE=1. Each restore is audit-logged.

Auth: Bearer token

Request body

FieldTypeDescription
confirm_channel_namestring requiredMust match the channel's name exactly. Acts as a typed confirmation.
snapshot_idUUID requiredThe checkpoint id from the snapshots list whose aligned_blob flag is true.

Returns 200 OK with the channel's blob status (same shape as GET .../blob).

Errors

StatusCodeWhen
400invalid_confirmationconfirm_channel_name does not match the channel's name
403prod_restore_blockedprod channel without XHOST_ALLOW_PROD_RESTORE=1
404snapshot_not_foundCheckpoint does not exist on this channel, or the resolved snapshot is too old to restore
409channel_busyChannel is busy
409no_aligned_blob_snapshotThe checkpoint has no aligned blob leg
503blob_unavailableObject storage is unavailable
GET /me/blob/storage

Report total object-store usage and provisioned store count for the authenticated user, across all channels.

Auth: Bearer token

Response — 200
{
  "blob_usage_bytes": 10485760,
  "store_count": 3
}

GitHub repo mirroring

Connect an existing GitHub repo as an app's source of truth. xhostd fetches GitHub into the app's internal repo via a per-app read-only deploy key; deploys and read tools keep operating on the internal repo unchanged. The private deploy key is never returned by any route.

POST /apps/{app_id}/github/connect

Generate a read-only Ed25519 deploy key and record the remote. Does not fetch — add the returned public key as a deploy key on GitHub, then call /github/sync.

Auth: Bearer token — requires the owner role on the app

Request body

FieldTypeDescription
remote_urlstring requiredSSH GitHub URL, e.g. git@github.com:alice/my-site.git.
Response — 200
{
  "public_key": "ssh-ed25519 AAAA... xhost-deploy-f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "remote_url": "git@github.com:alice/my-site.git"
}

Errors

StatusCodeWhen
400bad_requestremote_url is not an SSH GitHub URL
404not_foundApp not found or caller lacks the owner role
409conflictA GitHub repo is already connected
GET /apps/{app_id}/github

Connection status and last sync outcome. connected: false (other fields null) when no repo is connected.

Auth: Bearer token

Response — 200
{
  "connected": true,
  "remote_url": "git@github.com:alice/my-site.git",
  "public_key": "ssh-ed25519 AAAA...",
  "connected_at": "2026-07-01T08:00:00Z",
  "last_synced_at": "2026-07-20T09:11:00Z",
  "last_sync_status": "ok",
  "last_sync_error": null,
  "last_sync_refs": {"refs/heads/master": "a1b2c3d4..."}
}

Errors

StatusCodeWhen
404not_foundApp not found or caller is not a member
POST /apps/{app_id}/github/sync

Fetch the connected remote into the app's internal repo. Returns the updated status; inspect last_sync_status for the outcome.

Auth: Bearer token — requires the admin role on the app

Errors

StatusCodeWhen
404not_foundApp not found, no repo connected, or caller lacks the admin role
DELETE /apps/{app_id}/github

Disconnect the GitHub repo and discard the deploy key. The app's internal repo and deploy history are unchanged.

Auth: Bearer token — requires the owner role on the app

Returns 204 No Content on success.

Errors

StatusCodeWhen
404not_foundApp not found, no repo connected, or caller lacks the owner role

Agent registration

Two routes take no bearer. An agent with no person at a browser proves that it holds an ssh-ed25519 key by signing a short timestamped message with ssh-keygen -Y sign; the signature is the whole proof. The full recipe, with the commands that build each body, is Register as an agent.

POST /registrations

Open a starter account for the holder of an Ed25519 key. The response holds a 30-day default-scope token, and the key is registered on the account with api_login, so it renews the token through POST /auth/ssh-key and pushes over SSH with no further call.

Auth: none. The signed message is the proof.

Request body

FieldTypeDescription
public_keystring requiredOne OpenSSH public-key line; ssh-ed25519 only
timestampinteger requiredUnix seconds the client signed; within 300 seconds of the platform clock
signaturestring requiredThe armored SSHSIG block over the message, under the namespace xhostd-register
usernamestring optionalA requested name, ^agent[a-z0-9]{5,35}$; it must equal the second line of the signed message. Absent means the platform allocates agent plus 8 random characters
labelstring optionalThe key's label on the account, 64 characters or fewer; default registration

Any other field answers 422.

The signed message (three lines, each with a trailing newline)
xhostd-register
<username or empty>
<timestamp>
Request
curl -sS https://api.xhostd.com/registrations \
  -H "Content-Type: application/json" \
  --data @body.json -o response.json -w 'HTTP %{http_code}\n'
Response — 200
{
  "user_id": "3f1c2a7e-9b4d-4c1e-8a6f-2d5b7c9e0f13",
  "username": "agent7k2m9x4q",
  "plan": "starter",
  "token": "xh_...",
  "token_expires_at": "2026-10-07T12:00:00Z",
  "ssh_key_id": "b8e4d2c6-1a3f-4e5b-9c7d-0f2a4b6c8e1d",
  "fingerprint_sha256": "SHA256:x4bR5nQm7pZs2tVw9yAcE1gHjK3lMoPqR6sTuVwXyZ0",
  "git_ssh_host": "git.xhostd.com",
  "limits": { "tier": "starter", "max_channels": 1, "blob_storage_bytes": 134217728, "...": "..." },
  "next": { "verify_email": "POST /me/email-verifications", "renew_token": "POST /auth/ssh-key" }
}
Store the token in a file with mode 0600. The response is its one copy. limits is the starter row of GET /plans.

Errors

StatusCodeWhen
400bad_requestA key line the parser refuses, a key of another type, a timestamp outside the 300-second window, a signature that does not verify, a username outside the rule, or a label over 64 characters
409conflictThe requested username is taken; the key is registered already (sign in through POST /auth/ssh-key, never make a second key); or three random names in a row were taken (post again)
422noneAn unknown or missing field; FastAPI's validation body
429too_many_requestsThe fleet-wide or the per-source daily budget is spent; retry the next day
503service_unavailableAgent registration is closed by the operator
POST /auth/ssh-key

Mint a fresh 30-day default-scope token for a key registered with api_login: the registration key, or a key POST /ssh-keys stored with api_login true. An account holds at most 20 tokens from this route.

Auth: none. The signed message is the proof.

Request body

FieldTypeDescription
public_keystring requiredOne OpenSSH public-key line; ssh-ed25519 only
timestampinteger requiredUnix seconds the client signed; within 300 seconds of the platform clock
signaturestring requiredThe armored SSHSIG block over the message, under the namespace xhostd-login
The signed message (the fingerprint is the SHA256: value ssh-keygen -lf prints)
xhostd-login
<fingerprint_sha256>
<timestamp>
Request
curl -sS https://api.xhostd.com/auth/ssh-key \
  -H "Content-Type: application/json" \
  --data @body.json -o response.json -w 'HTTP %{http_code}\n'
Response — 200
{
  "token": "xh_...",
  "token_expires_at": "2026-11-06T12:00:00Z",
  "user_id": "3f1c2a7e-9b4d-4c1e-8a6f-2d5b7c9e0f13",
  "username": "agent7k2m9x4q"
}

Errors

StatusCodeWhen
400bad_requestA key line the parser refuses, a key of another type, a timestamp outside the 300-second window, or a signature that does not verify
404not_foundNo api_login key matches this fingerprint: an unknown key, or one without api_login. Register the key through POST /registrations
422noneAn unknown or missing field; FastAPI's validation body

Tokens & credentials

POST /tokens

Create a new API token for the authenticated user. The new token carries the full default scope set, so the token you call with must carry it too — a mint never grants more than the caller already holds. To mint from a narrower token, use POST /credentials, which grants the scopes you hold or fewer.

Auth: Bearer token carrying all nine default scopes

Request body

FieldTypeDescription
labelstring optionalHuman-readable label (e.g. "ci", "laptop")
expires_ininteger optionalLifetime in seconds. Omit for a token that does not expire, which is this route's default. No ceiling applies here, because any finite value is already shorter than no expiry.
Request
curl -X POST https://api.xhostd.com/tokens \
  -H "Authorization: Bearer $XHOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"label": "ci"}'
Response — 200
{
  "token_id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
  "plaintext": "xh_newtoken123...",
  "scopes": ["repo:*", "deploy:*", "channel:*", "db:*", "blob:*", "stats:read", "exports:read", "snapshots:read", "blobs:read"],
  "label": "ci",
  "created_at": "2026-04-22T11:00:00Z",
  "expires_at": null
}

Errors

StatusCodeWhen
403scope_deniedThe calling token does not carry all nine default scopes. Use POST /credentials instead.
Save the plaintext token. It is only returned once at creation time. Subsequent API calls reference the token by its token_id.
DELETE /tokens/{token_id}

Revoke a token. The token is immediately invalidated.

Auth: Bearer token

Request
curl -X DELETE https://api.xhostd.com/tokens/c56a4180-65aa-42ec-a945-5fd21dec0538 \
  -H "Authorization: Bearer $XHOST_TOKEN"

Returns 204 No Content on success.

Errors

StatusCodeWhen
404not_foundToken not found or not owned by the caller
POST /credentials

Mint a unified credential. The returned token serves as your git password, Postgres password, object-storage and download credential, and platform API bearer. It lives 30 days unless you pass a shorter expires_in. A mint never grants more than the caller already holds, so this route grants the default scopes your calling token carries, or the subset you name within them.

Auth: Bearer token

Request body (optional)

FieldTypeDescription
scopesarray of stringsWhen supplied, must be a non-empty subset of the default scopes your calling token holds (repo:*, deploy:*, channel:*, db:*, blob:*, stats:read, exports:read, snapshots:read, blobs:read) — mints a least-privilege credential. Omit for every default scope your calling token holds, which is all nine for a general credential.
expires_inintegerLifetime in seconds, at most 2592000 (30 days). Omit for 30 days. Composes with scopes, so one request can mint a credential that is both least-privilege and short-lived.
Request
curl -X POST https://api.xhostd.com/credentials \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
{
  "token": "xh_abc123...",
  "username": "alice",
  "expires_at": "2026-05-22T11:00:00Z",
  "scopes": ["repo:*", "deploy:*", "channel:*", "db:*", "blob:*", "stats:read", "exports:read", "snapshots:read", "blobs:read"]
}

To push over git, put the token in the password field of the remote URL — https://<username>:<token>@git.xhostd.com/<username>/<app>.git (any username works; the password is what is checked). git.xhostd.com also accepts the token via Authorization: Bearer. Re-mint after the 30-day expiry.

Errors

StatusCodeWhen
400bad_requestEmpty scopes list, unknown scope, or a scope the calling token does not hold. The message names the scopes you can ask for.

SSH keys

A registered key is a second credential class beside the token. A key belongs to the account, not to one app; the platform stores the public half only, and no route returns a key. SSH is the first git transport wherever a shell is available, and an SSH push needs no token.

POST /ssh-keys

Register one OpenSSH public-key line on the caller's account. The platform notifies the user about the new key, with its label and fingerprint. A key reaches every repository on the account, so the calling token must carry repo:*; api_login asks for all nine default scopes, because that is what the key goes on to mint.

Auth: Bearer token carrying repo:*

Request body

FieldTypeDescription
public_keystring requiredOne OpenSSH public-key line: ssh-ed25519, ssh-rsa, or ecdsa-sha2-nistp256/384/521
labelstring optionalYour own name for the key; max 64 characters
api_loginboolean optionalDefault false. true lets the key mint a token through POST /auth/ssh-key; a registration key has it set already

Any other field answers 422.

Request
curl -X POST https://api.xhostd.com/ssh-keys \
  -H "Authorization: Bearer $XHOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"public_key\": \"$(cat ~/.ssh/xhost_ed25519.pub)\", \"label\": \"claude-code\"}"
Response — 200
{
  "id": "b8e4d2c6-1a3f-4e5b-9c7d-0f2a4b6c8e1d",
  "label": "claude-code",
  "algo": "ssh-ed25519",
  "fingerprint": "SHA256:x4bR5nQm7pZs2tVw9yAcE1gHjK3lMoPqR6sTuVwXyZ0",
  "created_at": "2026-08-17T10:30:00Z",
  "last_used_at": null,
  "api_login": false
}

Then push: git remote add xhost-ssh "git@git.xhostd.com:<username>/<app>.git" and GIT_SSH_COMMAND="ssh -i ~/.ssh/xhost_ed25519 -o IdentitiesOnly=yes" git push xhost-ssh HEAD:master.

Errors

StatusCodeWhen
400bad_requestThe line is no valid OpenSSH public key, or the label is longer than 64 characters
403scope_deniedThe calling token does not carry repo:*, or asks for api_login without all nine default scopes
409conflictThe platform holds that fingerprint already. A fingerprint is unique platform-wide, so for the key at ~/.ssh/xhost_ed25519 this means an earlier session registered it; push with it, and never mint a second keypair to clear the conflict
422noneAn unknown or missing field; FastAPI's validation body
GET /ssh-keys

List the caller's SSH keys, newest first. Metadata only.

Auth: Bearer token

Request
curl https://api.xhostd.com/ssh-keys \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
{
  "ssh_keys": [
    {
      "id": "b8e4d2c6-1a3f-4e5b-9c7d-0f2a4b6c8e1d",
      "label": "claude-code",
      "algo": "ssh-ed25519",
      "fingerprint": "SHA256:x4bR5nQm7pZs2tVw9yAcE1gHjK3lMoPqR6sTuVwXyZ0",
      "created_at": "2026-08-17T10:30:00Z",
      "last_used_at": null,
      "api_login": false
    }
  ]
}

last_used_at is null while the key served no git command yet. api_login is true for a key that can sign in through POST /auth/ssh-key.

DELETE /ssh-keys/{key_id}

Delete one SSH key the caller owns. The delete is the whole revoke, so a push with that key fails at once.

Auth: Bearer token

Request
curl -X DELETE https://api.xhostd.com/ssh-keys/b8e4d2c6-1a3f-4e5b-9c7d-0f2a4b6c8e1d \
  -H "Authorization: Bearer $XHOST_TOKEN"

Returns 204 No Content on success.

Errors

StatusCodeWhen
404not_foundNo such key, or the key belongs to another account

Account

An account an agent registered holds no email, so no person can open the console for it. These two routes verify an address: the account moves from starter to basic, and Google sign-in with that address opens the console for the account. Both take a bearer and no scope. The MCP tools are request_email_verification and complete_email_verification.

POST /me/email-verifications

Start one email challenge. The platform mails an 8-character code (lowercase letters and digits without 0, 1, i, l, o) that expires in 15 minutes. A new request after 60 seconds replaces the code.

Auth: Bearer token

Request body

FieldTypeDescription
emailstring requiredThe address to verify; stripped and lowercased, 254 characters or fewer
Request
curl -X POST https://api.xhostd.com/me/email-verifications \
  -H "Authorization: Bearer $XHOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email": "owner@example.com"}'
Response — 200
{
  "status": "sent",
  "expires_at": "2026-09-07T12:15:00Z"
}

Errors

StatusCodeWhen
400bad_requestThe address fails the shape check
409conflictThe account already has a verified email
422noneAn unknown or missing field; FastAPI's validation body
429too_many_requestsA code was sent less than 60 seconds ago
POST /me/email-verifications/complete

Prove the code. Success writes the address as the account's sign-in email, moves a starter account to basic, and queues the plan apply that raises the limits.

Auth: Bearer token

Request body

FieldTypeDescription
codestring requiredThe 8-character code from the mail; stripped and lowercased
Request
curl -X POST https://api.xhostd.com/me/email-verifications/complete \
  -H "Authorization: Bearer $XHOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"code": "abcd2345"}'
Response — 200
{
  "status": "verified",
  "plan": "basic",
  "apply_queued": true
}

apply_queued is false when a move holds the account; the apply runs after it.

Errors

StatusCodeWhen
400bad_requestWrong code; the attempt counts
409conflictThe address belongs to another account; the challenge clears and the plan stays
410goneNo verification is pending, or the code expired; request a new code
422noneAn unknown or missing field; FastAPI's validation body
429too_many_requestsFive wrong codes locked the challenge; the right code answers this too. Request a new code

Feedback

POST /feedback

Submit free-text feedback to the xhostd team about platform friction. Attributed to the authenticated user. Fire-and-forget.

Auth: Bearer token

Request body

FieldTypeDescription
messagestring requiredThe feedback text. Must be non-empty after trimming; max 4000 characters.
app_idUUIDOptional id of the app being worked on, for context. An unknown or inaccessible id is silently dropped (stored as null); the feedback still lands.
Response — 200
{
  "id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
  "status": "Received"
}

Errors

StatusCodeWhen
400bad_requestEmpty message, message longer than 4000 characters, or the account reached its report limit (1000 by default; an operator raises or lowers it per account, and the message names the limit that applies)
GET /feedback

List the authenticated user's feedback reports, newest first, each with the xhostd team's answers. One call answers one page of the account's reports, whichever surface filed them.

Auth: Bearer token

Query parameters

ParameterTypeDescription
limitintegerHow many reports to return, 1–200. Default 50.
cursorstringThe next_cursor of the previous call. Omit it on the first call. A cursor the route cannot read answers bad_request (400).
Response — 200
{
  "reports": [
    {
      "id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
      "message": "Deploy logs don't stream.",
      "status": "Resolved",
      "source": "agent",
      "app_name": "myapp",
      "created_at": "2026-01-04T10:00:00+00:00",
      "handled_at": "2026-01-05T09:30:00+00:00",
      "messages": [
        {
          "body": "Status changed to Resolved.\n\nStreaming logs shipped today.",
          "created_at": "2026-01-05T09:30:00+00:00",
          "status": "Resolved"
        }
      ]
    }
  ],
  "next_cursor": "MjAyNi0wMS0wNFQxMDowMDowMCswMDowMHxjNTZhNDE4MC02NWFhLTQyZWMtYTk0NS01ZmQyMWRlYzA1Mzg"
}

status is one of Received, Resolved or Closed. source is agent or console. app_name is null when the report carries no app context. messages holds the team's answers oldest first; a message carries a status only when it records a status change. Internal team notes are never listed. next_cursor is an opaque value: pass it as cursor to read the next page, and it is null when the account has no older report.

Account & stats

GET /api/user/stats

Get dashboard statistics for the authenticated user. The counts and the sites rows cover every project the caller can see, shared projects included — a repo value reads owner/project. The resources block is the caller's own memory and CPU alone, because a shared project runs under its owner's resource slice.

Auth: Bearer token — requires stats:read scope

Request
curl https://api.xhostd.com/api/user/stats \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
{
  "username": "alice",
  "user_id": "550e8400-e29b-41d4-a716-446655440000",
  "platform": {
    "apps": 3,
    "channels": 5,
    "running_channels": 4,
    "deploys_last_hour": 1,
    "deploys_last_day": 7,
    "success_last_day": 6,
    "failed_last_day": 1
  },
  "resources": {
    "mem_current_mb": 45.2,
    "mem_limit_mb": 128.0,
    "mem_percent": 35.3,
    "cpu_current_percent": 2.5
  },
  "sites": [
    {
      "hostname": "my-site-alice.xhostd.app",
      "repo": "alice/my-site",
      "branch": "master",
      "status": "running",
      "sha": "abc1234",
      "latest_deploy_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
      "latest_deploy_status": "success"
    }
  ],
  "collected_at": "2026-04-24 10:30:00 UTC"
}

Response fields

FieldTypeDescription
platformobjectApp, channel, and deploy counts
resourcesobjectMemory and CPU usage from cgroup budgets (zero if not configured)
sitesarrayList of deployed channels with status and latest deploy info
collected_atstringTimestamp when stats were collected
GET /me/usage

Per-account database storage and current-month egress. Storage is a soft cap — over-limit warns, it never blocks. Egress carries no limit field, because no plan states an egress figure: the platform measures egress, reports it, and charges nothing for it.

Auth: Bearer token

Response — 200
{
  "plan": "basic",
  "storage_bytes": 1572864,
  "storage_limit_mb": 250,
  "bandwidth_month_bytes": 104857600
}

Errors

StatusCodeWhen
503postgres_unavailablePostgres admin pool is not configured (degraded mode), or a database could not be measured — a partial total is never reported
GET /plans

Every plan tier and the caps it grants, lowest rank first. No row states an egress figure, because no plan limits egress. This is the one published source of a plan number: the console, the docs and the agent tools all read it rather than holding a constant. The response carries no price — billing lives at Lemon Squeezy.

Auth: none. The route is public, reads no database, and returns the same body for every caller.

Response — 200
{
  "plans": [
    {
      "tier": "basic",
      "rank": 0,
      "max_channels": 5,
      "cpu_soft_cores": 0.1,
      "cpu_burst": 2,
      "visible_cores": 1,
      "mem_limit_mb": 128,
      "storage_mb": 250,
      "blob_storage_bytes": 1073741824,
      "image_size_bytes": 536870912,
      "snapshot_retention_days": 1,
      "deploy_snapshot_keep": 1,
      "port_forwarding": false
    }
  ]
}

Response fields

FieldTypeDescription
tierstringstarter, basic, builder, indie, or pro
rankintegerAscending plan order, 0 for starter. Compare two tiers with it
max_channelsintegerAccount-wide channel cap. Apps and channels draw on this one quota, because every app includes its prod channel
cpu_soft_coresnumberSoft CPU entitlement in cores — the fair share under contention
cpu_burstintegerBurst multiplier. The hard slice cap is cpu_burst × cpu_soft_cores
visible_coresintegerCores the container is pinned to, so nproc and thread-pool auto-sizers read a plan-scaled count
mem_limit_mbintegerPer-container memory ceiling in MiB
storage_mbintegerAccount-wide database storage in MiB. A soft cap: over-limit warns and blocks nothing
blob_storage_bytesintegerAccount-wide object storage in bytes. Enforced: the S3 gateway rejects a crossing PUT with 507. -1 means unlimited
image_size_bytesintegerPer-image ceiling in bytes, applied to the charged size (total minus matched platform base layers)
snapshot_retention_daysintegerAge window for a channel's nightly snapshots. The newest one always survives
deploy_snapshot_keepintegerCount of newest pre_deploy snapshots a channel keeps. A pre_deploy snapshot never ages out
port_forwardingbooleanWhether the tier can hold a public raw-TCP endpoint
agent_registration_onlybooleanTrue for a tier only the agent registration API can create an account on. The console never offers it
GET /me/apps/{app_id}/channels/{channel_id}/stats

Aggregated traffic statistics for one channel: requests and unique visitors by hour, status-class breakdown, top pages/404s/assets, hour-of-day distribution, and country breakdown.

Auth: Bearer token — requires stats:read scope

Query parameters

ParamTypeDescription
rangestringAggregation window: 24h (default), 7d, or 30d.
Response — 200
{
  "range": "24h",
  "finalized_through": "2026-07-21T00:00:00Z",
  "requests_by_hour": [{"t": "2026-07-21T09:00:00Z", "hits": 42}],
  "status_breakdown": {"2xx": 400, "3xx": 12, "4xx": 7, "5xx": 0},
  "top_pages": [{"path": "/", "hits": 180}],
  "top_404s": [{"path": "/favicon.png", "hits": 3}],
  "top_assets": [{"path": "/style.css", "hits": 170}],
  "unique_visitors_by_hour": [{"t": "2026-07-21T09:00:00Z", "unique_visitors": 17}],
  "hour_of_day_distribution": [{"hour": 9, "hits": 42}],
  "country_breakdown": [{"country_code": "US", "hits": 210, "unique_visitors": 40}],
  "country_totals": {"hits": 260, "unique_visitors": 55, "countries": 12}
}

finalized_through is the start of the current UTC day; buckets at or after it are still mutable (the live edge). country_code may be null when the lookup missed.

country_breakdown holds the top 10 countries, ranked by unique_visitors descending, then hits descending, then country_code. The country_code: null (Unknown) row is not ranked: it always follows the ranked countries, so the list may hold 11 entries. country_totals covers every country in the window and is the denominator of that list. Its unique_visitors is the SUM of the per-country distinct counts, not one distinct count over the window, so a visitor seen in two countries counts twice.

Errors

StatusCodeWhen
400bad_requestInvalid range
403scope_deniedToken lacks stats:read scope
404not_foundApp or channel not found
GET /me/apps/{app_id}/stats

Aggregated traffic statistics summed across all of an app's channels. Same shape and range semantics as the per-channel stats endpoint.

Auth: Bearer token — requires stats:read scope

Errors

StatusCodeWhen
400bad_requestInvalid range
403scope_deniedToken lacks stats:read scope
404not_foundApp not found
GET /apps/{app_id}/events

Attributed activity trail of project mutations (member changes, deploys, env writes and reveals, database operations, git pushes), newest first. No secret values appear. Any member can read.

Auth: Bearer token

Query parameters

ParameterTypeDescription
limitintegerMax events to return, default 50, clamped to 1–100
beforetimestampOnly events created before this instant (pagination cursor from next_before)
Response — 200
{
  "events": [
    {
      "id": "uuid",
      "actor_username": "alice",
      "action": "deploy.create",
      "target": "prod",
      "detail": {},
      "created_at": "2025-01-16T10:30:00Z"
    }
  ],
  "next_before": "2025-01-16T10:30:00Z"
}

next_before is null when there are no more events; otherwise pass it as before to fetch the next page. actor_username is null for system actions.

Exports (takeout)

Export a channel or a whole app — code, data, and generated restore scripts — as a downloadable archive. One non-terminal export per user at a time. Flow: create, poll status until ready, mint a download token, download.

POST /exports

Queue an export. Returns 202 with the export row; poll GET /exports/{export_id} for progress.

Auth: Bearer token

Request body

FieldTypeDescription
scopestring requiredchannel or app.
app_idUUID requiredThe app to export (or the channel's app).
channel_idUUIDRequired when scope is channel.
Response — 202
{
  "id": "5d41402a-bc4b-4a2a-8b8e-1327dd93b5b1",
  "scope": "channel",
  "app_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "channel_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "status": "queued",
  "detail": null,
  "progress_pct": 0,
  "size_bytes": null,
  "error": null,
  "blobs_included": false,
  "blobs_reason": null,
  "blob_object_count": null,
  "blob_bytes_estimate": null,
  "created_at": "2026-07-21T10:00:00Z",
  "finished_at": null,
  "expires_at": null
}

Errors

StatusCodeWhen
400bad_requestInvalid scope, missing channel_id for a channel export, or nothing deployed to export
404not_foundApp or channel not found
409conflictAn export is already in progress, or the account is undergoing scheduled maintenance
GET /exports

List the authenticated user's exports, newest first. GET /exports/{export_id} returns a single row (404 if not yours). Rows use the same shape as the create response; a ready export carries size_bytes, expires_at, and the blob-inclusion fields.

Auth: Bearer token

POST /exports/{export_id}/download-token

Mint a short-lived exports:read token for a ready, owned export. The calling token must carry exports:read itself, because a mint never grants more than the caller already holds. The plaintext is returned once and expires with the export. Use it as the bearer on the download URLs.

Auth: Bearer token carrying exports:read

Response — 200
{
  "token": "xh_export123...",
  "download_url": "/exports/5d41402a-bc4b-4a2a-8b8e-1327dd93b5b1/download",
  "blobs_download_url": "/exports/5d41402a-bc4b-4a2a-8b8e-1327dd93b5b1/download/blobs",
  "expires_at": "2026-07-24T10:05:00Z",
  "blobs_included": true,
  "blobs_reason": null
}

Errors

StatusCodeWhen
404not_foundExport not found, not owned by caller, or not ready
GET /exports/{export_id}/download

Stream the export.tar.gz archive. GET /exports/{export_id}/download/blobs streams blobs.tar.gz with the object-store contents pinned to the versions inventoried at build time. Each download is audit-logged.

Auth: Bearer token — requires exports:read scope (mint via download-token)

Request
curl https://api.xhostd.com/exports/5d41402a-bc4b-4a2a-8b8e-1327dd93b5b1/download \
  -H "Authorization: Bearer $DOWNLOAD_TOKEN" \
  -o export.tar.gz

Errors

StatusCodeWhen
403scope_deniedToken lacks exports:read scope
404not_foundExport not found, not owned by caller, or not ready
409conflict/download/blobs only: blobs not included (over the download threshold) — use the generated sync-blobs.sh instead

Reference

Hostname derivation

Every channel gets a unique hostname derived from the app name, channel name, and username.

ChannelHostname patternExample
prod<app>-<user>.xhostd.appmy-site-alice.xhostd.app
Any other<channel>-<app>-<user>.xhostd.appstaging-my-site-alice.xhostd.app

All name components must be valid DNS labels: lowercase letters, digits, and hyphens, with no leading or trailing hyphen and a maximum length of 40 characters.

Reserved prefixes

The following names cannot be used as app names (and cannot start app names followed by a hyphen):

git, api, www, admin, preview, staging

Channel status values

StatusMeaning
provisioningChannel created, no container running yet. Waiting for first deploy.
runningContainer is live and serving traffic.
failedLast deploy failed, or SQL provisioning failed at create time. The hourly sweeper retries SQL provisioning.

Deploy status values

StatusMeaning
queuedDeploy accepted and waiting to be processed.
runningDeploy is actively building/starting the container.
successDeploy completed and the site is live.
failedDeploy failed. Check the deploy logs for details.

git_ref_binding format

The git_ref_binding field controls which git refs a channel accepts for deployment.

FormatMeaningExample
branch:<name>Bind to a specific branch. One channel per branch.branch:master

The legacy branch:* wildcard is deprecated and rejected at create time.

Error codes

CodeHTTP StatusMeaning
auth_required401No token provided or token is invalid
token_invalid401Token does not exist or invite is invalid
token_revoked401Token has been revoked
scope_denied403Token lacks the required scope
permission_denied403Caller is not authorized for this action (e.g. not admin)
protected_action403A protected action — it needs a person in the web console. Turn agent access on at the URL in the message, then retry. Ownership transfer names no URL: no setting opens it
admin_not_configured403Admin user has not been bootstrapped on this instance
not_found404Resource does not exist or is not owned by the caller
bad_request400Invalid input (name format, reserved name, etc.)
conflict409State conflict: a taken name, a registered key, a verified email, a busy channel
gone410The thing the call acts on no longer exists, and a retry cannot bring it back: an expired or absent email challenge
too_many_requests429A budget or a window refused the call: the registration budget, a second verification request inside 60 seconds, or a locked challenge
bad_gateway502Upstream dependency failed
service_unavailable503A dependent service is degraded, or agent registration is closed
internal_error500Unexpected server error

Token scopes

ScopeGrants access toDefault
repo:*Create and manage apps (git repo provisioning), push to git reposYes
deploy:*Trigger deploys, manage env vars, read deploy and runtime logs, and list channel imagesYes
channel:*Create and delete channelsYes
db:*Connect to Postgres through the database gateway (external DB access)Yes
blob:*Mint object-storage credentials for a channelYes
stats:readRead account, resource, app, channel, health, and admin statisticsYes
exports:readDownload a ready export archiveYes
snapshots:readDownload a Postgres snapshot extractYes
blobs:readDownload an object-storage snapshot tarballYes

OAuth-issued bearer tokens (used by the MCP server) and tokens minted via POST /tokens carry all nine default scopes. Credentials minted via POST /credentials carry every default scope the calling token holds, which is all nine for a general credential, unless a narrower scopes subset is requested. Tokens from POST /registrations and POST /auth/ssh-key carry the same nine defaults and expire after 30 days.

A mint never grants more than the caller already holds. A narrowed credential can renew itself, and cannot widen itself back. POST /tokens grants a fixed nine scopes, so a token short of that set reads 403 scope_denied there and mints on POST /credentials instead. The three download-token routes each require the one scope they hand out, so a narrow download token cannot rotate into another artifact. POST /ssh-keys requires repo:*, the access a key carries by construction.

Enter a topic, task, or tool name.

Public documentation only · Search stays in your browser.