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: 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": {
"code": "not_found",
"message": "app not found"
}
}
Endpoints
POST /registrations; a person
signs up in the browser. After
signing in, create API tokens for CLI/agent use on the
dashboard.
Apps
List all apps owned by the authenticated user.
Auth: Bearer token
curl https://api.xhostd.com/apps \
-H "Authorization: Bearer $XHOST_TOKEN"
{
"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.
Create a new app. Provisions a git repo and a prod channel.
Auth: Bearer token — requires repo:* scope
Request body
| Field | Type | Description |
|---|---|---|
name | string required | App name. DNS label rules: lowercase, digits, hyphens. Max 40 chars. Must not start with a reserved prefix (git, api, www, admin, preview, staging). |
template | string optional | App 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. |
curl -X POST https://api.xhostd.com/apps \
-H "Authorization: Bearer $XHOST_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "my-site", "template": "static"}'
{
"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
| Status | Code | When |
|---|---|---|
| 400 | bad_request | Invalid name, reserved prefix, name taken, or invalid template |
| 403 | scope_denied | Token lacks repo:* scope |
Get details of a single app, including all channels.
Auth: Bearer token
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479 \
-H "Authorization: Bearer $XHOST_TOKEN"
{
"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
| Status | Code | When |
|---|---|---|
| 404 | not_found | App does not exist or is not owned by the caller |
Delete an app. Stops all containers, removes the git repo, and cleans up DNS routes.
Auth: Bearer token — requires repo:* scope
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
| Status | Code | When |
|---|---|---|
| 403 | scope_denied | Token lacks repo:* scope |
| 404 | not_found | App does not exist or is not owned by the caller |
Channels
List all channels for an app.
Auth: Bearer token
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels \
-H "Authorization: Bearer $XHOST_TOKEN"
[
{
"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
}
]
Create a new channel (e.g., a preview or staging environment).
Auth: Bearer token — requires channel:* scope
Request body
| Field | Type | Description |
|---|---|---|
name | string required | Channel name. DNS label rules. Cannot be prod (auto-created). |
git_ref_binding | string required | Git ref binding. Format: branch:<name>. One channel per branch; the legacy branch:* wildcard is deprecated and rejected at create time. |
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"}'
{
"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
| Status | Code | When |
|---|---|---|
| 400 | bad_request | Invalid name, reserved name (prod), or invalid git_ref_binding format |
| 403 | scope_denied | Token lacks channel:* scope |
| 404 | not_found | App not found |
Get details of a single channel.
Auth: Bearer token
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7 \
-H "Authorization: Bearer $XHOST_TOKEN"
{
"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
| Status | Code | When |
|---|---|---|
| 404 | not_found | App or channel not found |
Delete a channel. Stops the container and removes DNS routes. Cannot delete the prod channel.
Auth: Bearer token — requires channel:* scope
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
| Status | Code | When |
|---|---|---|
| 400 | bad_request | Attempted to delete the prod channel |
| 403 | scope_denied | Token lacks channel:* scope |
| 404 | not_found | App or channel not found |
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
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/images \
-H "Authorization: Bearer $XHOST_TOKEN"
{
"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
| Field | Type | Description |
|---|---|---|
images | array or null | Built images for this channel, newest first. null (never an error) when the channel's host agent is unreachable. |
images[].charged_size_bytes | integer or null | Size counted against the plan's image cap — recognized base-image layers are excluded. null when not computed. |
images[].matched_base | string or null | The recognized base image whose layers are not charged, or null. |
images[].current | boolean | Whether the image is the channel's currently deployed SHA. |
image_cap_bytes | integer | Per-plan cap on an image's charged size. |
Errors
| Status | Code | When |
|---|---|---|
| 403 | scope_denied | Token lacks deploy:* scope |
| 404 | not_found | App or channel not found |
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
| Field | Type | Description |
|---|---|---|
snapshot_id | UUID | A snapshot id from GET .../postgres/snapshots. The route serves the commit that row recorded. Omit it for the channel's current commit. |
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
| Status | Code | When |
|---|---|---|
| 403 | scope_denied | Token lacks repo:* scope |
| 403 | download_disabled | The operator keeps this download off |
| 404 | not_found | App or channel not found |
| 404 | snapshot_not_found | The id names no snapshot of this channel |
| 404 | snapshot_sha_unknown | The row records no commit, or the channel never deployed. Permanent — do not repeat the call. |
| 404 | snapshot_code_gone | A force-push and a later git gc removed the commit. Permanent — do not repeat the call. |
Deploy & logs
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
| Field | Type | Description |
|---|---|---|
sha | string | A 40-character hex SHA or a branch name (e.g. master, HEAD). The server resolves branch names to SHAs at deploy time. |
ref | string | A 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.
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"}'
{
"deploy_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"channel_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"status": "queued"
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | bad_request | Invalid SHA format, or neither sha nor ref given |
| 403 | scope_denied | Token lacks deploy:* scope |
| 404 | not_found | App or channel not found |
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
| Param | Type | Description |
|---|---|---|
deploy | UUID required | The deploy ID returned by the deploy endpoint |
offset | int optional | Byte 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_bytes | int optional | Window size in bytes. Default 16384, max 262144. |
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"
{
"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
| Status | Code | When |
|---|---|---|
| 403 | scope_denied | Token lacks deploy:* scope |
| 404 | not_found | Deploy not found under this app and channel |
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
| Field | Type | Description |
|---|---|---|
command | string optional | A 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_index | int optional | Which container generation to read. Omitted, the newest. available_indices in every reply lists what exists, oldest first. |
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"}'
{
"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
| Status | Code | When |
|---|---|---|
| 403 | scope_denied | Token lacks deploy:* scope |
| 404 | not_found | App or channel not found, or nothing readable for this channel yet |
| 503 | service_unavailable | The host cannot answer right now — retry later |
Environment & secrets
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
| Field | Type | Description |
|---|---|---|
key | string required | Env 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. |
value | string required | The 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. |
kind | string | env (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_id | UUID | Omit 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. |
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
| Status | Code | When |
|---|---|---|
| 400 | bad_request | Invalid key format or reserved key |
| 403 | scope_denied | Token lacks deploy:* scope |
| 404 | not_found | App not found, or channel_id not a channel of this app |
Delete an environment variable or secret. Takes effect on the next deploy.
Auth: Bearer token — requires deploy:* scope
Query parameters
| Param | Type | Description |
|---|---|---|
channel_id | UUID | With it, deletes only that channel's override; without it, deletes the app-level default. |
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
| Status | Code | When |
|---|---|---|
| 403 | scope_denied | Token lacks deploy:* scope |
| 404 | not_found | App not found |
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
| Param | Type | Description |
|---|---|---|
channel_id | UUID | Without 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. |
curl "https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/env?channel_id=7c9e6679-7425-40de-944b-e07fc1f90ae7" \
-H "Authorization: Bearer $XHOST_TOKEN"
{
"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
| Status | Code | When |
|---|---|---|
| 403 | scope_denied | Token lacks deploy:* scope |
| 404 | not_found | App not found, or channel_id not a channel of this app |
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
| Param | Type | Description |
|---|---|---|
channel_id | UUID | With it, resolved semantics: that channel's override wins, falling back to the app-level default. Without it, the app-level row only. |
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/env/STRIPE_SECRET_KEY/value \
-H "Authorization: Bearer $XHOST_TOKEN"
{
"key": "STRIPE_SECRET_KEY",
"kind": "secret",
"scope": "app",
"value": "sk_live_..."
}
Errors
| Status | Code | When |
|---|---|---|
| 403 | scope_denied | Token lacks deploy:* scope |
| 404 | not_found | App, channel, or env key not found |
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
{
"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
| Status | Code | When |
|---|---|---|
| 403 | scope_denied | Token lacks deploy:* scope |
| 404 | not_found | Deploy 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.
__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:
| Endpoint | Purpose |
|---|---|
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/whoami | JSON {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/jwks | RS256 public keys (JWK set) for app-side verification. |
GET https://auth.xhostd.com/.well-known/openid-configuration | OIDC 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.
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).
Attach a custom domain. Returns the DNS records you must create at your registrar before calling /verify.
Auth: Bearer token
Request body
| Field | Type | Description |
|---|---|---|
domain | string required | The hostname to attach. IDNA-encoded form is stored canonically; case-folded; trailing dot stripped. |
{
"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
| Status | Code | When |
|---|---|---|
| 400 | invalid domain: <reason> | Validation failure. Reasons: is_ip_address, invalid_idna, too_long, invalid_label, platform_domain_forbidden, needs_dot. |
| 400 | domain_limit_reached | Channel already has 5 attached domains. |
| 404 | not_found | App or channel not found. |
| 409 | domain_taken | Another channel owns this hostname. |
List every custom domain attached to a channel. Same per-item shape as POST.
Auth: Bearer token
{"domains": [{...}, {...}]}
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.
{
"domain": "app.customer.com",
"status": "verified",
"reason": null,
"dns_records": { ... },
"created_at": "2026-06-12T00:00:00Z",
"verified_at": "2026-06-12T00:05:00Z"
}
Detach a custom domain. The route is removed immediately; certificate renewals stop.
Auth: Bearer token
{"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.
Inspect the channel's Postgres database: name, role, status, live connection count, storage usage.
Auth: Bearer token
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/postgres \
-H "Authorization: Bearer $XHOST_TOKEN"
{
"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
| Field | Type | Description |
|---|---|---|
db_name | string | The name of the channel's own Postgres database, and the typed confirmation for destructive operations |
role_name | string | The Postgres role used in DATABASE_URL |
status | string | One of provisioning, ready, failed |
last_error | string or null | Provisioner error message if status is failed |
connection_count | integer | Live connections currently held by this role |
connection_limit | integer | Configured CONNECTION LIMIT for the write role |
password_set | boolean | Whether the role has a stored password |
storage_bytes | integer | Bytes this channel's data occupies on disk |
Errors
| Status | Code | When |
|---|---|---|
| 404 | not_found | App, channel, or schema row not found |
Empty the channel's database. The role and password are preserved, so the same DATABASE_URL keeps working.
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
| Field | Type | Description |
|---|---|---|
confirm_db_name | string required | Must match the channel's current db_name exactly. Acts as a typed confirmation. |
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
| Status | Code | When |
|---|---|---|
| 400 | invalid_confirmation | confirm_db_name does not match the channel's db_name |
| 404 | not_found | App, channel, or schema row not found |
| 409 | conflict | Channel postgres is not in ready state |
| 503 | postgres_unavailable | Postgres admin pool is not configured (degraded mode) |
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
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
| Status | Code | When |
|---|---|---|
| 404 | not_found | App, channel, or schema row not found |
| 409 | conflict | Channel postgres is not in ready state |
| 503 | postgres_unavailable | Postgres admin pool is not configured (degraded mode) |
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
[
{
"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
| Status | Code | When |
|---|---|---|
| 404 | not_found | App, channel, or schema row not found |
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
{
"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
| Status | Code | When |
|---|---|---|
| 403 | download_disabled | The operator keeps this download off |
| 404 | not_found | App or channel not found |
| 404 | snapshot_not_found | The id names no snapshot of this channel |
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)
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
| Status | Code | When |
|---|---|---|
| 403 | download_disabled | The operator keeps this download off |
| 403 | scope_denied | Token lacks snapshots:read scope |
| 404 | not_found | App or channel not found |
| 404 | snapshot_not_found | The id names no snapshot of this channel |
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
| Field | Type | Description |
|---|---|---|
confirm_db_name | string required | Must match the channel's current db_name exactly. Acts as a typed confirmation. |
snapshot_id | UUID required | A snapshot id from the snapshots list. |
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
| Status | Code | When |
|---|---|---|
| 400 | invalid_confirmation | confirm_db_name does not match the channel's db_name |
| 403 | prod_restore_blocked | prod channel without XHOST_ALLOW_PROD_RESTORE=1 |
| 404 | snapshot_not_found | Snapshot does not exist (or its file is missing) |
| 409 | channel_busy | Channel is busy, or the account is undergoing scheduled maintenance |
| 503 | postgres_unavailable | Postgres admin pool is not configured (degraded mode) |
Report total Postgres storage and database count for the authenticated user, across all channels.
Auth: Bearer token
curl https://api.xhostd.com/me/postgres/storage \
-H "Authorization: Bearer $XHOST_TOKEN"
{
"database_size_bytes": 1572864,
"database_count": 5
}
Response fields
| Field | Type | Description |
|---|---|---|
database_size_bytes | integer | Bytes the account's data occupies on disk, summed over every database that holds it |
database_count | integer | Number of databases that total was measured across |
Errors
| Status | Code | When |
|---|---|---|
| 503 | postgres_unavailable | Postgres 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.
Inspect the channel's object store: provisioning status, usage, and the virtual S3 endpoint/bucket the app sees.
Auth: Bearer token
{
"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
| Status | Code | When |
|---|---|---|
| 404 | not_found | App, channel, or blob store not found |
| 503 | blob_unavailable | Object storage is unavailable |
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
{
"access_key_id": "AKxhost...",
"secret_access_key": "…",
"endpoint": "https://my-site-alice.s3.xhostd.app",
"region": "xhost",
"bucket": "my-site-alice-xhostd-com"
}
Errors
| Status | Code | When |
|---|---|---|
| 403 | scope_denied | Token lacks blob:* scope |
| 404 | not_found | App, channel, or blob store not found |
| 409 | blob_not_ready | Blob store is not in ready state |
| 503 | blob_unavailable | Object storage is unavailable |
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
[
{
"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
| Status | Code | When |
|---|---|---|
| 404 | not_found | App, channel, or blob store not found |
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
{
"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
| Status | Code | When |
|---|---|---|
| 403 | download_disabled | The operator keeps this download off |
| 404 | not_found | App or channel not found |
| 404 | snapshot_not_found | The id names no checkpoint of this channel |
| 409 | no_aligned_blob_snapshot | The checkpoint has no aligned object-store snapshot |
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)
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
| Status | Code | When |
|---|---|---|
| 403 | download_disabled | The operator keeps this download off |
| 403 | scope_denied | Token lacks blobs:read scope |
| 404 | not_found | App or channel not found |
| 404 | snapshot_not_found | The id names no checkpoint of this channel |
| 409 | no_aligned_blob_snapshot | The checkpoint has no aligned object-store snapshot |
| 409 | conflict | The 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. |
| 503 | blob_unavailable | Object storage is unavailable |
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
| Field | Type | Description |
|---|---|---|
confirm_channel_name | string required | Must match the channel's name exactly. Acts as a typed confirmation. |
snapshot_id | UUID required | The 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
| Status | Code | When |
|---|---|---|
| 400 | invalid_confirmation | confirm_channel_name does not match the channel's name |
| 403 | prod_restore_blocked | prod channel without XHOST_ALLOW_PROD_RESTORE=1 |
| 404 | snapshot_not_found | Checkpoint does not exist on this channel, or the resolved snapshot is too old to restore |
| 409 | channel_busy | Channel is busy |
| 409 | no_aligned_blob_snapshot | The checkpoint has no aligned blob leg |
| 503 | blob_unavailable | Object storage is unavailable |
Report total object-store usage and provisioned store count for the authenticated user, across all channels.
Auth: Bearer token
{
"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.
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
| Field | Type | Description |
|---|---|---|
remote_url | string required | SSH GitHub URL, e.g. git@github.com:alice/my-site.git. |
{
"public_key": "ssh-ed25519 AAAA... xhost-deploy-f47ac10b-58cc-4372-a567-0e02b2c3d479",
"remote_url": "git@github.com:alice/my-site.git"
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | bad_request | remote_url is not an SSH GitHub URL |
| 404 | not_found | App not found or caller lacks the owner role |
| 409 | conflict | A GitHub repo is already connected |
Connection status and last sync outcome. connected: false (other fields null) when no repo is connected.
Auth: Bearer token
{
"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
| Status | Code | When |
|---|---|---|
| 404 | not_found | App not found or caller is not a member |
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
| Status | Code | When |
|---|---|---|
| 404 | not_found | App not found, no repo connected, or caller lacks the admin role |
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
| Status | Code | When |
|---|---|---|
| 404 | not_found | App 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.
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
| Field | Type | Description |
|---|---|---|
public_key | string required | One OpenSSH public-key line; ssh-ed25519 only |
timestamp | integer required | Unix seconds the client signed; within 300 seconds of the platform clock |
signature | string required | The armored SSHSIG block over the message, under the namespace xhostd-register |
username | string optional | A 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 |
label | string optional | The key's label on the account, 64 characters or fewer; default registration |
Any other field answers 422.
xhostd-register
<username or empty>
<timestamp>
curl -sS https://api.xhostd.com/registrations \
-H "Content-Type: application/json" \
--data @body.json -o response.json -w 'HTTP %{http_code}\n'
{
"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" }
}
limits is the starter row of GET /plans.
Errors
| Status | Code | When |
|---|---|---|
| 400 | bad_request | A 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 |
| 409 | conflict | The 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) |
| 422 | none | An unknown or missing field; FastAPI's validation body |
| 429 | too_many_requests | The fleet-wide or the per-source daily budget is spent; retry the next day |
| 503 | service_unavailable | Agent registration is closed by the operator |
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
| Field | Type | Description |
|---|---|---|
public_key | string required | One OpenSSH public-key line; ssh-ed25519 only |
timestamp | integer required | Unix seconds the client signed; within 300 seconds of the platform clock |
signature | string required | The armored SSHSIG block over the message, under the namespace xhostd-login |
SHA256: value ssh-keygen -lf prints)xhostd-login
<fingerprint_sha256>
<timestamp>
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'
{
"token": "xh_...",
"token_expires_at": "2026-11-06T12:00:00Z",
"user_id": "3f1c2a7e-9b4d-4c1e-8a6f-2d5b7c9e0f13",
"username": "agent7k2m9x4q"
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | bad_request | A key line the parser refuses, a key of another type, a timestamp outside the 300-second window, or a signature that does not verify |
| 404 | not_found | No api_login key matches this fingerprint: an unknown key, or one without api_login. Register the key through POST /registrations |
| 422 | none | An unknown or missing field; FastAPI's validation body |
Tokens & credentials
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
| Field | Type | Description |
|---|---|---|
label | string optional | Human-readable label (e.g. "ci", "laptop") |
expires_in | integer optional | Lifetime 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. |
curl -X POST https://api.xhostd.com/tokens \
-H "Authorization: Bearer $XHOST_TOKEN" \
-H "Content-Type: application/json" \
-d '{"label": "ci"}'
{
"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
| Status | Code | When |
|---|---|---|
| 403 | scope_denied | The calling token does not carry all nine default scopes. Use POST /credentials instead. |
token_id.
Revoke a token. The token is immediately invalidated.
Auth: Bearer token
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
| Status | Code | When |
|---|---|---|
| 404 | not_found | Token not found or not owned by the caller |
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)
| Field | Type | Description |
|---|---|---|
scopes | array of strings | When 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_in | integer | Lifetime 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. |
curl -X POST https://api.xhostd.com/credentials \
-H "Authorization: Bearer $XHOST_TOKEN"
{
"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
| Status | Code | When |
|---|---|---|
| 400 | bad_request | Empty 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.
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
| Field | Type | Description |
|---|---|---|
public_key | string required | One OpenSSH public-key line: ssh-ed25519, ssh-rsa, or ecdsa-sha2-nistp256/384/521 |
label | string optional | Your own name for the key; max 64 characters |
api_login | boolean optional | Default 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.
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\"}"
{
"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
| Status | Code | When |
|---|---|---|
| 400 | bad_request | The line is no valid OpenSSH public key, or the label is longer than 64 characters |
| 403 | scope_denied | The calling token does not carry repo:*, or asks for api_login without all nine default scopes |
| 409 | conflict | The 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 |
| 422 | none | An unknown or missing field; FastAPI's validation body |
List the caller's SSH keys, newest first. Metadata only.
Auth: Bearer token
curl https://api.xhostd.com/ssh-keys \
-H "Authorization: Bearer $XHOST_TOKEN"
{
"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 one SSH key the caller owns. The delete is the whole revoke, so a push with that key fails at once.
Auth: Bearer token
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
| Status | Code | When |
|---|---|---|
| 404 | not_found | No 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.
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
| Field | Type | Description |
|---|---|---|
email | string required | The address to verify; stripped and lowercased, 254 characters or fewer |
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"}'
{
"status": "sent",
"expires_at": "2026-09-07T12:15:00Z"
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | bad_request | The address fails the shape check |
| 409 | conflict | The account already has a verified email |
| 422 | none | An unknown or missing field; FastAPI's validation body |
| 429 | too_many_requests | A code was sent less than 60 seconds ago |
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
| Field | Type | Description |
|---|---|---|
code | string required | The 8-character code from the mail; stripped and lowercased |
curl -X POST https://api.xhostd.com/me/email-verifications/complete \
-H "Authorization: Bearer $XHOST_TOKEN" \
-H "Content-Type: application/json" \
-d '{"code": "abcd2345"}'
{
"status": "verified",
"plan": "basic",
"apply_queued": true
}
apply_queued is false when a move holds the account; the apply runs after it.
Errors
| Status | Code | When |
|---|---|---|
| 400 | bad_request | Wrong code; the attempt counts |
| 409 | conflict | The address belongs to another account; the challenge clears and the plan stays |
| 410 | gone | No verification is pending, or the code expired; request a new code |
| 422 | none | An unknown or missing field; FastAPI's validation body |
| 429 | too_many_requests | Five wrong codes locked the challenge; the right code answers this too. Request a new code |
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
| Field | Type | Description |
|---|---|---|
message | string required | The feedback text. Must be non-empty after trimming; max 4000 characters. |
app_id | UUID | Optional id of the app being worked on, for context. An unknown or inaccessible id is silently dropped (stored as null); the feedback still lands. |
{
"id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
"status": "Received"
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | bad_request | Empty 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) |
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
| Parameter | Type | Description |
|---|---|---|
limit | integer | How many reports to return, 1–200. Default 50. |
cursor | string | The next_cursor of the previous call. Omit it on the first call. A cursor the route cannot read answers bad_request (400). |
{
"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 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
curl https://api.xhostd.com/api/user/stats \
-H "Authorization: Bearer $XHOST_TOKEN"
{
"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
| Field | Type | Description |
|---|---|---|
platform | object | App, channel, and deploy counts |
resources | object | Memory and CPU usage from cgroup budgets (zero if not configured) |
sites | array | List of deployed channels with status and latest deploy info |
collected_at | string | Timestamp when stats were collected |
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
{
"plan": "basic",
"storage_bytes": 1572864,
"storage_limit_mb": 250,
"bandwidth_month_bytes": 104857600
}
Errors
| Status | Code | When |
|---|---|---|
| 503 | postgres_unavailable | Postgres admin pool is not configured (degraded mode), or a database could not be measured — a partial total is never reported |
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.
{
"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
| Field | Type | Description |
|---|---|---|
tier | string | starter, basic, builder, indie, or pro |
rank | integer | Ascending plan order, 0 for starter. Compare two tiers with it |
max_channels | integer | Account-wide channel cap. Apps and channels draw on this one quota, because every app includes its prod channel |
cpu_soft_cores | number | Soft CPU entitlement in cores — the fair share under contention |
cpu_burst | integer | Burst multiplier. The hard slice cap is cpu_burst × cpu_soft_cores |
visible_cores | integer | Cores the container is pinned to, so nproc and thread-pool auto-sizers read a plan-scaled count |
mem_limit_mb | integer | Per-container memory ceiling in MiB |
storage_mb | integer | Account-wide database storage in MiB. A soft cap: over-limit warns and blocks nothing |
blob_storage_bytes | integer | Account-wide object storage in bytes. Enforced: the S3 gateway rejects a crossing PUT with 507. -1 means unlimited |
image_size_bytes | integer | Per-image ceiling in bytes, applied to the charged size (total minus matched platform base layers) |
snapshot_retention_days | integer | Age window for a channel's nightly snapshots. The newest one always survives |
deploy_snapshot_keep | integer | Count of newest pre_deploy snapshots a channel keeps. A pre_deploy snapshot never ages out |
port_forwarding | boolean | Whether the tier can hold a public raw-TCP endpoint |
agent_registration_only | boolean | True for a tier only the agent registration API can create an account on. The console never offers it |
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
| Param | Type | Description |
|---|---|---|
range | string | Aggregation window: 24h (default), 7d, or 30d. |
{
"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
| Status | Code | When |
|---|---|---|
| 400 | bad_request | Invalid range |
| 403 | scope_denied | Token lacks stats:read scope |
| 404 | not_found | App or channel not found |
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
| Status | Code | When |
|---|---|---|
| 400 | bad_request | Invalid range |
| 403 | scope_denied | Token lacks stats:read scope |
| 404 | not_found | App not found |
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
| Parameter | Type | Description |
|---|---|---|
limit | integer | Max events to return, default 50, clamped to 1–100 |
before | timestamp | Only events created before this instant (pagination cursor from next_before) |
{
"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.
Queue an export. Returns 202 with the export row; poll GET /exports/{export_id} for progress.
Auth: Bearer token
Request body
| Field | Type | Description |
|---|---|---|
scope | string required | channel or app. |
app_id | UUID required | The app to export (or the channel's app). |
channel_id | UUID | Required when scope is channel. |
{
"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
| Status | Code | When |
|---|---|---|
| 400 | bad_request | Invalid scope, missing channel_id for a channel export, or nothing deployed to export |
| 404 | not_found | App or channel not found |
| 409 | conflict | An export is already in progress, or the account is undergoing scheduled maintenance |
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
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
{
"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
| Status | Code | When |
|---|---|---|
| 404 | not_found | Export not found, not owned by caller, or not ready |
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)
curl https://api.xhostd.com/exports/5d41402a-bc4b-4a2a-8b8e-1327dd93b5b1/download \
-H "Authorization: Bearer $DOWNLOAD_TOKEN" \
-o export.tar.gz
Errors
| Status | Code | When |
|---|---|---|
| 403 | scope_denied | Token lacks exports:read scope |
| 404 | not_found | Export not found, not owned by caller, or not ready |
| 409 | conflict | /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.
| Channel | Hostname pattern | Example |
|---|---|---|
prod | <app>-<user>.xhostd.app | my-site-alice.xhostd.app |
| Any other | <channel>-<app>-<user>.xhostd.app | staging-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
| Status | Meaning |
|---|---|
provisioning | Channel created, no container running yet. Waiting for first deploy. |
running | Container is live and serving traffic. |
failed | Last deploy failed, or SQL provisioning failed at create time. The hourly sweeper retries SQL provisioning. |
Deploy status values
| Status | Meaning |
|---|---|
queued | Deploy accepted and waiting to be processed. |
running | Deploy is actively building/starting the container. |
success | Deploy completed and the site is live. |
failed | Deploy 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.
| Format | Meaning | Example |
|---|---|---|
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
| Code | HTTP Status | Meaning |
|---|---|---|
auth_required | 401 | No token provided or token is invalid |
token_invalid | 401 | Token does not exist or invite is invalid |
token_revoked | 401 | Token has been revoked |
scope_denied | 403 | Token lacks the required scope |
permission_denied | 403 | Caller is not authorized for this action (e.g. not admin) |
protected_action | 403 | A 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_configured | 403 | Admin user has not been bootstrapped on this instance |
not_found | 404 | Resource does not exist or is not owned by the caller |
bad_request | 400 | Invalid input (name format, reserved name, etc.) |
conflict | 409 | State conflict: a taken name, a registered key, a verified email, a busy channel |
gone | 410 | The thing the call acts on no longer exists, and a retry cannot bring it back: an expired or absent email challenge |
too_many_requests | 429 | A budget or a window refused the call: the registration budget, a second verification request inside 60 seconds, or a locked challenge |
bad_gateway | 502 | Upstream dependency failed |
service_unavailable | 503 | A dependent service is degraded, or agent registration is closed |
internal_error | 500 | Unexpected server error |
Token scopes
| Scope | Grants access to | Default |
|---|---|---|
repo:* | Create and manage apps (git repo provisioning), push to git repos | Yes |
deploy:* | Trigger deploys, manage env vars, read deploy and runtime logs, and list channel images | Yes |
channel:* | Create and delete channels | Yes |
db:* | Connect to Postgres through the database gateway (external DB access) | Yes |
blob:* | Mint object-storage credentials for a channel | Yes |
stats:read | Read account, resource, app, channel, health, and admin statistics | Yes |
exports:read | Download a ready export archive | Yes |
snapshots:read | Download a Postgres snapshot extract | Yes |
blobs:read | Download an object-storage snapshot tarball | Yes |
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.