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.
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
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,
"channels": [
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"name": "prod",
"hostname": "my-site-alice.xhostd.com",
"git_ref_binding": "branch:master",
"current_sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"status": "running"
}
],
"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.
external_db_access_enabled /
external_blob_access_enabled report the app's external
Postgres and object-store access opt-ins; the toggles themselves are
web-console only.
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. With docker, the repo-root Dockerfile is built and run; the container must listen on $PORT, and per-plan image-size caps apply. |
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,
"channels": [
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"name": "prod",
"hostname": "my-site-alice.xhostd.com",
"git_ref_binding": "branch:master",
"current_sha": null,
"status": "provisioning"
}
],
"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,
"channels": [
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"name": "prod",
"hostname": "my-site-alice.xhostd.com",
"git_ref_binding": "branch:master",
"current_sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"status": "running"
}
],
"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
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 |
|---|---|---|
| 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.com",
"git_ref_binding": "branch:master",
"current_sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"status": "running"
},
{
"id": "a3bb189e-8bf9-3888-9912-ace4e6543002",
"name": "staging",
"hostname": "staging-my-site-alice.xhostd.com",
"git_ref_binding": "branch:staging",
"current_sha": null,
"status": "provisioning"
}
]
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.com",
"git_ref_binding": "branch:staging",
"current_sha": null,
"status": "provisioning"
}
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.com",
"git_ref_binding": "branch:master",
"current_sha": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"status": "running"
}
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
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 |
| 404 | not_found | App or channel not found |
Live built-image inventory for one channel, newest first, plus the per-plan image-size cap. Only docker-template apps build images; other templates return an empty list.
Auth: Bearer token
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 |
|---|---|---|
| 404 | not_found | App or channel not found |
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 |
Retrieve the build/deploy log for a specific deploy.
Auth: Bearer token
Query parameters
| Param | Type | Description |
|---|---|---|
deploy | UUID required | The deploy ID returned by the deploy endpoint |
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"
[2026-04-22T10:31:00Z] git-sync: resolved HEAD -> a1b2c3d4
[2026-04-22T10:31:01Z] starting container...
[2026-04-22T10:31:03Z] health check passed
[2026-04-22T10:31:03Z] deploy complete
Errors
| Status | Code | When |
|---|---|---|
| 404 | not_found | Deploy not found, or log not yet available |
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, DATABASE_URL, 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. |
kind | string | env (default, plain variable) or secret. 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
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 |
|---|---|---|
| 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
xhost 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
xhost'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. xhost 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 xhost 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.com",
"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 channel automatically gets its own Postgres schema inside the user's
database, with a dedicated role and a DATABASE_URL injected
into the container at start. Schema migrations are user-managed —
put alembic upgrade head, prisma migrate deploy,
or equivalent in your install.sh. The database is reachable
before install.sh runs.
Inspect the channel's Postgres schema: 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": "u_550e8400e29b41d4a716446655440000",
"schema_name": "ch_7c9e66797425",
"role_name": "r_7c9e66797425",
"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 user-scoped Postgres database name |
schema_name | string | The channel's schema inside that database |
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 role |
password_set | boolean | Whether the role has a stored password |
storage_bytes | integer | Total size of the channel's schema on disk |
Errors
| Status | Code | When |
|---|---|---|
| 404 | not_found | App, channel, or schema row not found |
Drop and recreate the channel's schema. The role and password are preserved, so the same DATABASE_URL keeps working.
confirm_schema_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_schema_name | string required | Must match the channel's current schema_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_schema_name": "ch_7c9e66797425"}'
Returns 204 No Content on success.
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_confirmation | confirm_schema_name does not match the channel's schema |
| 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 schema. Single schema only. 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 pre-deploy Postgres snapshots, newest first. A snapshot is taken automatically before each deploy.
Auth: Bearer token
[
{
"snapshot_id": "1f0e2d3c-4b5a-6978-8796-a5b4c3d2e1f0",
"deploy_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"created_at": "2026-07-20T09:12:00Z",
"size_bytes": 524288
}
]
Errors
| Status | Code | When |
|---|---|---|
| 404 | not_found | App, channel, or schema row not found |
Restore the channel's schema from a pre-deploy snapshot, 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_schema_name | string required | Must match the channel's current schema_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_schema_name": "ch_7c9e66797425", "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_schema_name does not match the channel's schema |
| 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 being moved to another server |
| 503 | postgres_unavailable | Postgres admin pool is not configured (degraded mode) |
Report total Postgres storage and schema 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,
"schema_count": 5
}
Response fields
| Field | Type | Description |
|---|---|---|
database_size_bytes | integer | Total size of the user's u_<user_id> database |
schema_count | integer | Number of ch_* schemas in that database |
Errors
| Status | Code | When |
|---|---|---|
| 503 | postgres_unavailable | Postgres admin pool is not configured (degraded mode) |
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 pre-deploy
snapshots.
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://s3.xhostd.com",
"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://s3.xhostd.com",
"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 pre-deploy object-store snapshots, newest first. Each marks the point-in-time of a deploy.
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"
}
]
Errors
| Status | Code | When |
|---|---|---|
| 404 | not_found | App, channel, or blob store not found |
Restore the channel's object-store prefix to a snapshot's point-in-time, replacing current objects. 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 | A snapshot id from the snapshots list. |
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 | Snapshot does not exist or is too old to restore |
| 409 | channel_busy | Channel is busy |
| 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. xhost 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 |
Tokens & credentials
Create a new API token for the authenticated user.
Auth: Bearer token
Request body
| Field | Type | Description |
|---|---|---|
label | string optional | Human-readable label (e.g. "ci", "laptop") |
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:*"],
"label": "ci",
"created_at": "2026-04-22T11:00:00Z"
}
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 30-day unified credential. The returned token serves as your git password, Postgres password, and platform API bearer.
Auth: Bearer token
Request body (optional)
| Field | Type | Description |
|---|---|---|
scopes | array of strings | When supplied, must be a non-empty subset of the default set (repo:*, deploy:*, channel:*, db:*, blob:*) — mints a least-privilege credential. Omit for the full default scopes. |
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:*"]
}
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 scope outside the default set |
Feedback
Submit free-text feedback to the xhost 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": "new"
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | bad_request | Empty message or message longer than 4000 characters |
Account & stats
Get dashboard statistics for the authenticated user.
Auth: Bearer token
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": 256.0,
"mem_percent": 17.7,
"cpu_current_percent": 2.5,
"cpu_avg_percent": 1.1,
"cpu_usage_sec": 142.5
},
"sites": [
{
"hostname": "my-site-alice.xhostd.com",
"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 usage against the plan's soft-cap limits: account-wide DB storage and current-month egress. Both are soft caps — over-limit warns, it never blocks.
Auth: Bearer token
{
"plan": "free",
"storage_bytes": 1572864,
"storage_limit_mb": 500,
"bandwidth_month_bytes": 104857600,
"bandwidth_limit_gb": 50
}
Errors
| Status | Code | When |
|---|---|---|
| 503 | postgres_unavailable | Postgres admin pool is not configured (degraded mode) |
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
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}]
}
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.
Errors
| Status | Code | When |
|---|---|---|
| 400 | bad_request | Invalid range |
| 404 | not_found | App or channel not found |
Aggregated traffic statistics summed across all of an app's channels. Same shape, range semantics, and errors as the per-channel stats endpoint above.
Auth: Bearer token
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 being moved to another server |
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 plaintext is returned once and expires with the export. Use it as the bearer on the download URLs.
Auth: Bearer token
{
"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.com | my-site-alice.xhostd.com |
| Any other | <channel>-<app>-<user>.xhostd.com | staging-my-site-alice.xhostd.com |
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) |
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.) |
bad_gateway | 502 | Upstream dependency failed |
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 and manage env vars | 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 |
OAuth-issued bearer tokens (used by the MCP server) and tokens minted via
POST /tokens carry all five default scopes. Credentials
minted via POST /credentials
carry the defaults unless a narrower scopes subset is
requested.