xhost
Sign in

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 header
Authorization: Bearer xh_abc123...

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

Error envelope

Every error response uses the same shape:

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

Endpoints

Signing up happens in the browser via Google sign-in at xhostd.com. There is no signup API. After signing in, create API tokens for CLI/agent use on the dashboard.

Apps

GET /apps

List all apps owned by the authenticated user.

Auth: Bearer token

Request
curl https://api.xhostd.com/apps \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
{
  "apps": [
    {
      "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
      "name": "my-site",
      "repo_url": "https://git.xhostd.com/alice/my-site.git",
      "template": "static",
      "created_at": "2026-04-22T10:30:00Z",
      "external_db_access_enabled": false,
      "external_blob_access_enabled": false,
      "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.

POST /apps

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

Auth: Bearer token — requires repo:* scope

Request body

FieldTypeDescription
namestring requiredApp name. DNS label rules: lowercase, digits, hyphens. Max 40 chars. Must not start with a reserved prefix (git, api, www, admin, preview, staging).
templatestring optionalApp template. static (default), app, or docker. With docker, the repo-root Dockerfile is built and run; the container must listen on $PORT, and per-plan image-size caps apply.
Request
curl -X POST https://api.xhostd.com/apps \
  -H "Authorization: Bearer $XHOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "my-site", "template": "static"}'
Response — 200
{
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "name": "my-site",
  "repo_url": "https://git.xhostd.com/alice/my-site.git",
  "template": "static",
  "created_at": "2026-04-22T10:30:00Z",
  "external_db_access_enabled": false,
  "external_blob_access_enabled": false,
  "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

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

Get details of a single app, including all channels.

Auth: Bearer token

Request
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479 \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
{
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "name": "my-site",
  "repo_url": "https://git.xhostd.com/alice/my-site.git",
  "template": "static",
  "created_at": "2026-04-22T10:30:00Z",
  "external_db_access_enabled": false,
  "external_blob_access_enabled": false,
  "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

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

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

Auth: Bearer token

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

Returns 204 No Content on success.

Errors

StatusCodeWhen
404not_foundApp does not exist or is not owned by the caller

Channels

GET /apps/{app_id}/channels

List all channels for an app.

Auth: Bearer token

Request
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
[
  {
    "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "name": "prod",
    "hostname": "my-site-alice.xhostd.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"
  }
]
POST /apps/{app_id}/channels

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

Auth: Bearer token — requires channel:* scope

Request body

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

Errors

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

Get details of a single channel.

Auth: Bearer token

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

Errors

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

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

Auth: Bearer token

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

Returns 204 No Content on success.

Errors

StatusCodeWhen
400bad_requestAttempted to delete the prod channel
404not_foundApp or channel not found
GET /apps/{app_id}/channels/{channel_id}/images

Live built-image inventory for one channel, newest first, plus the per-plan image-size cap. Only docker-template apps build images; other templates return an empty list.

Auth: Bearer token

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

Response fields

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

Errors

StatusCodeWhen
404not_foundApp or channel not found

Deploy & logs

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

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

Auth: Bearer token — requires deploy:* scope

Request body

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

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

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

Errors

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

Retrieve the build/deploy log for a specific deploy.

Auth: Bearer token

Query parameters

ParamTypeDescription
deployUUID requiredThe deploy ID returned by the deploy endpoint
Request
curl "https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/logs?deploy=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200 (text/plain)
[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

StatusCodeWhen
404not_foundDeploy not found, or log not yet available

Environment & secrets

POST /apps/{app_id}/env

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

Auth: Bearer token — requires deploy:* scope

Request body

FieldTypeDescription
keystring requiredEnv var name. Uppercase letters, digits, underscores. Must match ^[A-Z_][A-Z0-9_]*$. Reserved (rejected, system-injected per channel): XHOST_USER, XHOST_SHA, DATABASE_URL, DATABASE_HOST, DATABASE_PASSWORD, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION.
valuestring requiredThe value. Encrypted at rest.
kindstringenv (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_idUUIDOmit for an app-level default; set to a channel id for a per-channel override. At deploy time the channel override wins over the app default, and system-injected keys win over both.
Request
curl -X POST https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/env \
  -H "Authorization: Bearer $XHOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"key": "STRIPE_SECRET_KEY", "value": "sk_live_...", "kind": "secret"}'

Returns 204 No Content on success.

Errors

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

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

Auth: Bearer token

Query parameters

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

Returns 204 No Content on success.

Errors

StatusCodeWhen
404not_foundApp not found
GET /apps/{app_id}/env

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

Auth: Bearer token — requires deploy:* scope

Query parameters

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

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

Errors

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

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

Auth: Bearer token — requires deploy:* scope

Query parameters

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

Errors

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

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

Auth: Bearer token — requires deploy:* scope

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

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

Errors

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

Sign in with Google

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.

Verify, don't trust headers. Read identity from the verified __Host-xhost_id cookie (claims: iss, aud=hostname, sub, email, name, iat, exp). Pin RS256, select the key by kid, and check iss/aud/exp. 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:

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

Custom domains (per channel)

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

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

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

Auth: Bearer token

Request body

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

Errors

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

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

Auth: Bearer token

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

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

Auth: Bearer token

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

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

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

Auth: Bearer token

Response — 200
{"ok": true}

Postgres

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

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

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

Auth: Bearer token

Request
curl https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/postgres \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
{
  "db_name": "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

FieldTypeDescription
db_namestringThe user-scoped Postgres database name
schema_namestringThe channel's schema inside that database
role_namestringThe Postgres role used in DATABASE_URL
statusstringOne of provisioning, ready, failed
last_errorstring or nullProvisioner error message if status is failed
connection_countintegerLive connections currently held by this role
connection_limitintegerConfigured CONNECTION LIMIT for the role
password_setbooleanWhether the role has a stored password
storage_bytesintegerTotal size of the channel's schema on disk

Errors

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

Drop and recreate the channel's schema. The role and password are preserved, so the same DATABASE_URL keeps working.

Destructive. All data and migration history in the schema is permanently destroyed. This is intended for deliberate, human-approved use; the required 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

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

Returns 204 No Content on success.

Errors

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

Stream a pg_dump of the channel's schema. Single schema only. Useful for backups and migrating data between channels. Each dump is audit-logged.

Auth: Bearer token

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

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

Errors

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

List the channel's pre-deploy Postgres snapshots, newest first. A snapshot is taken automatically before each deploy.

Auth: Bearer token

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

Errors

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

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

FieldTypeDescription
confirm_schema_namestring requiredMust match the channel's current schema_name exactly. Acts as a typed confirmation.
snapshot_idUUID requiredA snapshot id from the snapshots list.
Request
curl -X POST https://api.xhostd.com/apps/f47ac10b-58cc-4372-a567-0e02b2c3d479/channels/7c9e6679-7425-40de-944b-e07fc1f90ae7/postgres/restore \
  -H "Authorization: Bearer $XHOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"confirm_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

StatusCodeWhen
400invalid_confirmationconfirm_schema_name does not match the channel's schema
403prod_restore_blockedprod channel without XHOST_ALLOW_PROD_RESTORE=1
404snapshot_not_foundSnapshot does not exist (or its file is missing)
409channel_busyChannel is busy, or the account is being moved to another server
503postgres_unavailablePostgres admin pool is not configured (degraded mode)
GET /me/postgres/storage

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

Auth: Bearer token

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

Response fields

FieldTypeDescription
database_size_bytesintegerTotal size of the user's u_<user_id> database
schema_countintegerNumber of ch_* schemas in that database

Errors

StatusCodeWhen
503postgres_unavailablePostgres 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.

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

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

Auth: Bearer token

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

Errors

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

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

Auth: Bearer token — requires blob:* scope

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

Errors

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

List the channel's pre-deploy object-store snapshots, newest first. Each marks the point-in-time of a deploy.

Auth: Bearer token

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

Errors

StatusCodeWhen
404not_foundApp, channel, or blob store not found
POST /apps/{app_id}/channels/{channel_id}/blob/restore

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

FieldTypeDescription
confirm_channel_namestring requiredMust match the channel's name exactly. Acts as a typed confirmation.
snapshot_idUUID requiredA snapshot id from the snapshots list.

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

Errors

StatusCodeWhen
400invalid_confirmationconfirm_channel_name does not match the channel's name
403prod_restore_blockedprod channel without XHOST_ALLOW_PROD_RESTORE=1
404snapshot_not_foundSnapshot does not exist or is too old to restore
409channel_busyChannel is busy
503blob_unavailableObject storage is unavailable
GET /me/blob/storage

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

Auth: Bearer token

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

GitHub repo mirroring

Connect an existing GitHub repo as an app's source of truth. 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.

POST /apps/{app_id}/github/connect

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

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

Request body

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

Errors

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

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

Auth: Bearer token

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

Errors

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

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

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

Errors

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

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

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

Returns 204 No Content on success.

Errors

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

Tokens & credentials

POST /tokens

Create a new API token for the authenticated user.

Auth: Bearer token

Request body

FieldTypeDescription
labelstring optionalHuman-readable label (e.g. "ci", "laptop")
Request
curl -X POST https://api.xhostd.com/tokens \
  -H "Authorization: Bearer $XHOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"label": "ci"}'
Response — 200
{
  "token_id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
  "plaintext": "xh_newtoken123...",
  "scopes": ["repo:*", "deploy:*", "channel:*", "db:*", "blob:*"],
  "label": "ci",
  "created_at": "2026-04-22T11:00:00Z"
}
Save the plaintext token. It is only returned once at creation time. Subsequent API calls reference the token by its token_id.
DELETE /tokens/{token_id}

Revoke a token. The token is immediately invalidated.

Auth: Bearer token

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

Returns 204 No Content on success.

Errors

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

Mint a 30-day unified credential. The returned token serves as your git password, Postgres password, and platform API bearer.

Auth: Bearer token

Request body (optional)

FieldTypeDescription
scopesarray of stringsWhen 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.
Request
curl -X POST https://api.xhostd.com/credentials \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
{
  "token": "xh_abc123...",
  "username": "alice",
  "expires_at": "2026-05-22T11:00:00Z",
  "scopes": ["repo:*", "deploy:*", "channel:*", "db:*", "blob:*"]
}

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

Errors

StatusCodeWhen
400bad_requestEmpty scopes list, unknown scope, or scope outside the default set

Feedback

POST /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

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

Errors

StatusCodeWhen
400bad_requestEmpty message or message longer than 4000 characters

Account & stats

GET /api/user/stats

Get dashboard statistics for the authenticated user.

Auth: Bearer token

Request
curl https://api.xhostd.com/api/user/stats \
  -H "Authorization: Bearer $XHOST_TOKEN"
Response — 200
{
  "username": "alice",
  "user_id": "550e8400-e29b-41d4-a716-446655440000",
  "platform": {
    "apps": 3,
    "channels": 5,
    "running_channels": 4,
    "deploys_last_hour": 1,
    "deploys_last_day": 7,
    "success_last_day": 6,
    "failed_last_day": 1
  },
  "resources": {
    "mem_current_mb": 45.2,
    "mem_limit_mb": 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

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

Per-account 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

Response — 200
{
  "plan": "free",
  "storage_bytes": 1572864,
  "storage_limit_mb": 500,
  "bandwidth_month_bytes": 104857600,
  "bandwidth_limit_gb": 50
}

Errors

StatusCodeWhen
503postgres_unavailablePostgres admin pool is not configured (degraded mode)
GET /me/apps/{app_id}/channels/{channel_id}/stats

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

Auth: Bearer token

Query parameters

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

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

StatusCodeWhen
400bad_requestInvalid range
404not_foundApp or channel not found
GET /me/apps/{app_id}/stats

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

Auth: Bearer token

GET /apps/{app_id}/events

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

Auth: Bearer token

Query parameters

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

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

Exports (takeout)

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

POST /exports

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

Auth: Bearer token

Request body

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

Errors

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

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

Auth: Bearer token

POST /exports/{export_id}/download-token

Mint a short-lived exports:read token for a ready, owned export. The plaintext is returned once and expires with the export. Use it as the bearer on the download URLs.

Auth: Bearer token

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

Errors

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

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

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

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

Errors

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

Reference

Hostname derivation

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

ChannelHostname patternExample
prod<app>-<user>.xhostd.commy-site-alice.xhostd.com
Any other<channel>-<app>-<user>.xhostd.comstaging-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

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

Deploy status values

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

git_ref_binding format

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

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

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

Error codes

CodeHTTP StatusMeaning
auth_required401No token provided or token is invalid
token_invalid401Token does not exist or invite is invalid
token_revoked401Token has been revoked
scope_denied403Token lacks the required scope
permission_denied403Caller is not authorized for this action (e.g. not admin)
admin_not_configured403Admin user has not been bootstrapped on this instance
not_found404Resource does not exist or is not owned by the caller
bad_request400Invalid input (name format, reserved name, etc.)
bad_gateway502Upstream dependency failed
internal_error500Unexpected server error

Token scopes

ScopeGrants access toDefault
repo:*Create and manage apps (git repo provisioning), push to git reposYes
deploy:*Trigger deploys and manage env varsYes
channel:*Create and delete channelsYes
db:*Connect to Postgres through the database gateway (external DB access)Yes
blob:*Mint object-storage credentials for a channelYes

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.